Web Components 是一种 Web 技术,它允许你创建可复用的自定义元素(Custom Elements)和封装功能强大的组件。Web Components 由三个主要技术组成:
- Custom Elements(自定义元素):一组 JavaScript API,允许你定义 custom elements 和它们的行为。
- Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的 Shadow DOM 树附加到元素(与主文档 DOM 分开)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
- HTML Templates(HTML 模板):通过
<template>和<slot>元素,使你可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。
实现 web component 的基本方法如下:
- 创建一个类或函数来指定 web 组件的功能。
- 使用
CustomElementRegistry.define()方法注册你的新自定义元素,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素。 - 如果需要的话,使用
Element.attachShadow()方法将一个 shadow DOM 附加到自定义元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件监听器等等。 - 如果需要的话,使用
<template>和<slot>定义一个 HTML 模板。再次使用常规 DOM 方法克隆模板并将其附加到你的 shadow DOM 中。 - 在页面任何你喜欢的位置使用自定义元素,就像使用常规 HTML 元素那样。
下面是一个简单的示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Component</title>
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
h1 {
color: red;
}
</style>
<h1>Hello World</h1>
`;
}
}
customElements.define('my-component', MyComponent);
</script>
</head>
<body>
<my-component></my-component>
</body>
</html>
把这段代码保存成一个 html 文件,浏览器打开就能看到效果。代码看不懂没关系,接下来我们将详细介绍如何创建并使用 Web Components。
Custom Elements(自定义元素)
我们上面提到了 Web Components 的三个主要技术,第一个就是能够创建自定义元素,这些元素扩展了浏览器中可用的元素集。
自定义元素有两种类型:
- Customized built-in elements:自定义内置元素,继承自内置 HTML 元素(比如
HTMLParagraphElement)的自定义元素。它们继承了内置元素的行为,但可以添加额外的功能。 - Autonomous custom elements:独立自定义元素,继承自 HTMLElement。
创建自定义元素
对于这两种类型的自定义元素,创建方式是一样的,只是继承的类不同。
如果是自定义内置元素,需要继承自内置元素的类,比如继承自 HTMLParagraphElement 的自定义元素:
class MyParagraph extends HTMLParagraphElement {
constructor() {
super();
// Your code here
}
}
如果是独立自定义元素,需要继承自 HTMLElement:
class MyElement extends HTMLElement {
constructor() {
super();
// Your code here
}
}
注册自定义元素
创建之后我们需要使用 CustomElementRegistry.define() 方法注册自定义元素,之后才能在页面中使用。
define 方法接受三个参数:
name:自定义元素的名称,必须包含一个连字符(-)。constructor:自定义元素的构造函数。options:仅对于自定义内置元素,这是一个包含单个属性extends的对象,该属性是一个字符串,命名了要扩展的内置元素。
下面分别是注册自定义内置元素和独立自定义元素的示例:
// 注册自定义内置元素
customElements.define('my-paragraph', MyParagraph, { extends: 'p' });
// 注册独立自定义元素
customElements.define('my-element', MyElement);
使用自定义元素
定义和注册自定义元素之后,我们就可以在页面中使用它了:
要使用自定义内置元素,只需在 HTML 中使用扩展的内置元素名称,将自定义名称作为 is 属性的值:
<p is="my-paragraph">Hello, world!</p>
要使用独立自定义元素,只需在 HTML 中使用自定义元素名称,就像使用常规 HTML 元素一样:
<my-element></my-element>
生命周期回调
一旦你的自定义元素被注册,当页面中的代码以特定方式与你的自定义元素交互时,浏览器将调用你的类的某些方法。通过提供这些方法的实现,规范称之为生命周期回调,你可以运行代码来响应这些事件。
自定义元素生命周期回调包括:
connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。disconnectedCallback():每当元素从文档中移除时调用。adoptedCallback():每当元素被移动到新文档中时调用。attributeChangedCallback():在属性更改、添加、移除或替换时调用。
下面是一个简单的示例:
// 为这个元素创建类
class MyCustomElement extends HTMLElement {
static observedAttributes = ['color', 'size'];
constructor() {
// 必须首先调用 super 方法
super();
}
connectedCallback() {
console.log('自定义元素添加至页面。');
}
disconnectedCallback() {
console.log('自定义元素从页面中移除。');
}
adoptedCallback() {
console.log('自定义元素移动至新页面。');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已变更。`);
}
}
customElements.define('my-custom-element', MyCustomElement);
响应属性变化
与内置元素一样,自定义元素可以使用 HTML 属性来配置元素的行为。为了有效地使用属性,元素必须能够响应属性值的变化。为此,自定义元素需要将以下成员添加到实现自定义元素的类中:
-
observedAttributes:一个静态属性,它是一个字符串数组,包含你想要监听的属性名称。 -
attributeChangedCallback():当元素的一个被监视的属性发生变化时,浏览器将调用此方法。attributeChangedCallback()回调在列在元素的observedAttributes属性中的属性被添加、修改、移除或替换时调用。它接收三个参数:name:属性的名称。oldValue:属性的旧值。newValue:属性的新值。
下面是一个简单的示例:
class MyCustomElement extends HTMLElement {
static observedAttributes = ['size'];
constructor() {
super();
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
}
}
customElements.define('my-custom-element', MyCustomElement);
完整示例
所以一个完整的自定义元素示例可能是这样的:
独立自定义元素
class MyCustomElement extends HTMLElement {
static observedAttributes = ['size'];
constructor() {
super();
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
console.log('connectedCallback: MyCustomElement added to the page.');
this.render();
}
disconnectedCallback() {
console.log('disconnectedCallback: MyCustomElement removed from the page.');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
this.render();
}
render() {
const size = this.getAttribute('size') || 'medium';
this.shadowRoot.innerHTML = `
<style>
p {
font-size: ${this.getFontSize(size)};
color: blue;
}
</style>
<p>Hello, I am a custom element with size ${size}!</p>
`;
}
getFontSize(size) {
switch (size) {
case 'small':
return '12px';
case 'large':
return '24px';
default:
return '16px';
}
}
}
customElements.define('my-custom-element', MyCustomElement);
在 HTML 中使用这个自定义元素:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Element Example</title>
</head>
<body>
<my-custom-element size="small"></my-custom-element>
<script>
// 修改属性以触发 attributeChangedCallback
const customElement = document.querySelector('my-custom-element');
setTimeout(() => {
customElement.setAttribute('size', 'large');
}, 3000);
// 移除元素以触发 disconnectedCallback
setTimeout(() => {
customElement.remove();
}, 6000);
</script>
</body>
</html>
自定义内置元素
class MyCustomParagraph extends HTMLParagraphElement {
static observedAttributes = ['size'];
constructor() {
super();
// 创建 Shadow DOM
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
console.log('connectedCallback: MyCustomParagraph added to the page.');
this.render();
}
disconnectedCallback() {
console.log(
'disconnectedCallback: MyCustomParagraph removed from the page.'
);
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已由 ${oldValue} 变更为 ${newValue}。`);
this.render();
}
render() {
const size = this.getAttribute('size') || 'medium';
this.shadowRoot.innerHTML = `
<style>
p {
font-size: ${this.getFontSize(size)};
color: blue;
}
</style>
<p>Hello, I am a custom paragraph with size ${size}!</p>
`;
}
getFontSize(size) {
switch (size) {
case 'small':
return '12px';
case 'large':
return '24px';
default:
return '16px';
}
}
}
customElements.define('my-custom-paragraph', MyCustomParagraph, {
extends: 'p',
});
在 HTML 中使用这个自定义元素:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Custom Element Example</title>
</head>
<body>
<p is="my-custom-paragraph" size="small"></p>
<script>
// 修改属性以触发 attributeChangedCallback
const customParagraph = document.querySelector(
'p[is="my-custom-paragraph"]'
);
setTimeout(() => {
customParagraph.setAttribute('size', 'large');
}, 3000);
// 移除元素以触发 disconnectedCallback
setTimeout(() => {
customParagraph.remove();
}, 6000);
</script>
</body>
</html>
Shadow DOM
Shadow DOM 是 Web Components 的一个重要特性,它允许你将封装的 Shadow DOM 树附加到元素(与主文档 DOM 分开)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
上面示例的构造函数中已经展示了如何创建 Shadow DOM:
this.attachShadow({ mode: 'open' });
attachShadow() 方法接受一个配置对象,其中 mode 属性可以是 open 或 closed。open 意味着外部代码可以访问 Shadow DOM,而 closed 意味着外部代码无法访问 Shadow DOM。
Shadow DOM 并不是 Web Components 的专属功能,下面是一个简单的示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow DOM Example</title>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById('container');
const shadowRoot = container.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
p {
color: red;
}
</style>
<p>Hello, I am a paragraph in Shadow DOM!</p>
`;
</script>
</body>
</html>
使用 JavaScript 操作 Shadow DOM
默认情况下,Shadow DOM 是封闭的,这意味着它是私有的,外部代码无法访问。比如我们有如下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow DOM Example</title>
</head>
<body>
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<br />
<button id="upper" type="button">将 span 元素转换为大写</button>
<script>
const host = document.querySelector('#host');
const shadow = host.attachShadow({ mode: 'open' });
const span = document.createElement('span');
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
const upper = document.querySelector('button#upper');
upper.addEventListener('click', () => {
const spans = Array.from(document.querySelectorAll('span'));
for (const span of spans) {
span.textContent = span.textContent.toUpperCase();
}
});
</script>
</body>
</html>
点击按钮后,只有页面中的 span 元素被转换为大写,而 Shadow DOM 中的 span 元素没有被转换。
要访问 Shadow DOM 中的元素,首先把 attachShadow() 方法的 mode 参数设置成 open, 接下来可以使用 shadowRoot 属性访问 Shadow DOM:
const spans = Array.from(host.shadowRoot.querySelectorAll('span'));
这样就可以把 Shadow DOM 中的 span 元素转换为大写了。
CSS 封装
页面的 CSS 不会影响 Shadow DOM 内的节点:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow DOM Example</title>
<style>
span {
color: blue;
border: 1px solid black;
}
</style>
</head>
<body>
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<script>
const host = document.querySelector('#host');
const shadow = host.attachShadow({ mode: 'open' });
const span = document.createElement('span');
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
</script>
</body>
</html>
在这种情况下,页面中的 span 元素是蓝色的,有黑色边框,而 Shadow DOM 中的 span 元素是默认样式。
要在 Shadow DOM 中使用 CSS,有两种方法:
- 编程式:通过构建一个
CSSStyleSheet对象并将其附加到影子根。 - 声明式:通过在一个
<template>元素的声明中添加一个<style>元素。
在这两种情况下,Shadow DOM 树中定义的样式局限在该树内,所以就像页面样式不会影响 Shadow DOM 中的元素一样,Shadow DOM 样式也不会影响页面中其它元素的样式。
要通过构建 CSSStyleSheet 来为 Shadow DOM 添加样式,我们可以:
- 创建一个
CSSStyleSheet对象。 - 使用
CSSStyleSheet.replace或者CSSStyleSheet.replaceSync方法添加样式规则。 - 使用
shadowRoot.adoptedStyleSheets属性将样式表附加到 Shadow DOM。
下面是一个示例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow DOM Example</title>
</head>
<body>
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<script>
const sheet = new CSSStyleSheet();
sheet.replaceSync('span { color: red; border: 2px dotted black;}');
const host = document.querySelector('#host');
const shadow = host.attachShadow({ mode: 'open' });
shadow.adoptedStyleSheets = [sheet];
const span = document.createElement('span');
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);
</script>
</body>
</html>
构建 CSSStyleSheet 对象的一个替代方法是将一个 <style> 元素包含在用于定义 web 组件的 <template> 元素中。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shadow DOM Example</title>
</head>
<body>
<!-- 定义模板 -->
<template id="my-element">
<style>
span {
color: red;
border: 2px dotted black;
}
</style>
<span>I'm in the shadow DOM</span>
</template>
<!-- 宿主元素 -->
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<script>
// 获取宿主元素
const host = document.querySelector('#host');
// 创建 Shadow DOM
const shadow = host.attachShadow({ mode: 'open' });
// 获取模板内容并克隆
const template = document.getElementById('my-element');
const content = template.content.cloneNode(true);
// 将模板内容附加到 Shadow DOM
shadow.appendChild(content);
</script>
</body>
</html>
使用哪种方式取决于你的应用程序和个人喜好。
创建一个 CSSStyleSheet 并通过 adoptedStyleSheets 将其赋给影子根允许你创建单一样式表并将其与多个 DOM 树共享。例如,一个组件库可以创建单个样式表,然后将其与该库的所有自定义元素共享。浏览器将仅解析该样式表。此外,你可以对样式表进行动态更改,并将更改传播到使用表的所有组件。
而当你希望是声明式的、需要较少的样式并且不需要在不同组件之间共享样式的时候,附加 <style> 元素的方法则非常适合。
模板和插槽
接下来我们介绍 HTML template 和 slot,它们是 Web Components 的另一个重要特性。
当需要在网页中多次重用相同的 HTML 结构时,可以使用 template。模板是一个 HTML 元素,它包含了不会被呈现的 HTML 内容。模板通常用于存储可重复使用的 HTML 片段,这些片段可以通过 JavaScript 复制和插入到文档中。
让我们看一个简单的示例:
<template id="my-paragraph">
<p>我的段落</p>
</template>
要将它显示在页面中,需要使用 JavaScript 获取对它的引用,然后将其内容插入到 DOM 中:
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
document.body.appendChild(templateContent);
模板本身就很有用,但与 web component 一起使用效果更好。
customElements.define(
'my-paragraph',
class extends HTMLElement {
constructor() {
super();
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.appendChild(templateContent.cloneNode(true));
}
}
);
这里要注意的关键点是,我们将模版内容的克隆添加到通过 Node.cloneNode() 方法创建的影子根上。
由于我们添加了模板的内容到影子 DOM,我们可以在模板的 <style> 元素中包含一些样式信息,然后将其封装在自定义元素中。
比如:
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>我的段落</p>
</template>
除了模板,我们还可以使用插槽(slot)来插入内容,让你的组件更灵活。插槽是一种占位符,允许你将内容插入到自定义元素中。
如果想添加一个插槽,可以在模板中添加一个 <slot> 元素:
<p><slot name="my-text">我的默认文本</slot></p>
如果没有插入内容,那么默认文本将被显示。如果要插入内容,在使用自定义元素时,在 <slot> 元素中添加内容:
<my-paragraph>
<span slot="my-text">这是插槽中的文本</span>
</my-paragraph>
总结
在 JavaScript 框架的地位和能力不断提高的时代,Web Components 一直在努力获得认可和采用。现在大部分前端开发者都使用 React、Vue 或 Angular,Web Components 看起来可能很复杂且笨重,尤其是缺少数据绑定和状态管理等功能。
但 Web Components 作为一种标准化的 Web 技术,近年来也得到了显著的发展和广泛的支持。许多前端框架和库已经开始支持或集成 Web Components,使得它们更容易使用。
虽然有一些问题需要解决,但 Web Components 的未来仍然是值得期待的。