anjia / blog

博客,积累与沉淀
106 stars 4 forks source link

Web Components #90

Open anjia opened 2 years ago

anjia commented 2 years ago

作为开发人员,我们都知道“尽可能地重用代码”是个很不错的办法。Web Components 就旨在创建可复用的自定义元素,它的功能是封装起来的,并不会影响到页面上的其它代码。

Web Components 由三个主要技术组成的,分别是:

  1. 自定义元素:一组 JavaScript API,用来定义自定义元素及其行为
  2. Shadow DOM:一组 JavaScript API,用来将功能封装的 Shadow DOM 树附加到页面元素上,并控制其功能。Shadow DOM 的样式和脚本是单独渲染的,所以不用担心它会和页面上的其它代码产生冲突。
  3. HTML 模板:元素 <template><slot> 用来写标记模板,它们不会渲染到页面上,非常适合写那些可以被自定义元素多次复用的代码结构。

custom elements, 自定义元素
shadow DOM, 影子 DOM
HTML templates, HTML 模板

自定义元素

在 Web 文档上,自定义元素的控制器是 CustomElementRegistry 对象,该对象允许我们在页面上注册一个自定义元素,并返回相关信息。它有 4 个方法,如下;

方法 说明
CustomElementRegistry.define() 定义一个新的自定义元素
CustomElementRegistry.get() 返回自定义元素的构造函数
CustomElementRegistry.upgrade() 直接升级自定义元素
CustomElementRegistry.whenDefined() 返回一个空的 promise,它会在自定义元素被定义的时候 resolves。
如果该元素已经被定义了,那么返回的 promise 就会被立即 fulfilled。

CustomElementRegistry 接口提供了注册自定义元素和查询已注册元素的方法,我们可以通过 window.customElements 属性获取到它的实例。

注册

当我们想在页面上注册一个自定义元素的时候,可以使用 customElements.define() 方法,它有 3 个参数,分别是:

第一个参数是 DOMString,表示自定义元素的名称。名称必须包含字符-,而不能是单个单词。DOMString 是一个 16 位无符号整数,通常解释为 UTF-16 代码单元,这完全对应于 JavaScript 的原始类型 String。当一个 Web API 接受 DOMString 时,提供的值会被字符串化,使用 ToString()

  • 对于 Symbol 以外的类型,调用 ToString() 会和 String() 函数具有相同的行为。
  • 某些接受 DOMString 的 Web API 有个历史遗留问题,那就是会把 null 转成空字符串而不是 "null"。

第二个参数是 class 对象,它定义了元素的行为。

第三个参数是可选的,是一个对象,其中 extends 属性指定了该自定义元素继承的内置元素。

eg.

// 自定义元素的名称是 word-count,类 WordCount 定义了它的功能,它继承自元素 <p>
customElements.define("word-count", WordCount, { extends: "p" });

两种类型

有两种类型的自定义元素:

autonomous custom elements,自治的自定义元素
customized built-in elements,自定义的内置元素

一种是自治的自定义元素,它不继承标准的 HTML 元素。我们可以像使用普通的 HTML 元素那样使用它,比如:

<popup-info data-text="the info"></popup-info>

或者

document.createElement("popup-info");

注册它的时候,大约长这样:

class PopUpInfo extends HTMLElement {
  constructor() {
    super();
    // ...
  }
}
customElements.define("popup-info", PopUpInfo);

还有一种是自定义的内置元素,它继承自基本的 HTML 元素。创建时,就得指明它所继承的元素;使用时,是写基本元素,然后通过 is 属性来指定自定义元素的名称。比如:

<!-- is 是 HTML 的全局属性,允许我们指定标准 HTML 元素的行为应该类似于已注册的自定义内置元素 -->
<p is="word-count"></p>

或者

// is 选项允许我们创建一个标准 HTML 元素的实例,其行为类似于给定的已注册的自定义内置元素
document.createElement("p", { is: "word-count" });

注册它的时候,需要显式指定所继承的元素。如下:

// HTMLParagraphElement
class WordCount extends HTMLParagraphElement {
  constructor() {
    super();
    // ...
  }
}
// { extends: "p" }
customElements.define("word-count", WordCount, { extends: "p" });

