🗒️Web Component
custom element, shadow DOM, HTML template
作为开发人员,我们都知道“尽可能地重用代码”是个很不错的办法。Web Component 就旨在创建可复用的自定义元素,它的功能是封装起来的,并不会影响到页面上的其它代码。
Web Component 由三个主要技术组成,分别是:
自定义元素(Custom Element):一组 JavaScript API,用来定义自定义元素及其行为。
Shadow DOM:一组 JavaScript API,用来将功能封装的 Shadow DOM 树附加到页面元素上,并控制其功能。Shadow DOM 的样式和脚本是单独渲染的,所以不用担心它会和页面上的其它代码产生冲突。
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" });
它有三个参数,分别是:
DOMString,表示自定义元素的名称
名称必须包含字符
-
,不能是单个单词DOMString 是一个 16 位无符号整数,通常解释为 UTF-16 代码单元,这完全对应于 JavaScript 的原始类型 String
当一个 Web API 接受 DOMString 时,提供的值会用
ToString()
被字符串化对于 Symbol 以外的类型,调用
ToString()
会和String()
函数具有相同的行为某些 Web API 有个历史遗留问题:会把
null
转成空字符串而不是 "null"
class
对象,定义了元素的行为可选的,是一个对象,其中
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 生命周期
我们可以在自定义元素的类定义中,定义几个不同的回调,它们会在元素生命周期的不同点触发。
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');
}
}
使用说明:
自定义元素的渲染,是放在
constructor()
中,还是connectedCallback()
中?有一种说法:应该放
connectedCallback()
,因为在constructor()
中调用getAttribute()
会得到null
,那时实例被创建了但是还未插入页面。但是根据我的测试,constructor()
的调用时机就是真正插入到 HTML 中时,且getAttribute()
能正常取到值另外,
connectedCallback()
的调用时机(移动节点或移除节点时也会被调用),总觉得在语义上不适合做组件的初次渲染所以,个人更倾向于放在
constructor()
里
关于生命周期,在使用上需要深入测试下它们会被哪些 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 元素。

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

shadow tree:Shadow DOM 内部的 DOM tree
shadow root:shadow tree 的根节点
shadow host:常规的 DOM 节点,即附加 Shadow DOM 的那个节点
shadow boundary:Shadow DOM 结束 & 常规 DOM 开始的地方
结合下文的一个例子,来感受下这 4 个术语。
我们可以用 Element.attachShadow()
将 shadow root 附加到任何元素上。比如:
let shadow1 = elementRef1.attachShadow({ mode: "open" });
let shadow2 = elementRef2.attachShadow({ mode: "closed" });
open
意味着我们可以在主页面的上下文里使用 JavaScript 来访问 Shadow DOM,即elementRef.shadowRoot
closed
则意味着我们在主页面里访问不到其 Shadow DOM,此时elementRef.shadowRoot
会返回null
以下元素可以被附加一个 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 使用说明
shadow DOM 还可以附加到普通元素上
虽然很少见,但很直观,方便我们理解 shadow DOM(如下图)
当一个普通元素下同时存在普通 DOM 和 shadow DOM 时,会渲染 shadow DOM
样式关系
shadow DOM 会继承 shadow host 的样式(如下图)
外面想影响里面的样式,可以使用 CSS 变量、伪元素
::part()
里面想影响外面的样式,只能影响到 shadow host,用伪类
:host
当内外设置了同一个属性,外面的文档样式优先,除非内部有
!important
在给 shadow root 添加子元素时,是用
innerHTML
还是appendChild()
当和自定义元素搭配使用时,一般用后者,主要考虑到通常都会在 class 里操作 DOM,因此可顺手将其作为 class 的实例属性
事件传播

3. HTML 模板
本节将介绍如何使用 <template>
和 <slot>
元素来创建灵活的模板,然后用它来填充 Web Component 中的 Shadow DOM。
3.1 <template>
<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>
<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"><<slot name="element-name">元素名称</slot>></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 的基本方法,通常如下:
用 ES2015 中的
class
语法创建一个类,指定 Web Component 的功能用
customElements.define()
注册一个新的自定义元素。此时,需要指定元素名称、实现其功能的类,有时也需要指定它所继承的父元素(自治的自定义元素 or 自定义的内置元素)如果需要,用
Element.attachShadow()
给自定义元素附加 Shadow DOM。此时,可以使用常规的 DOM 方法给 Shadow DOM 添加子元素和事件监听器等如果需要,用
<template>
和<slot>
定义 HTML 模板。然后再用常规的 DOM 方法复制一份模板并将其附加到 Shadow DOM 上最后一步,在页面的任意位置使用自定义元素,就像使用普通的 HTML 元素一样
Last updated