现代化的浏览器可重用元素:web component

Web components 是用于创建独立组件的一组标准:自定义 HTML 元素,它们具有自己的属性和方法,封装好的 DOM 和样式。

当我们写html/css时,如何想要创建自己的html元素或者进行代码复用,这就需要通过使用web components或者react/vue这种前端库.

Web Components 旨在解决这些问题 — 它由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  • Custom element(自定义元素):一组 JavaScript API,允许你定义 custom elements 及其行为,然后可以在你的用户界面中按照需要使用它们。
  • Shadow DOM(影子 DOM):一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  • HTML template(HTML 模板): template和slot元素使你可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

实现 web component 的基本方法通常如下所示:

  1. 创建一个类或函数来指定 web 组件的功能,如果使用类,请使用 ECMAScript 2015 的类语法
  2. 使用 CustomElementRegistry.define()方法注册的新自定义元素,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素。
  3. 如果需要的话,使用Element.attachShadow()方法将一个 shadow DOM 附加到自定义元素上。使用通常的 DOM 方法向 shadow DOM 中添加子元素、事件监听器等等。
  4. 如果需要的话,使用template和slot 定义一个 HTML 模板。再次使用常规 DOM 方法克隆模板并将其附加到你的 shadow DOM 中。
  5. 在页面任何你喜欢的位置使用自定义元素,就像使用常规 HTML 元素那样。

自定义元素

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

  • 自定义内置元素(Customized built-in element)继承自标准的 HTML 元素,例如 HTMLImageElementHTMLParagraphElement。它们的实现定义了标准元素的行为。
  • 独立自定义元素(Autonomous custom element)继承自 HTML 元素基类 HTMLElement。你必须从头开始实现它们的行为。

自定义元素作为一个类来实现,该类可以扩展 HTMLElement(在独立元素的情况下)或者你想要定制的接口(在自定义内置元素的情况下)。

1
2
3
4
5
6
class WordCount extends HTMLParagraphElement {
constructor() {
super();
}
// 此处编写元素功能
}

构造函数中可以添加一些元素的状态、注册事件监听器等. 此外有一些特别的回调方法,一旦自定义元素被注册,当页面中的代码以特定方式与自定义元素交互时,浏览器将调用类的某些方法。通过提供这些方法的实现,规范称之为生命周期回调,你可以运行代码来响应这些事件。

  • connectedCallback():每当元素添加到文档中时调用。规范建议开发人员尽可能在此回调中实现自定义元素的设定,而不是在构造函数中实现。
  • disconnectedCallback():每当元素从文档中移除时调用。
  • adoptedCallback():每当元素被移动到新文档中时调用。
  • attributeChangedCallback():在属性更改、添加、移除或替换时调用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class WordCount extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
console.log("自定义元素添加至页面。");
}

disconnectedCallback() {
console.log("自定义元素从页面中移除。");
}

adoptedCallback() {
console.log("自定义元素移动至新页面。");
}

attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已变更。`);
}
}

customElements.define("word-count", WordCount);

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<word-count/>
<script src="main.js"></script>
</body>
</html>

image-20241015204756114

注册元素

要使自定义元素在页面中可用,请调用Window.customElements 的 define() 方法。

元素的名称。必须以小写字母开头,包含一个连字符,并符合规范中有效名称的定义中列出的一些其他规则

1
2
customElements.define("word-count", WordCount); // 独立自定义元素 <word-count> </word-count>
customElements.define("word-count", WordCount,{extends:"p"}); //自定义内置元素 命名了要扩展的内置元素 <p is="word-count"></p>

使用元素

定义并注册元素之后,就可以直接在html中使用了.

要使用自定义内置元素,请使用内置元素,但将自定义名称作为is 属性的值:

1
<p is="word-count"></p>

使用独立自定义元素,,就像使用内置的 HTML 元素一样,使用自定义名称即可:

1
2
3
<word-count>
<!-- 元素的内容 -->
</word-count>

相应属性变化

与内置元素一样,自定义元素可以使用 HTML 属性来配置元素的行为。为了有效地使用属性,元素必须能够响应属性值的变化。为此,自定义元素需要将以下成员添加到实现自定义元素的类中:

  • 一个名为 observedAttributes 的静态属性。这必须是一个包含元素需要变更通知的所有属性名称的数组。
  • attributeChangedCallback() 生命周期回调的实

attributeChangedCallback() 回调在列在元素的 observedAttributes 属性中的属性被添加、修改、移除或替换时调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class WordCount extends HTMLElement {
static observedAttributes = ["size"];
constructor() {
super();
}
connectedCallback() {
console.log("自定义元素添加至页面。");
}

disconnectedCallback() {
console.log("自定义元素从页面中移除。");
}

adoptedCallback() {
console.log("自定义元素移动至新页面。");
}

attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已变更。从${oldValue}变为${newValue}`);
}
}