生命周期

我们可以在自定义元素的类定义中,定义几个不同的回调,它们会在元素生命周期的不同点触发。如下:

回调函数 执行时机
connectedCallback 当自定义元素被附加到文档时
- 这将发生在每次移动节点的时候,可能是在元素的内容被完全解析之前
- 当自定义元素不再和文档连接时也会被执行,所以用 Node.isConnected 判断下
disconnectedCallback 当自定义元素和文档 DOM 断开连接时
adoptedCallback 当自定义元素移动到新文档时
attributeChangedCallback 当自定义元素的属性有添加/删除/更改时
要监听哪些属性是在静态 get 方法 observedAttributes() 中指定的
class DemoLifeCycle extends HTMLElement {

  // 指定要监听的属性
  static get observedAttributes() {
    return ['c', 'l'];
  }

  constructor() {
    super();
    // ...
  }

  // 生命周期的回调们,作为类的方法
  connectedCallback() {
    console.log('added to page');
  }

  disconnectedCallback() {
    console.log('removed from page');
  }

  adoptedCallback() {
    console.log('moved to new page');
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log('attributes changed');
  }
}

小结

Shadow DOM

Web 组件的封装功能能够将标签结构、样式和行为给隐藏起来,然后和页面上的其它代码分开,这样就不用担心代码冲突了而且还能保持代码整洁。Shadow DOM API 就是这其中的关键部分,它能将功能封装的 DOM 附加到元素上。

Shadow DOM 挺实用的

Shadow DOM 允许将“隐藏的 DOM 树”附加到常规 DOM 树中的元素上,这个“隐藏的 DOM 树”就是 Shadow DOM 树。Shadow DOM 树的根节点是 shadow root,我们可以使用常规的 DOM API 将任何元素附加到 shadow root 上。关系如下图:

DOM tree, shadow host → shadow DOM root, shadow DOM tree

相关术语:

结合下文的一个例子,来感受下这 4 个术语。如下图:

我们可以用 Element.attachShadow() 方法将 shadow root 附加到任何元素上,如下:

let shadow1 = elementRef1.attachShadow({ mode: "open" });
let shadow2 = elementRef2.attachShadow({ mode: "closed" });

Shadow DOM 不算是一个全新的技术,因为其实浏览器有一直在用它来封装元素的内部结构,比如 <video> 元素,我们在 DOM 中只看到了一个 <video>,其实它在其 Shadow DOM 中包含了一系列的按钮和其它控件。Shadow DOM 规范将此技术扩展到了自定义元素中。

在自定义元素中使用

我们可以将 Shadow DOM 附加到自定义元素上(迄今为止 Shadow DOM 最有用的 application),代码如下:

<my-p info="Hello, shadow DOM!"></my-p>
customElements.define('my-p', class extends HTMLElement {
    constructor() {
        super();

        // this 即自定义元素本身
        // 将 shadow root 附加到自定义元素上
        let shadow = this.attachShadow({ mode: 'open' });

        // 构造 shadow DOM
        let style = document.createElement('style');
        style.textContent = 'p { background-color: pink; }';
        let p = document.createElement('p');
        p.innerText = this.getAttribute('info') || 'Hello world!';

        // 向 shadow root 添加子元素
        shadow.appendChild(style);
        shadow.appendChild(p);
    }
});

最终的 DOM 树和渲染的 UI 如下:

内联样式和外联样式

在上面的例子中,我们使用 <style> 元素将样式应用在了 Shadow DOM 上。当然,我们也可以使用 <link> 元素来引用外部样式,代码如下:

let link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('href', 'style.css');

shadow.appendChild(link);

最终的 DOM 树和渲染的 UI 如下:

说明:

HTML 模板

本节将介绍如何使用 <template><slot> 元素来创建灵活的模板,然后用它来填充 Web Component 中的 Shadow DOM。

<template>

HTML 的 <template> 元素是一种保存 HTML 的机制,页面在加载的时候它是不会被渲染的,但可以通过 JavaScript 在运行时访问到。虽然在加载页面的时候,解析器确实会处理 <template> 元素的内容,但这样做的目的只是为了确保内容是有效的。我们可以将 <template> 视为先暂存起来以便后续使用的内容片段。

