Appearance
web components
web components 浏览器原生支持的组件化方案,实现自定义标签,隔离样式和行为。
核心概念
参看示例定义一个可复用的自定义元素,涉及如下核心概念
- Custom Elements 自定义元素
- 采用
extends HTMLElement
采用扩展 HTMlElement 或者内置的标签元实现自定义元素和原生标签的增强使 customElements.define
利用该方法注册自定义元素,使得浏览器可以正确识别并解析自定义标签connectedCallback
通过内置的生命周期钩子控制自定义元素的行为
- 采用
- Shadow DOM 通过 Element 的
attachShadow
方法创建 shadow DOM, 利用 HTMLElement 的shadowRoot
属性访问 shadowRoot ,通过shadowRoot.innerHTML
或者appendChild
等方法注入style
和标签实现自定义元素的内容和样式,注意 shadow DOM 的样式和 DOM 树的样式完全隔离不会互相影响 - Templates and slots 通过
template
标签定义模板,利用<slot>、<slot name="xx">
定义插槽, 在消费自定义组件的时候通过slot="xx"
指定默认和具名插槽,实现定制化的嵌套组件
上面技术共同实现了 Web Components 的核心特性。涉及的规范如下, 具体细节可以参考 webcomponents 规范
html
<!-- 1. 定义模版和插槽 -->
<template id="my-element-template">
<style>
:host {
display: block;
width: 200px;
height: 100px;
background: lightgray;
border: 1px solid black;
margin: 10px;
}
/* 直接作用于模板中的 h2,而不是宿主元素 */
h2.title {
color: red;
}
:host(.highlight) {
border-color: orange;
}
</style>
<h2 class="title">这是标题</h2>
<slot></slot>
</template>
<script>
//2. 创建自定义元素
class MyElement extends HTMLElement {
constructor() {
super();
// 3. 创建 shadow DOM
const shadowRoot = this.attachShadow({ mode: 'open' });
const template = document.getElementById('my-element-template');
const templateContent = template.content;
// 4. 插入模版到 shadow DOM
shadowRoot.appendChild(templateContent.cloneNode(true));
}
set title(value) {
this.setAttribute('title', value);
if (this.shadowRoot) {
const h2 = this.shadowRoot.querySelector('h2');
if (h2) {
h2.textContent = value;
}
}
}
// 5. 绑定挂载回调
connectedCallback() {
if (this.hasAttribute('title')) {
const title = this.getAttribute('title');
this.title = title;
}
}
}
// 6. 全局自定义元素
customElements.define('my-element', MyElement);
</script>
<!-- 7. 使用自定义元素 -->
<my-element class="highlight" title="自定义标题">
<p>这是一个插槽</p>
</my-element>
<!-- 8. 使用自定义元素 -->
<my-element title="自定义标题">
<p>这是一个插槽</p>
</my-element>
Shadow DOM
浏览器在 Element 元素的实例上定义了 attachShadow
方法,用于创建一个新的 shadow root。可以在元素上通过 shadowRoot
属性访问 shadow root。
html
<div id="root"></div>
<script>
const el = document.getElementById('root');
const shadowRoot = el.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<p>自定义元素</p>`
// 注意返回的 shadowRoot 可以通过 el.shadowRoot 访问
console.log(shadowRoot === el.shadowRoot);
</script>
attachShadow
attachShadow(options) 支持的核心配置 options
包括
mode
open
通过element.shadowRoot
可以访问 shadow rootclosed
通过element.shadowRoot
无法访问 shadow root, 返回的为 null, 如果不期望外部通过 api 修改 shadow dom 则可以设置为 closed
delegatesFocus
该配置决定了当 shadow host 获取焦点时,焦点是否会自动转移到 shadow DOM 内部的第一个可聚焦元素上,默认值为 false
WARNING
注意不是所有元素都支持 attachShadow 方法, 例如 video, input
等元素由于本省就是自定义元素所以不支持 attachShadow 方法
此外一个元素只能有一个 shadow root, 多次调用 attachShadow 方法会抛出异常
shadowRoot
attachShadow
方法会返回一个 shadow root 元素, 该元素集继承 DocumentFragment 对象,整个原型链为
ShadowRoot -> DocumentFragment -> Node -> EventTarget -> Object
意味着你可以使用原型链上的所有方法和属性操作 shadow root。
可以使用例如 innerHTML
或者 appendChild
等方法向 shadow root 中注入内容和样式,注入的样式只作用于 shadow DOM 内部,不会影响外部的样式。外部的样式也无法影响 Shadow DOM 内部的元素。
html
<style>
/* 注意这里的样式无法影响 shadow DOM 内部元素 */
p {
background: orange;
color: purple;
}
</style>
<div id="root"></div>
<p>外部元素</p>
<script>
const el = document.getElementById('root');
const shadowRoot = el.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
/* 注意这里的样式不会影响外部的 <p> 元素 */
p {
background: lightgray;
color: red;
}
</style>
<p>自定义元素</p>
`
</script>
TIP
实际上外部根元素上设置的属性,如果具有继承关系,例如 color, font-size
等属性,shadow DOM 内部的元素是可以继承到的,后有详述
innerHTML vs appendChild
可以采用 innerHTML
或者 appendChild
来设置 shadow DOM 内的元素。 由于 innerHTML 是一个字符串,因此对于 script 标签内容无法正常解析,但是可以创建 script 标签后通过 appendChild
添加到 shadow DOM 内部。触发执行,具体可以参考 script in shadow dom
html
<style>
/* 注意这里的样式无法影响 shadow DOM 内部元素 */
p {
background: orange;
color: purple;
}
</style>
<div id="root"></div>
<div id="root1"></div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `<script>alert('自定义元素')<\/\script><p>自定义元素, alert 无法触发</p>`;
const el1 = document.getElementById("root1");
const shadowRoot1 = el1.attachShadow({ mode: "open" });
const fragment = document.createDocumentFragment();
const script = document.createElement("script");
script.textContent = "alert('自定义元素1')";
fragment.appendChild(script);
const p = document.createElement("p");
p.textContent = "自定义元素1, alert 可以触发";
fragment.appendChild(p);
shadowRoot1.appendChild(fragment);
</script>
style
shadowroot 内部的样式和外部完全隔离不会相互影响
html
<style>
h1 {
color: red;
}
</style>
<h1>外部样式</h1>
<div id="root"></div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
h1 {
color: blue;
}
</style>
<h1>内部样式</h1>
`;
</script>
你也可以通过导入外部样式表来控制 shadow DOM 内部的样式
html
<style>
h1 {
color: red;
}
</style>
<h1>外部样式</h1>
<div id="root"></div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<h1 class="text-primary fs-2 fw-bold">内部样式 (Bootstrap)</h1>
`;
</script>
继承属性
custom properties
虽然 shadow DOM 内部的样式和外部完全隔离,但是 shadow dom 内部元素任然可以通过属性集成和 custom properties 继承外部的样式。
详细的继承属性可以参考 继承属性 参看示例, font-size 和 color 作为继承属性, shadow DOM 内部的元素可以继承外部的样式
html
<style>
#root {
color: red;
font-weight: bold;
}
</style>
<div id="root"></div>
<script>
const el = document.getElementById('root');
el.attachShadow({ mode: 'open' });
el.shadowRoot.innerHTML = `<span>继承样式</h1>`;
</script>
TIP
页面可能引用了全局的重置样式,为了避免这些外部重置样式对 shadow DOM 内部的影响,可以通过 all: initial
重置 shadow DOM 内部的样式,避免受到属性继承导致的影响
html
<style>
body {
color: red;
font-weight: bold;
}
</style>
<div id="root"></div>
<script>
const el = document.getElementById('root');
el.attachShadow({ mode: 'open' });
// 通过设置 all: initial 避免了 body 的样式由于是继承属性,对 shadow DOM 内部的影响
el.shadowRoot.innerHTML = `
<style>
:host {
all: initial;
}
</style>
<span>继承样式</span>
`;
</script>
示例中的 :host
用来表示 shadow DOM 的宿主元素, 此处为 <div id="root">
元素, 后续会讲解
由于继承属性的副作用,对于 shadow dom 元素更标准的策略是采用自定义属性控制内部元素的核心样式。
html
<style>
#root {
--my-highlight-color: red;
}
</style>
<div id="root"></div>
<script>
const el = document.getElementById('root');
el.attachShadow({ mode: 'open' });
// 通过设置 all: initial 避免了 body 的样式由于是继承属性,对 shadow DOM 内部的影响
el.shadowRoot.innerHTML = `
<style>
span {
all: initial;
color: var(--my-highlight-color, blue);
}
</style>
<span>继承样式</span>
`;
</script>
伪元素
为了实现对 shadow DOM 内部样式的控制,浏览器提供了一系列伪元素
:host
:host
伪类选择器用于选择 shadow DOM 的宿主元素,可以通过 :host
选择器为宿主元素设置样式。 注意 :host
只在 shadow DOM 内部有效, 无法在 shadow DOM 外部使用
html
<style>
h1 {
color: red;
}
</style>
<div id="root"></div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
:host {
padding: 10px;
border: 1px solid red;
color: blue;
}
</style>
<h1>内部样式</h1>
`;
</script>
你可以把采用 :host
设置样式理解为 shadow DOM 的默认样式,而外部定义的宿主元素样式为用户样式可以覆盖内部的默认样式行为。
html
<style>
#root {
/* 覆盖了内部 blue 控制 */
color: red;
border: 2px dashed blue;
padding: 20px;
}
/* 注意只能覆盖 root 的样式,内部元素无法覆盖 */
#root h1 {
color: purple;
}
</style>
<div id="root"></div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
:host {
padding: 10px;
border: 1px solid red;
color: blue;
}
h1 {
color: yellow;
}
</style>
<h1>内部样式</h1>
`;
</script>
WARNING
注意示例中只能覆盖根元素的样式,但是内部元素无法覆盖。
:host()
:host()
伪类选择器用于根据条件选择 shadow DOM 的宿主元素,可以通过 :host()
选择器为宿主元素设置条件样式。来匹配满足特定选择器规则的宿主元素
html
<div id="root" class="demo"></div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
:host {
padding: 10px;
border: 1px solid red;
color: blue;
}
:host(.demo) {
background: lightgray;
}
</style>
<h1>内部样式</h1>
`;
</script>
:host-context()
基于祖先的选择器来控制样式。
html
<div class="warp">
<div id="root" class="demo"></div>
</div>
<script>
const el = document.getElementById("root");
const shadowRoot = el.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
:host {
padding: 10px;
border: 1px solid red;
color: blue;
}
:host(.demo) {
background: lightgray;
}
:host-context(.warp) {
background: lightgreen;
}
</style>
<h1>内部样式</h1>
`;
</script>
WARNING
参考 MDN :host-context 该属性后续可能会被废弃不推荐使用
自定义元素
除了直接调用元素上 attachShadow
创建 shadow DOM 之外,更常用的方式是通过自定义元素实现组件化。 可以通过 extends
方法扩展 HTMLElement 或者内置的标签元素实现自定义元素。
extends HTMLElement
html
<script>
class MyElement extends HTMLElement {
constructor() {
super();
// 创建 shadow DOM
this.attachShadow({ mode: 'open' });
// 注入样式和内容
this.shadowRoot.innerHTML = `<p>这是一个自定义元素</p>`;
}
}
// 定义自定义元素
customElements.define('my-element', MyElement);
</script>
<!-- 消费自定义元素 -->
<my-element></my-element>
WARNING
注意自定义元素必须采用小写,中划线分隔的命名方式,例如 my-element
,否则会抛出异常,详细规则参考 规范 valid custom element name
extends 内置标签
html
<p is="my-paragraph">测试扩展 P 标签</p>
<script>
class MyParagraph extends HTMLParagraphElement {
constructor() {
super();
this.style.color = 'red';
this.style.fontWeight = 'bold';
this.textContent = this.textContent + ' (已扩展)';
}
}
// 定义“自定义内置元素”必须带第三个参数 { extends: 'p' }
customElements.define('my-paragraph', MyParagraph, { extends: 'p' });
</script>
采用自定义标签, 可以使用自定义标签覆盖内置标签的默认行为
connectedCallback
一般我们会在自定义元素上定义属性,参考如下示例
html
<script>
class MyHighlight extends HTMLElement {
constructor() {
super()
debugger
this.attachShadow({
mode: 'open'
})
const content = this.getAttribute('content') || '默认内容'
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
}
}
customElements.define('my-highlight', MyHighlight)
</script>
<!-- 显示为默认内容而不是 hello world -->
<my-highlight content="hello world"></my-highlight>
<input id="input" type="text" value="abc">
:::waning 此处 contructor 会在浏览器解析到自定义标签时触发一次,由于 HTML 标签是流式解析的,所以当读取到标签 token 时就会立即触发 contructor, 而此时并未读取到属性值,所以在 contructor 中通过 getAttribute
获取属性值会返回 null, 从而显示默认值。 :::
因此自定义元素提供了 connectedCallback
确保在元素标签完全解析,并且插入到 DOM 树后才触发。
html
<script>
class MyHighlight extends HTMLElement {
constructor() {
super()
debugger
this.attachShadow({
mode: 'open'
})
const content = this.getAttribute('content') || '默认内容'
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
}
connectedCallback() {
debugger
const content = this.getAttribute('content') || '默认内容'
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
}
}
customElements.define('my-highlight', MyHighlight)
</script>
<!-- 显示为默认内容而不是 hello world -->
<my-highlight content="hello world"></my-highlight>
可以打开断点查看示例,会先显示默认内容,然后触发 connectedCallback
获取到
disconnectedCallback
除了 connectedCallback
之外, 自定义元素还提供了 disconnectedCallback
生命周期钩子,当自定义元素从 DOM 树中移除时触发。 可以利用 disconnectedCallback
释放资源,例如取消定时器,取消网络请求等。
html
<script>
class MyHighlight extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.timer = null;
}
connectedCallback() {
const content = this.getAttribute("content") || "默认内容";
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
// 模拟实际场景:定时高亮闪烁
this.timer = setInterval(() => {
this.shadowRoot.querySelector("strong").style.background =
this.shadowRoot.querySelector("strong").style.background === "yellow"
? ""
: "yellow";
}, 500);
}
disconnectedCallback() {
// 清理定时器,避免内存泄漏
if (this.timer) {
// 注意此处如果不销毁该定时器会导致,即使组件移除任然有定时器在运行
clearInterval(this.timer);
this.timer = null;
}
console.log("MyHighlight 元素已被移除");
}
}
customElements.define("my-highlight", MyHighlight);
// 演示动态移除组件
setTimeout(() => {
document.querySelector("my-highlight").remove();
}, 3000);
</script>
<my-highlight content="hello world"></my-highlight>
通过定时器实现高亮闪烁自定义元素,当移除元素的时候自动销毁定时器
connectedMoveCallback
除了添加或者删除元素,当调用 moveBefore
方法移动元素时,会触发 connectedMoveCallback
钩子。
html
<script>
// 假设浏览器支持 connectedMoveCallback
class MyHighlight extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render("已插入文档");
}
// connectedMoveCallback 在元素移动到文档中的新位置时触发
connectedMoveCallback() {
this.render("已移动到新位置");
}
render(status) {
const content = this.getAttribute("content") || "默认内容";
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}(${status})</strong>`;
}
}
customElements.define("my-highlight", MyHighlight);
// 演示动态移动组件
setTimeout(() => {
const container = document.querySelector("#container");
const el = document.querySelector("my-highlight");
const item = document.querySelector("#item");
// 将 my-highlight 移动到 item 之前
container.moveBefore(el, item);
}, 2000);
</script>
<div id="container">
<div id="item">test</div>
<my-highlight content="hello world"></my-highlight>
</div>
WARNING
注意只有通过 moveBefore 方法移动元素,才会触发 connectedMoveCallback 钩子, 采用 insertBefore 方法添加元素,则不会触发 connectedMoveCallback 钩子。
html
<script>
// 假设浏览器支持 connectedMoveCallback
class MyHighlight extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
console.log("MyHighlight 元素已被插入");
this.render("已插入文档");
}
disconnectedCallback() {
console.log("MyHighlight 元素已被移除");
}
connectedMoveCallback() {
console.log("MyHighlight 元素已被移动");
}
render(status) {
const content = this.getAttribute("content") || "默认内容";
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}(${status})</strong>`;
}
}
customElements.define("my-highlight", MyHighlight);
// 演示动态移动组件
setTimeout(() => {
const container = document.querySelector("#container");
const el = document.querySelector("my-highlight");
const item = document.querySelector("#item");
// 注意 interBefore 方法是卸载元素后再插入元素,所以会触发 disconnectedCallback 和 connectedCallback
container.insertBefore(el, item);
}, 2000);
</script>
<div id="container">
<div id="item">test</div>
<my-highlight content="hello world"></my-highlight>
</div>
attributeChangedCallback
为了实现修改属性的时候,触发内容也实时变化可以采用 attributeChangedCallback
钩子。监听属性的修改。
html
<script>
class MyHighlight extends HTMLElement {
static get observedAttributes() {
return ["content"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this._updateContent();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "content" && oldValue !== newValue) {
this._updateContent();
}
}
_updateContent() {
const content = this.getAttribute("content") || "默认内容";
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
}
}
customElements.define("my-highlight", MyHighlight);
</script>
<!-- 修改 content 属性时内容会动态更新 -->
<my-highlight id="highlight" content="hello world"></my-highlight>
<button
onclick="document.getElementById('highlight').setAttribute('content', '动态内容已更新')"
>
修改 content 属性
</button>
observedAttributes
为了优化性能,示例中必须采用 observedAttributes
属性定义需要监听的属性,只有在 observedAttributes
定义的属性修改时才会触发 attributeChangedCallback
钩子。
adoptedCallback
当自定义元素被移动到新的文档时,会触发 adoptedCallback
钩子。 一般在自定义元素移动到 iframe 时触发
html
<script>
// 假设浏览器支持 adoptedCallback
class MyHighlight extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
// 只在主文档插入时渲染,adopted 后不重复渲染
if (this.ownerDocument === document) {
this.render("已插入文档");
}
}
// adoptedCallback 在元素被 adopted 到新文档时触发
adoptedCallback(oldDocument, newDocument) {
this.render("已被 adopted 到新文档");
}
render(status) {
const content = this.getAttribute("content") || "默认内容";
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}(${status})</strong>`;
}
}
customElements.define("my-highlight", MyHighlight);
// 演示 adoptedCallback 的调用
setTimeout(() => {
const el = document.querySelector("my-highlight");
const f1 = document.getElementById("f1");
// 先移除元素,确保不会再次触发 connectedCallback
el.remove();
f1.onload = () => {
// 使用 adoptNode 方法将自定义元素 adopt 到新文档
const adoptedEl = f1.contentDocument.adoptNode(el);
f1.contentDocument.body.appendChild(adoptedEl);
};
f1.srcdoc = "<body></body>";
}, 2000);
</script>
<div id="container">
<iframe id="f1" frameborder="0"></iframe>
<my-highlight content="hello world"></my-highlight>
</div>
:::waring
注意由于此时挂载到了新的文档,所以不会触发 connectedMoveCallback 钩子,同时由于在新文档上挂载会重新触发 connectedCallback 钩子,为了避免此情况,可以参考示例中的逻辑,判断自定义元素所属的文档不是主文档时,不执行初次渲染逻辑, 避免重新挂载覆盖 adoptedCallback 的行为
js
// 只在主文档插入时渲染,adopted 后不重复渲染
if (this.ownerDocument === document) {
this.render("已插入文档");
}
:::
hooks 总结
整个自定义元素的生命周期如下
- constructor 每次解析到自定义元素标签就会触发,此时无法读取属性,用于初始化自定义元素基本信息
- connectedCallback 添加元素到 DOM 树时触发,此时可以读取属性,可以在此时初始化或者更新元素
- attributeChangedCallback 修改属性时触发
- disconnectedCallback 从 DOM 树移除元素时触发
- connectedMoveCallback 通过 moveBefore 方法移动元素时触发
- adoptedCallback 元素被移动到新的文档时触发, 注意由于移动到新的 document 会重新触发
connectedCallback
钩子, 可以利用this.ownerDocument
判断是否是主文档,避免重复渲染
attribute vs prperty
- attribute 属于 HTML 标签上定义的属性,可以通过
getAttribute、setAttribute、removeAttribute
方法访问和修改 - property 采用自定义元素时实例上的属性,可以通过
this.xx
访问和修改
HTML 规范在处理 attribute 和 property 上有一系列规则,可以阅读 HTML attributes vs DOM properties 了解细节
属性反射
除了直接通过 getAttribute、setAttribute、removeAttribute
方法访问和修改属性之外,你也可以直接修改 this.xx
的值,值的改变会同步到 attribute 上, 这种行为称为属性反射。详细资料可以参考 attribute reflection
html
<script>
class MyHighlight extends HTMLElement {
constructor() {
super();
// 创建 shadow DOM
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const content = this.getAttribute('content') || '默认内容';
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
}
}
customElements.define('my-highlight', MyHighlight);
</script>
<my-highlight id="my-highlight" content="hello world"></my-highlight>
<script>
const el = document.getElementById('my-highlight');
console.log('id', el.id)
setTimeout(() => {
el.setAttribute('id', 'my-highlight-modified');
console.log('id modified', el.id)
}, 2000);
</script>
可以看到当修改元素上内置属性 id
的时候,属性值会同步到 attribute 上
但是针对 content 采用 this.content
修改并不会触发内容更新。 核心的原因在于 attributeChangedCallback
只会监听采用 setAttribute
等方式来修改属性,为了实现属性反射的效果,可以额外添加 content
的 setter 和 getter 方法,结合 attributeChangedCallback
实现属性反射
html
<script>
class MyHighlight extends HTMLElement {
static get observedAttributes() {
return ["content"];
}
constructor() {
super();
this.attachShadow({ mode: "open" });
}
get content() {
return this.getAttribute("content");
}
set content(value) {
this.setAttribute("content", value);
}
connectedCallback() {
this._updateContent();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "content" && oldValue !== newValue) {
this._updateContent();
}
}
_updateContent() {
const content = this.getAttribute("content") || "默认内容";
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`;
}
}
customElements.define("my-highlight", MyHighlight);
</script>
<!-- 修改 content 属性时内容会动态更新 -->
<my-highlight id="highlight" content="hello world"></my-highlight>
<button onclick="document.getElementById('highlight').setAttribute('content', '动态内容已更新')">
setAttribute 修改 content 属性
</button>
<button onclick="document.getElementById('highlight').content = '属性反射修改 content'">
属性反射,利用 property 修改 content 属性
</button>
事件处理
由于自定义元素继承自 HTMLElement, 因此根元素上的可以直接绑定事件,参看示例
html
<script>
class MyHighlight extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
const content = this.getAttribute('content') || '默认内容'
this.innerHTML = `<strong style="color: red;">${content}</strong>`
}
}
customElements.define('my-highlight', MyHighlight)
</script>
<my-highlight onclick="alert(1)"></my-highlight>
自定义事件
对于内部元素可以使用 dispatchEvent
触发自定义事件,参看示例
html
<script>
class MyHighlight extends HTMLElement {
constructor() {
super()
}
connectedCallback() {
this.attachShadow({ mode: 'open' })
const content = this.getAttribute('content') || '默认内容'
this.shadowRoot.innerHTML = `<strong style="color: red;">${content}</strong>`
const strongEl = this.shadowRoot.querySelector('strong')
strongEl.addEventListener('click', (e) => {
this.dispatchEvent(new CustomEvent('highlight-click', {
detail: {
message: 'highlight clicked'
},
bubbles: true,
composed: true
}))
})
}
}
customElements.define('my-highlight', MyHighlight)
</script>
<my-highlight></my-highlight>
<script>
document.body.addEventListener('highlight-click', (e) => {
console.log(e)
})
</script>
TIP
由于内置事件属性例如 onclick
会自动处理绑定的表达式,而自定义事件没有此功能,可以通过监听属性绑定结合属性反射的功能实现支持在属性上绑定事件表达式, 此外需要设置 bubbles: true, composed: true
使得事件可以冒泡到 shadow DOM 外部。注意内部元素会被隐藏所以,事件的 target 始终为 shadow host
template 和 slot
为了实现可复用的结构来简化自定义元素的的创建,同时提供内置的扩展能力,web components 提供了 template 和 slot 来实现此功能
html
<template id="my-highlight">
<style>
:host {
color: red;
}
</style>
<slot></slot>
</template>
<script>
class MyHighlight extends HTMLElement {
constructor() {
super();
const template = document.getElementById("my-highlight").content;
this.attachShadow({ mode: "open" }).appendChild(template.cloneNode(true));
}
}
customElements.define("my-highlight", MyHighlight);
</script>
<my-highlight><h2>自定义内容</h2></my-highlight>
示例中
- template 定义模版可以被多个自定义元素引用
- slot 使得可以在自定义组件内部插入不同的标签实现自定义的高亮内容,而不受通过 content 属性只能注入文本的限制
template
除了直接使用 template 标签定义模版外,你可以直接将 template 模版写在自定义元素内部,采用 shadowrootmode="open"
的方式创建 shadow DOM, 浏览器会自动将 template 内部的内容注入到 shadow DOM 内部
html
<my-highlight>
<template shadowrootmode="open">
<style>
:host {
color: red;
}
</style>
<slot></slot>
</template>
<h2>高亮内容</h2>
</my-highlight>
TIP
对于不复用的自定义组件可以简单采用此方式定义,一般还是通过 CustomElement 的方式来创建 web components
name slot
slot 支持 name 属性插入多个插槽,在自定义组件内部使用 slot
属性制定具体的插入点
html
<template id="my-highlight">
<style>
:host {
color: red;
}
</style>
<slot></slot>
<slot name="content"></slot>
</template>
<script>
class MyHighlight extends HTMLElement {
constructor() {
super();
const template = document.getElementById("my-highlight").content;
this.attachShadow({ mode: "open" }).appendChild(template.cloneNode(true));
}
}
customElements.define("my-highlight", MyHighlight);
</script>
<my-highlight>
<h2>标题内容</h2>
<p slot="content">这是具名插槽的内容。</p>
</my-highlight>
duplicate slot
你可以在 template 中定义多个同名的插槽,但是浏览器只会默认使用第一个插槽, 在自定义元素中,你也可以同时插入多个同名的插槽,浏览器会自动根据插槽的 name 属性匹配,一次插入对应位置。
html
<template id="my-highlight">
<style>
:host {
color: red;
}
</style>
<!-- 只会使用第一个默认插槽 -->
<slot></slot>
<h2>2 slot</h2>
<slot></slot>
<!-- 只会使用第一个具名插槽 -->
<slot name="content"></slot>
<h2>2 slot content</h2>
<slot name="content"></slot>
</template>
<script>
class MyHighlight extends HTMLElement {
constructor() {
super();
const template = document.getElementById("my-highlight").content;
this.attachShadow({ mode: "open" }).appendChild(template.cloneNode(true));
}
}
customElements.define("my-highlight", MyHighlight);
</script>
<my-highlight>
<h2>标题1</h2>
<p slot="content">内容1</p>
<h2>标题2</h2>
<p slot="content">内容2</p>
<h2>标题3</h2>
<p slot="content">内容2</p>
</my-highlight>
TIP
注意示例说明的规则
- 当在 template 中定义多个同名插槽的时候,默认使用第一个插槽作为插入点
- 当自定义元素消费插槽时,浏览器会自定基于插槽名称插入对应的插槽点,如果重复注入多个同名插槽内容,会依次按顺序插入对应的插槽点
在 Vue 的框架中不允许定义和注入重复插槽,此时会触发报错,因为框架在做 update 操作的时候如果定义重复的插槽或者注入重复的插槽会导致更新算法没法利用 key 或者其他属性来高性能的更新视图,注意此问题
::part
除了之前介绍的 custom properties 之外,shadow DOM 还提供了 part
属性来实现对 shadow DOM 内部元素的样式控制, 你可以在 元素上定义 part 属性, 然后通过 ::part(xx)
伪元素选择器控制 shadow dom 内部元素样式
html
<template id="my-element">
<style>
::part(title) {
color: red;
font-weight: bold;
}
</style>
<h1 part="title">
<slot></slot>
</h1>
</template>
<script>
class MyElement extends HTMLElement {
constructor() {
super();
const template = document.getElementById('my-element');
const templateContent = template.content;
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(templateContent.cloneNode(true));
}
}
customElements.define('my-element', MyElement);
</script>
<style>
#custom::part(title) {
color: blue;
font-weight: normal;
}
</style>
<my-element>默认样式</my-element>
<my-element id="custom">覆盖 part 后样式</my-element>
::slotted
此外可以通过 ::slotted(xx)
伪元素选择器控制插入到 slot 内部的元素样式
html
<!-- 12.slotted.html 演示 ::slotted 对匿名(默认)和具名插槽的样式设置 -->
<template id="my-element">
<style>
/* 匿名插槽(默认插槽)内所有分发元素的基础样式 */
::slotted(*) {
font-family: sans-serif;
color: #444;
}
/* 匿名插槽中特定类名的元素 */
::slotted(.highlight) {
color: crimson;
font-weight: bold;
}
/* 具名插槽 subtitle 中的所有分发元素 */
::slotted([slot=subtitle]) {
color: steelblue;
font-size: 14px;
letter-spacing: 1px;
}
/* 具名插槽中特定更细粒度的匹配 */
::slotted(span[slot=subtitle].warn) {
color: orange;
}
</style>
<h1>
<slot></slot> <!-- 匿名(默认)插槽 -->
</h1>
<h2>
<slot name="subtitle"></slot> <!-- 具名插槽 -->
</h2>
</template>
<script>
class MyElement extends HTMLElement {
constructor() {
super();
const tpl = document.getElementById('my-element');
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(tpl.content.cloneNode(true));
}
}
customElements.define('my-element', MyElement);
</script>
<!-- 用例 1:最基本 -->
<my-element>
默认标题
<span slot="subtitle">默认副标题</span>
</my-element>
<hr>
<!-- 用例 2:匿名插槽中的高亮样式 -->
<my-element>
<span class="highlight">高亮的主标题</span>
<span slot="subtitle">普通副标题</span>
</my-element>
<hr>
<!-- 用例 3:具名插槽中更细粒度选择器 -->
<my-element>
普通主标题
<span slot="subtitle" class="warn">警告副标题</span>
</my-element>
<!-- 说明:
1. ::slotted 只能写在 Shadow DOM 内部样式表中,且仅作用于直接分发进插槽的顶层节点。
2. 不能通过 ::slotted 选择分发元素的后代,如 ::slotted(div span) 无效。
3. [slot=subtitle] 仅匹配放入 name="subtitle" 插槽的顶层元素。 -->
最佳实践
组件设计
结构定义
attribute 和 property
样式控制
:defined
采用 :defined
伪类解决自定义组件 FOUC 问题
:defined
伪类选择器用于选择已经定义的自定义元素,可以通过 :defined
选择器为已经定义的自定义元素设置样式。一般通过该元素解决样式闪烁问题
html
<!-- 07.defined.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<meta charset="UTF-8" />
<title>:defined 解决自定义元素 FOUC 示例</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Arial, sans-serif;
line-height: 1.5;
padding: 24px;
}
h1 {
margin-top: 0;
}
section {
margin-bottom: 32px;
}
fouc-panel,
fixed-panel {
display: block;
margin: 12px 0;
}
/* 方案 A:直接隐藏未定义元素,避免看到“原始未升级内容”闪烁 */
fixed-panel:not(:defined) {
visibility: hidden;
}
/* 方案 B:骨架占位(带 skeleton 类的才使用),覆盖上面的隐藏规则 */
fixed-panel.skeleton:not(:defined) {
visibility: visible;
position: relative;
}
fixed-panel.skeleton:not(:defined)::before {
content: '加载中…';
display: block;
padding: 16px;
background: #f2f3f5;
color: #888;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
animation: pulse 1s linear infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: .45;
}
}
</style>
<h1>:defined 解决 FOUC(样式闪烁 / 内容闪现)</h1>
<section>
<h2>对比演示</h2>
<ol>
<li>无处理:会先短暂显示「原始未升级内容」,随后被 Shadow DOM 替换,发生 FOUC。</li>
<li>隐藏方案:用 :not(:defined) 隐藏,升级后再显示,避免闪烁。</li>
<li>骨架方案:未定义前显示占位骨架,升级后自动替换成真正内容。</li>
</ol>
</section>
<h3>1. 无处理(会 FOUC)</h3>
<fouc-panel title="未做处理">原始未升级内容(会闪一下)</fouc-panel>
<h3>2. 使用 :not(:defined) 隐藏(无闪烁)</h3>
<fixed-panel title="隐藏避免闪烁">原始未升级内容(不会看到)</fixed-panel>
<h3>3. 使用骨架占位(平滑过渡)</h3>
<fixed-panel class="skeleton" title="骨架占位">原始未升级内容(被骨架遮挡)</fixed-panel>
<script>
// 延迟注册,模拟网络/代码分片加载
setTimeout(() => {
class BasePanel extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
const title = this.getAttribute('title') || '默认标题';
root.innerHTML = `
<style>
:host {
display:block;
border:1px solid #409eff;
padding:14px 16px 16px;
border-radius:8px;
background:#f8fbff;
box-shadow:0 2px 4px rgba(0,0,0,.08);
font-size:14px;
color:#444;
animation:fade .28s;
}
h2 {
margin:0 0 8px;
font-size:16px;
color:#1f2d3d;
}
p { margin:0; }
@keyframes fade {
from { opacity:0; transform:translateY(4px); }
to { opacity:1; transform:translateY(0); }
}
</style>
<h2>${title}</h2>
<p>这是组件升级后的正式内容。</p>
`;
}
}
// fouc-panel:未使用任何 :defined 预处理
customElements.define('fouc-panel', class extends BasePanel { });
// fixed-panel:页面级样式使用 :not(:defined) 做隐藏或骨架
customElements.define('fixed-panel', class extends BasePanel { });
}, 1200);
</script>
<!-- 说明:
1. :not(:defined) 仅在自定义元素尚未注册时匹配,注册后元素立即匹配 :defined,隐藏/骨架规则失效,显示 Shadow DOM。
2. 隐藏方案最简单;骨架方案提供视觉占位,减少布局跳动与空白。
3. 两个标签 fouc-panel 与 fixed-panel 的区别只在“未定义时期的样式处理”。 -->
</html>
事件处理
库和框架
总结
快速使用
基础示例
注意事项
- 自定义元素名称必须包含连字符
-
- 自定义元素类必须继承自
HTMLElement
或其子类 - 消费自定义元素无法自闭和,必须手动闭合
- 自定义元素名称必须包含连字符
样式设置
class CustomElement extends HTMLElement
中- constructor 只会初始化一次,
connectedCallback
每次插入 DOM 都会调用, 建议对于初始化操作放在 constructor 中, 对于可以延迟和多次调用的放在connectedCallback
中, 参考资料, 注意consturctor
在遇到标签的时候就会解析,此时在 contructor 中直接调用getAttribute
获取不到值,可以参考 cannot-access-attributes-of-a-custom-element-from-its-constructor 进一步理解
- constructor 只会初始化一次,
自定义元素属性
- 创建属性
调试
- Chrome DevTools -> 设置 -> Elements -> Show user agent shadow DOM 可以看到 video 标签就是自定义元素
- 原生的自定义元素包括
video
视频input
输入框select
下拉框