customElements.define("word-count", WordCount);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
word-count {
display: block;
margin-top: 20px;
}
</style>
</head>
<body>
<word-count size="100"/>
<script src="main.js"></script>
</body>
</html>

使用Shadow DOM

影子(shadow) DOM 允许将隐藏的DOM 树附加到常规 DOM 树中的元素上——这个影子 DOM 始于一个影子根,在其之下可以用与普通 DOM 相同的方式附加任何元素

显示文档、影子根和影子宿主交互的图示的 SVG 版本

  • 影子宿主(Shadow host): 影子 DOM 附加到的常规 DOM 节点。
  • 影子树(Shadow tree): 影子 DOM 内部的 DOM 树。
  • 影子边界(Shadow boundary): 影子 DOM 终止,常规 DOM 开始的地方。
  • 影子根(Shadow root): 影子树的根节点。

使用attachShadow给一个元素挂在影子DOM,出于安全考虑,一些元素不能使用 shadow DOM(例如``),以及许多其他的元素。下面是一个可以挂载 shadow root 的元素列表:

image-20241015212842605

1
2
const pEle = document.querySelector("#para");
pEle.attachShadow({ mode: "open" });

影子 DOM 中元素对页面中的 JavaScript 来说基本上是隐藏的

mode 设置为 "open" 时,页面中的 JavaScript 可以通过影子宿主的shadowRoot属性访问影子 DOM 的内部。

1
2
3
4
5
6
const shadow = pEle.attachShadow({ mode: "open" });
shadow.appendChild(document.createTextNode("这是一个自定义元素"));
const spans = shadow.querySelectorAll("span");
for(const span of spans) {
span.textContent = span.textContent.toLocaleLowerCase();
}

页面的 CSS 不会影响影子 DOM 内的节点,有两种方法可以改变影子DOM树中的样式.

  • 编程式,通过构建一个 CSSStyleSheet 对象并将其附加到影子根。
  • 声明式,通过在一个 template元素的声明中添加一个style元素

影子 DOM 树中定义的样式局限在该树内,所以就像页面样式就像不会影响影子 DOM 中的元素一样,影子 DOM 样式也不会影响页面中其它元素的样式。

编程式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
customElements.define("word-count", WordCount);
const pEle = document.querySelector("#para");
const shadow = pEle.attachShadow({ mode: "open" });
const sheet = new CSSStyleSheet();
sheet.replaceSync("span {color: red;}");
shadow.adoptedStyleSheets = [sheet];
const span = document.createElement("span");
span.appendChild(document.createTextNode("这是一个span元素"));
shadow.appendChild(document.createTextNode("这是一个自定义元素"));
shadow.appendChild(span);
const spans = shadow.querySelectorAll("span");
for (const span of spans) {
span.textContent = span.textContent.toLocaleLowerCase();
}

声明式

在template中声明样式和元素,通过获取这个元素content,加到shadow dom中.