content fragment, 内容片段

<template>对应的 DOM 接口是 HTMLTemplateElement,它的 content 属性包含了模板所代表的 DOM 子树。

来看个单独使用 <template> 的例子,代码如下:

<template id="temp-p">
  <p>Hello world!</p>
</template>

<script>
  // 能被 JavaScript 访问到
  let template = document.getElementById("temp-p");
  // 要想将其内容渲染到页面上,需要手动操作
  let templateContent = template.content;
  document.body.appendChild(templateContent);
</script>

最终的 DOM 树和渲染的 UI 如下:

注意:HTMLTemplateElement 的 content 属性是一个只读的 DocumentFragment,而 DocumentFragment 不是各种事件的有效目标,所以最好是克隆一份 content 的内容或是引用其内部的元素。直接使用 content 的值可能会导致不符合预期的行为,比如:

<div id="container"></div>

<template id="template">
    <div>click me</div>
</template>

<script>
    const container = document.getElementById('container');
    const template = document.getElementById('template');

    function clickHandler(event) {
        console.log('clicked');
        event.target.append('— Clicked');
    }

    // firstClone 是一个 DocumentFragment 实例,单击它是不会触发 click 事件的
    const firstClone = template.content.cloneNode(true);
    firstClone.addEventListener('click', clickHandler);
    container.appendChild(firstClone);

    // secondClone 是一个 HTMLDivElement 实例,单击它可以正常触发 click 事件
    const secondClone = template.content.firstElementChild.cloneNode(true);
    secondClone.addEventListener('click', clickHandler);
    container.appendChild(secondClone);
</script>

DocumentFragment 实例响应不了各种事件

在 Web Components 中使用模板

HTML 模板本身很有用,但和 Web Components 一起,会工作得更好。看个例子,如下:

<!-- 自定义元素 -->
<my-p></my-p>

<template id="temp-p">
  <p>Hello world!</p>
</template>

<script>
customElements.define('my-p', class extends HTMLElement {
    constructor() {
        super();

        let template = document.getElementById('temp-p');
        let templateContent = template.content;

        // 将模板内容的 clone 附加到 shadow root 上
        let shadowRoot = this.attachShadow({ mode: 'open' });
        shadowRoot.appendChild(templateContent.cloneNode(true));
    }
});
</script>

最终的 DOM 树和渲染的 UI 如下:

由于我们是将模板的整个内容都附加到了自定义元素上,所以就可以在模板里写 <style> 样式了。如下:

<template id="temp-p">
    <!-- 新增 <style> 元素 -->
    <style>
        p {
            background-color: pink;
        }
    </style>
    <p>Hello world!</p>
</template>

此时,最终的 DOM 树和渲染的 UI 会如下图所示:

注意,如果是将带 <style> 的模板附加到普通的 DOM 上,样式是不会生效的。

上面例子中定义的自定义元素 <my-p> 只能显示 "Hello world!" 文本。接下来,我们用 <slot> 对它进行下改造,用一种友好的声明方式让不同的元素实例可以显示不同的内容。

<slot>

HTML 的<slot>元素是 Web Components 里的占位符(placeholder),它允许我们在模板中定义一个插槽(slot),然后再用任意的标记片段(markup fragment)来填充。插槽由它的 name 属性来标识的。

slot, 插槽
a long narrow opening, into which you put or fit sth

要想使用 <slot>,可以像下面这样改造我们的代码。

首先,在 <template> 中定义一个 name 为 "content" 的 <slot>。如下:

<template id="temp-p">
    <style>
        p {
            background-color: pink;
        }
    </style>
    <p>
        <!-- 新增 <slot> 标签 -->
        <slot name="content">The default text.<slot>
    </p>
</template>

然后,在页面中使用自定义元素的时候,在它里面包含一个 slot 属性等于 "content"(即要填充的 <slot> 的 name 属性的值)的标签即可。标签的 HTML 结构可以是任意的。比如:

<my-p></my-p>
<my-p>
    <span slot="content">Hi, slot!</span>
