🗒️Web Component

custom element, shadow DOM, HTML template

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

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

  1. 自定义元素(Custom Element):一组 JavaScript API,用来定义自定义元素及其行为。

  2. Shadow DOM:一组 JavaScript API,用来将功能封装的 Shadow DOM 树附加到页面元素上,并控制其功能。Shadow DOM 的样式和脚本是单独渲染的,所以不用担心它会和页面上的其它代码产生冲突。

  3. HTML 模板(HTML template):用元素 <template><slot> 来写标记模板,它们不会渲染到页面上,非常适合写那些可以被自定义元素多次复用的代码结构。

1. 自定义元素

在 Web 文档上,自定义元素的控制器是 CustomElementRegistry 对象,它允许我们在页面上注册一个自定义元素,并返回相关信息。可以通过 window.customElements 属性获取到它的实例,它提供了注册自定义元素和查询已注册元素的方法。

方法
说明

define()

定义一个新的自定义元素

get()

返回自定义元素的构造函数

upgrade()

直接升级自定义元素

whenDefined()

返回一个空的 promise,它会在自定义元素被定义的时候 resolves。

如果该元素已经被定义了,那么返回的 promise 就会被立即 fulfilled。

1.1 注册/定义

当我们想在页面上注册一个自定义元素的时候,可以使用 customElements.define() 方法。

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

它有三个参数,分别是:

  1. DOMString,表示自定义元素的名称

    • 名称必须包含字符 -,不能是单个单词

    • DOMString 是一个 16 位无符号整数,通常解释为 UTF-16 代码单元,这完全对应于 JavaScript 的原始类型 String

    • 当一个 Web API 接受 DOMString 时,提供的值会用 ToString() 被字符串化

      • 对于 Symbol 以外的类型,调用 ToString() 会和 String() 函数具有相同的行为

      • 某些 Web API 有个历史遗留问题:会把 null 转成空字符串而不是 "null"

  2. class 对象,定义了元素的行为

  3. 可选的,是一个对象,其中 extends 属性指定了该自定义元素继承的内置元素

1.2 两种类型

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

  • autonomous custom elements,自治的自定义元素

  • customized built-in elements,自定义的内置元素(方便 SEO 和 A11Y)

(1)自治的自定义元素

它不继承标准的 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);

(2)自定义的内置元素

它继承自基本的 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" });

1.3 生命周期

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

  1. connectedCallback

    • 执行时机:当自定义元素被附加到文档时

    • 这将发生在每次移动节点的时候,可能是在元素的内容被完全解析之前

    • 当自定义元素不再和文档连接时也会被执行,所以用 Node.isConnected 判断下

  2. disconnectedCallback

    • 执行时机:当自定义元素和文档 DOM 断开连接时

  3. adoptedCallback

    • 执行时机:当自定义元素移动到新文档时

  4. 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');
  }
}

使用说明:

  1. 自定义元素的渲染,是放在 constructor() 中,还是 connectedCallback() 中?

    • 有一种说法:应该放 connectedCallback(),因为在 constructor() 中调用 getAttribute() 会得到 null,那时实例被创建了但是还未插入页面。但是根据我的测试,constructor() 的调用时机就是真正插入到 HTML 中时,且 getAttribute() 能正常取到值

    • 另外,connectedCallback() 的调用时机(移动节点或移除节点时也会被调用),总觉得在语义上不适合做组件的初次渲染

    • 所以,个人更倾向于放在 constructor()

  2. 关于生命周期,在使用上需要深入测试下它们会被哪些 DOM API 触发

2. Shadow DOM

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

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

在开发者工具里,开启“Show user agent shadow DOM”,就能看到浏览器自己用 shadow DOM 实现的 HTML 元素。

比如 <input>, <video>

2.1 隐藏的 DOM 树

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

  1. shadow tree:Shadow DOM 内部的 DOM tree

  2. shadow root:shadow tree 的根节点

  3. shadow host:常规的 DOM 节点,即附加 Shadow DOM 的那个节点

  4. shadow boundary:Shadow DOM 结束 & 常规 DOM 开始的地方

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

我们可以用 Element.attachShadow() 将 shadow root 附加到任何元素上。比如:

let shadow1 = elementRef1.attachShadow({ mode: "open" });
let shadow2 = elementRef2.attachShadow({ mode: "closed" });
  1. open 意味着我们可以在主页面的上下文里使用 JavaScript 来访问 Shadow DOM,即 elementRef.shadowRoot

  2. closed 则意味着我们在主页面里访问不到其 Shadow DOM,此时 elementRef.shadowRoot 会返回 null

  3. 以下元素可以被附加一个 shadow root

    • autonomous custom element(自治的自定义元素)

    • 除了 <address> 之外的内容块元素<body>, <main>, <aside>, <nav>, <article>, <header>, <footer>, <h1>~<h6>, <section>

    • <div>, <p>, <blockquote>

    • <span>

至此,有两种 DOM tree 了——普通 tree 和 Shadow DOM tree,以后在使用 DOM API 时稍稍留意下

2.2 在自定义元素中使用

我们可以将 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 如下:

2.3 内联样式和外联样式

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

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

shadow.appendChild(link);

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

说明:

  • <link> 元素不会阻止 shadow root 的绘制,所以在样式表加载完毕之后可能会出现视觉抖动

  • 许多现代浏览器对 <style> 标签进行了优化以允许它们共享单个样式表,这样的话内部样式和外部样式在性能上应该是差不多的

2.4 使用说明

  1. shadow DOM 还可以附加到普通元素上

    • 虽然很少见,但很直观,方便我们理解 shadow DOM(如下图)

    • 当一个普通元素下同时存在普通 DOM 和 shadow DOM 时,会渲染 shadow DOM

  2. 样式关系

    • shadow DOM 会继承 shadow host 的样式(如下图)

    • 外面想影响里面的样式,可以使用 CSS 变量、伪元素 ::part()

    • 里面想影响外面的样式,只能影响到 shadow host,用伪类 :host

    • 当内外设置了同一个属性,外面的文档样式优先,除非内部有 !important

  3. 在给 shadow root 添加子元素时,是用 innerHTML 还是 appendChild()

    • 当和自定义元素搭配使用时,一般用后者,主要考虑到通常都会在 class 里操作 DOM,因此可顺手将其作为 class 的实例属性

  4. 事件传播

该例子中 shadow host 是 <div> 所以 shadow DOM 会继承 <div> 自己的及其继承的样式(如绿色标出的) 而 shadow DOM 内的其它样式,内外互不影响(如红色标出的)

3. HTML 模板

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

3.1 <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>

3.2 配合自定义元素

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> 对它进行下改造,用一种友好的声明方式让不同的元素实例可以显示不同的内容。

3.3 <slot>

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

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

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

<template id="temp-p">
    <style>
        p {
            background-color: pink;
        }
    </style>
    <p>
        <!-- 新增 <slot> 标签 -->
        <!-- slot: a long narrow opening, into which you put or fit sth -->
        <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>
    <!-- 可以插入插槽的节点称为 Slottable 节点 -->
    <!-- 当节点已经插入到对应插槽中时,我们就说它是有槽的(slotted) -->
    <ul slot="content">
        <li>text</li>
        <li>text</li>
        <li>text</li>
    </ul>
</my-p>

最终渲染的 UI 如下:

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

3.4 小结

这里用一个相对复杂的 <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 如下:

4. 总结

实现一个 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 元素一样

Last updated