1
2
3
4
5
6
7
8
9
10
<template id="wc">
<style>
span {
display: block;
margin-top: 20px;
}
</style>
<span>I'm in the shadow DOM</span>
</template>
<div id="host"></div>
1
2
3
4
const wc = document.getElementById("wc");
const host = document.getElementById("host");
const other_shadow = host.attachShadow({ mode: "open" });
other_shadow.appendChild(wc.content);

使用哪种方式取决于你的应用程序和个人喜好。

创建一个 CSSStyleSheet 并通过 adoptedStyleSheets 将其赋给影子根允许你创建单一样式表并将其与多个 DOM 树共享。例如,一个组件库可以创建单个样式表,然后将其与该库的所有自定义元素共享。浏览器将仅解析该样式表。此外,你可以对样式表进行动态更改,并将更改传播到使用表的所有组件。

而当希望是声明式的、需要较少的样式并且不需要在不同组件之间共享样式的时候,附加 <style> 元素的方法则非常适合。

如果没有影子 DOM 提供的封装,自定义元素就无法使用。因为只需在某个页面上运行一些 JavaScript 或 CSS,就有可能无意间破坏自定义元素的行为或布局。

模板与插槽

模板

template很方便,搭配web component效果更好. 使用声明式搭配模板的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyParagraph 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));
}
connectedCallback() {
console.log("自定义元素添加至页面。");
}
}
customElements.define("my-paragraph", MyParagraph);

首先定义一个元素,shadowRoot就是独立的自定义元素,然后添加template的内容,在html中声明template并使用元素. 使用cloneNode拷贝使得能创建多个自定义元素

1
2
3
4
5
6
7
8
9
10
11
12
    <template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>我的段落</p>
</template>
<my-paragraph></my-paragraph>
<my-paragraph></my-paragraph>

通过元素上的属性设置

1
2
3
4
5
6
7
8
9
10
11
<my-paragraph margin="10"></my-paragraph>
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>我的段落</p>
</template>

通过挂载到独立的自定义元素,并将template的内容放在shadowroot下即可,当属性改变时改变对应html原生元素样式,还可以监听事件等.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MyParagraph extends HTMLElement {
static observedAttributes = ["margin"];
constructor() {
super();
let template = document.getElementById("my-paragraph");
let templateContent = template.content;
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(templateContent.cloneNode(true));
}
connectedCallback() {
console.log("自定义元素添加至页面。");
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`属性 ${name} 已变更。从${oldValue}变为${newValue}`);
if (name == "margin") {
this._shadowRoot.querySelector("p").style.margin = newValue+'px';
}
}
}
customElements.define("my-paragraph", MyParagraph);

插槽

可以使用slot元素通过声明式的语法在每个元素实例中显示不同的文本。插槽由其 name 属性标识,并且允许在模板中定义占位符,当在标记中使用该元素时,该占位符可以填充所需的任何 HTML 标记片段。

在template中声明slot,它可以有一个name属性,在自定义元素中加入想添加的元素,可以设置其slot属性,其与name相匹配,如果slot的name和元素的slot属性均不声明则进行填充.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>我的段落</p>
<!-- <slot></slot> -->
<slot name="text"></slot>
</template>
<my-paragraph margin="10"></my-paragraph>
<my-paragraph>
<h1 slot="text">Hi this is default</h1>
</my-paragraph>
1
2
3
4
5
6
7
8
9
10
11
<hello-world name="Craig">

<template shadowroot="closed">
<slot name="msgtext" class="hw-text"></slot>
<slot></slot>
</template>

<h1 slot="msgtext">Hello Default!</h1>
<p>This text will become part of the component.</p>

</hello-world>

这相当于给了一定的灵活度.

参考资料

  1. Web Component - Web API | MDN (mozilla.org)
  2. Web components (javascript.info)
  3. Web Components 入门实例教程 - 阮一峰的网络日志 (ruanyifeng.com)
  4. web-components-examples/word-count-web-component at main · mdn/web-components-examples (github.com)
  5. A Complete Introduction to Web Components in 2024 (kinsta.com)
  6. Web Components Tutorial for Beginners [2019] (robinwieruch.de)
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道