</my-p>
<my-p>
    <ul slot="content">
        <li>text</li>
        <li>text</li>
        <li>text</li>
    </ul>
</my-p>
  • 可以插入插槽的节点称为 Slottable 节点
  • 当节点已经插入到对应插槽中时,我们就说它是有槽的(slotted)

最终渲染的 UI 如下:

当在页面中使用自定义元素时,如果插槽的内容没有定义或是浏览器不支持插槽,那么自定义元素就会回退到只显示默认文本 "The default text."。此外,在模板中未命名的 <slot> 将填充自定义元素的所有没有 slot 属性的顶级子节点,包括文本节点。

小结

这里用一个相对复杂的 <template><slot> 的例子做个小结。

注册自定义元素,代码如下:

customElements.define('element-details',
    class extends HTMLElement {
        constructor() {
            super();
            const template = document.getElementById('element-details-template');
            const shadowRoot = this.attachShadow({ mode: 'open' });
            shadowRoot.appendChild(template.content.cloneNode(true));
        }
    }
);

HTML 模板,代码如下:

<template id="element-details-template">
    <style>
        .name {
            font-weight: bold;
            color: #217ac0;
            font-size: 120%
        }
        h4 {
            margin: 10px 0 -8px 0;
        }
        h4 span {
            background: #217ac0;
            padding: 2px 6px 2px 6px;
            border: 1px solid #cee9f9;
            border-radius: 4px;
            color: white;
        }
        .attributes {
            margin-left: 22px;
            font-size: 90%
        }
        .attributes p {
            margin-left: 16px;
            font-style: italic;
        }
    </style>
    <details>
        <summary>
            <span>
                <code class="name">&lt;<slot name="element-name">元素名称</slot>&gt;</code>
                <i class="desc">
                    <slot name="description">这里是描述</slot>
                </i>
            </span>
        </summary>
        <div class="attributes">
            <h4><span>属性</span></h4>
            <slot name="attributes">
                <p>无</p>
            </slot>
        </div>
    </details>
    <hr>
</template>

在页面中使用自定义元素,代码如下:

<style>
    dl {
        margin-left: 6px;
    }
    dt {
        font-weight: bold;
        color: #217ac0;
        font-size: 110%
    }
    dd {
        margin-left: 16px
    }
</style>

<element-details></element-details>

<element-details>
    <span slot="element-name">template</span>
    <span slot="description">一种用于保存客户端内容的机制。页面在加载的时候它是不会被渲染的,但可以通过 JavaScript 在运行时访问到。</span>
</element-details>

<element-details>
    <span slot="element-name">slot</span>
    <span slot="description">web component 里的占位符。它允许我们在模板中定义一个插槽,然后再用任何的标记片段来填充。</span>
    <dl slot="attributes">
        <dt>name</dt>
        <dd>插槽的名称</dd>
    </dl>
</element-details>

<element-details>
    <span slot="element-name">a</span>
    <span slot="description">超链接</span>
    <dl slot="attributes">
        <dt>href</dt>
        <dd>超链接指向的 url。可以是普通的 url, <code>tel:</code>, <code>mailto:</code>等。</dd>
        <dt>target</dt>
        <dd>在哪里显示该链接。可以是<code>_blank</code>, <code>_self</code>, <code>_parent</code>, <code>_top</code> 等。</dd>
    </dl>
</element-details>

最终渲染的 UI 如下:

总结

实现一个 Web Component 的基本方法,通常如下:

  1. 用 ES2015 中的 class 语法创建一个类,指定 Web Component 的功能。
  2. customElements.define() 注册一个新的自定义元素。此时,需要指定元素名称、实现其功能的类,有时也需要指定它所继承的父元素(自治的自定义元素 or 自定义的内置元素)。
  3. 如果需要,用 Element.attachShadow() 给自定义元素附加 Shadow DOM。此时,可以使用常规的 DOM 方法给 Shadow DOM 添加子元素和事件监听器等。
  4. 如果需要,用 <template><slot> 定义 HTML 模板。然后再用常规的 DOM 方法复制一份模板并将其附加到 Shadow DOM 上。
  5. 最后一步,在页面的任意位置使用自定义元素,就像使用普通的 HTML 元素一样。