跳到主要内容

3.3-WebComponent

Create by fall on 17 Oct 2021
Recently revised in 06 May 2023

Web Component​

Web Component 意为 Web 组件系统,使用 JS 创建和 React、Vue 的组件类似可复用的组件。实现比如生命周期,CSS 样式隔离,组合继承等方法。并且可以创造一个用户定制的 HTML 标签,拥有 HTML 元素的所有属性,可以在任何支持的浏览器中通过引入这些脚本,就可以调用这些组件。

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

  • Custom element(自定义元素):一组 JavaScript API,允许您自定义元素及其行为(创建 <my-component>),然后可以在您的用户界面中按照需要使用它们。
  • Shadow DOM(影子 DOM):一组 JavaScript API,允许元素的功能私有(隔离 DOM 和 CSS 样式)通过将封装的“影子”DOM 树附加到元素(单独的 DOM 作用域)进行控制其关联的功能。这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  • HTML template(HTML 模板): <template> 和 <slot> 元素使您可以编写不在页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

对比框架​

目前的前端框架具有数据绑定、状态管理和相当标准化的代码库等功能所带来的额外价值。以及使用习惯。

框架的数据绑定会更方便,在 Web Component 中,也允许直接用数据来设置 web component 的属性。并且可以通过 attributeChangedCallback 来监听这些属性的改变。前端框架提供了一个标准的代码基准,可以使团队中的每一个新人从一开始就熟悉这些代码基准。

现在 web component 已经得到了广泛的支持,可以与框架一较高下:

  • 不需要框架
  • 性能更好,生成代码更少
  • 易于继承,不需要编译
  • 真正的局部 CSS 作用域(Shadow DOM)
  • 只有 HTML,CSS,JavaScript

但,框架和 Web Component 不一定只有对立,肯定是相互融合,最终呈现出更繁荣的生态环境。

基本使用​

使用​

创建模板,该内容放置在 index.html 的 body 标签内

<template id="span-group" >
<span>没什么的,对吧?</span>
</template>

添加该脚本

window.onload = function () {
class MyElement extends HTMLElement {
// 当元素被创建时,会调用 constrcutor 函数,更多内容请查看生命周期
constructor() {
// 必须首先调用 super 方法
super()
// 元素的功能代码写在这里
const dom = document.querySelector('#span-group')
const content = dom.content.cloneNode(true) // 使用模板的克隆,其他位置引用时不会相互影响
const text = this.getAttribute('text') // 获取当前组件上的 text 属性
content.querySelector('span').innerText = text
this.appendChild(content) // 在当前 DOM(MyElement) 中插入这些内容
}
}
// 最后向 html 中注册组件
// 为了避免和 native 的标签冲突,必须用 `-` 进行链接
customElements.define('my-component', MyElement) // 分别输入想在 dom 中使用的名称和对应的类
}

使用

<my-component></my-component>
<my-component text="有问题啊"></my-component>
<my-component text="没有问题啊"></my-component>
<my-component></my-component>

状态管理​

数据监听和事件触发

class MyElement extends HTMLElement{
// 定义被监视的属性
static get observedAttributes(){
return ['foo', 'bar'] // 这些属性都在 标签上被调用
}
attributeChangedCallback(attr, oldVal, newVal){
// 每当将属性添加到 observedAttributes 的数组中时
switch(attr){
case 'foo':
console.log('foo changed, new value is',newVal)
break;
case 'bar':
}
}
}

给每一个需要被监听的属性添加get函数

get text () {
return this.getAttribute('text');
}

再定义属性的set函数

set text (value) {
this.setAttribute('text', value);
}

现在我们就能直接给组件对象定义属性并赋值,而不需要定义在组件DOM元素上。如下

const mybutton = document.querySelector('my-button');
mybutton.text = 'Hi!';

自定义方法​

在你的元素上定义的任何方法,都会成为其公共 JavaScript 的一部分。

class MyElement extends HTMLElement {
// ...
doSomething() {
// do something in this method
}
}
const element = document.querySelector('my-element');
element.doSomething();

事件​

自定义元素的标准事件(如鼠标和键盘事件)默认情况下将会从 Shadow DOM 中冒泡,事件的 target 属性还是会指向自定义元素本身。如果你想找出事件实际来自 Shadow DOM 中的哪个元素,可以调用 event.composedPath() 来检索事件经过的节点数组。

有两种发送方式

  • 自定义组件上添加自定义方法监听 DOM 元素事件,在需要的地方调用组件的自定义方法
  • 自定义事件使用 CustomEvent 从自定义元素中抛出任何你想要的事件。
class MyButton extends HTMLElement{
constructor(){
super() // 同 react 都需要在最开始调用 super
const template = document.getElementById('my_button')
const content = template.content.cloneNode(true)
const button = content.querySelector("#btn")
button.innerText = this.getAttributes('text')
// button 被点击时,触发点击事件
button.addEventListener('click',ev=>{
// 把点击事件挂载到该组件上,点击时向外传递参数
this.onClick('f,m,l')
// 因为 HTMLElement 同样继承了 EventTarget,所以也有 dispatchEvent 方法
// this.dispatchEvent(
// new CustomEvent('onClick', {
// detail: 'Hello'
// })
// )
})
}
connectedCallback() {
this.dispatchEvent(new CustomEvent('custom', {
detail: {message: 'a custom event'}
}));
}
}
// 在需要的时候,调用组件的自定义方法
document.querySelector('my-button').onClick = value => {
console.log(value)
}
class MyElement extends HTMLElement {
// ...

}
// on the outside
document.querySelector('my-element').addEventListener('custom', e => console.log('message from event:', e.detail.message));

如果一个事件从 Shadow DOM 的节点抛出而不是自定义元素本身,默认不会从 ShadowDOM 上冒泡,除非它使用了 composition: true。

class MyElement extends HTMLElement {
// ...
connectedCallback() {
this.container = this.shadowRoot.querySelector('#container');
// dispatchEvent is now called on this.container instead of this
this.container.dispatchEvent(new CustomEvent('custom', {
detail: {message: 'a custom event'},
composed: true // without composed: true this event will not bubble out of Shadow DOM
}));
}
}

生命周期​

生命周期执行顺序

类进行创建的时候,执行 constructor

当元素被插入 DOM 时,会调用 connectCallback

从文档中删除时执行 disconnectedCallback,在关闭浏览器时不会调用

当web component被移动到新文档时执行 adoptedCallback。

被监听的属性发生变化时执行 attributeChangedCallback

constructor -> attributeChangedCallback -> connectedCallback -> disconnectedCallback

生命周期回调

class myElement extends HTMLElement{
adoptedCallback(){
// 当元素通过调用 document.adoptNode(element) 被采用到文档时将会被调用
}
static observedAttributes(){ // 被监视的属性
return ['foo', 'bar'] // 这些属性都在 标签上被调用
}
attributeChangedCallback(attr, oldVal, newVal){
// 每当将属性添加到 observedAttributes 的数组中时
switch(attr){
case 'foo':
break;
case 'bar':
}
}
connectCallback(){
// 当这个元素被插入DOM树的时候将会触发当前方法
// 对元素进行设置
// 此时可以确定所有的属性和子元素都已经可用的办法
}
disconnectCallback(){
// 当元素从DOM中被移除的时候,会被调用
}
}

理论上通过序列化可以将复杂值传递给属性,但是这样会影响性能,并且你可以直接调用组件的方法,所以不需要这样做。

attributeChangedCallback 要在 connectedCallback 之前执行

因为 web 组件上的属性,被插入DOM前就应该初始化配置了。因此 attributeChangedCallback 要在 connectedCallback 之前执行。 这意味着你需要根据某些属性的值,在 Shadow DOM 中配置节点,那么你需要在构造函数中引用这些节点,而不是在 connectedCallback 中引用它们。

Shadow DOM​

Shadow DOM,可以将自定义元素的 HTML 和 CSS 完全封装在组件内。整个自定义内容将以单个的 HTML 标签出现在文档的 DOM 树中(不用再担心内部 id 的冲突)。内部的元素将始终不会影响到它外部的元素(除了 :focus-within)。

比如网页中原生的 <video> 元素也使用了 Shadow DOM。它作为一个单独的标签使用,因为在它的 Shadow DOM 中,包含了一系列的按钮和其他控制器,所以能够拥有播放和暂停视频的控件。

要在 Chrome 中显示 Shadow DOM,进入开发者工具中的 Preferences 中,选中 Show user agent Shadow DOM。你就可以看到该元素的 Shadow DOM了。

一些特有的术语需要了解

  • Shadow host:常规 DOM 节点,Shadow DOM 会被附加到这个节点上。
  • Shadow tree:Shadow DOM 内部的 DOM 结构
  • Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方
  • Shadow root:Shadow tree 的根节点

开启 Shadow DOM

class MyElement extends HTMLElement {
// 当元素被创建时,会调用 constrcutor 函数,更多内容请查看生命周期
constructor() {
// 必须先调用 super()
super()
const template = document.querySelector('template#span-group')
const content = template.content.cloneNode(true) // 使用模板的克隆,其他位置引用时不会相互影响
// 获取 shadowRoot
const shadowRoot = this.attachShadow({ mode: "open" });
// 在 shadow DOM 中插入内容,插入的内容会应用插槽
shadowRoot.appendChild(content)
// 通过 shadowRoot 去查找元素
shadowRoot.querySelector('.pink')
}
}

样式​

样式需要搭配 Shadow DOM 实现样式隔离,使组件内所有的 CSS 都只应用于组件本身。

可以将样式书写在 template 中,以保证样式仅应用于 button 上。

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `<p>Hello world</p>`;

这定义了一个带 mode: open 的 Shadow root,这意味着可以再开发者工具找到它并与之交互,配置暴露出的 CSS 属性,监听抛出的事件。同样也可以定义 mode:closed,会得到与之相反的表现。

自定义元素默认使用 display:inline,可以使用 :host 选择器对组件本身进行样式设置。但是,从外部定义在组件本身的样式优先于使用 :host 样式。

<template id="mybutton">
<style>
:host {
display: block;
}
button {
color:blue;
border: 0;
border-radius: 5px;
background-color: brown;
}
</style>
<button id="btn">My Button</button>
</template>

样式也支持标签的写法

// 该样式会覆盖 Shadow DOM 中定义的 :host
my-element {
display: inline-block;
}
// 当然也允许你根据上下文的样式化。
// 通过 disabled 的 attribute 来改变组件的背景是否为灰色
:host([disabled]) {
opacity: 0.5;
}
// 自定义元素会从周围的 CSS 中继承一些属性,例如颜色和字体等
// 如果想清空组件的初始状态并且将组件内的所有 CSS 都设置为默认的初始值,使用:
:host {
all: initial;
}

样式一般通过暴露一个变量名称,让用户通过 DOM 上的节点去自定义这个样式,不应该在外部进行更改。

// 比如,你设置默认背景颜色为 #ffffff
:host {
--background-color: #ffffff;
}
// 现在用户可以在组件的外部设置它的背景颜色
// 假设 shadow dom 中有一个节点为 div#container
#container {
background-color: var(--background-color);
}
// 也可以通过标签进行修改
my-element {
--background-color: #00cc00;
}

通过提供局部的 CSS、HTML,Shadow DOM 将标记和样式捆绑到自己的组件内,而不需要任何工具和命名约定。你再也不用担心新的 class 或 id 会与现有的任何一个冲突。

除此之外,还可以通过 CSS 变量设置 web 组件的内部样式,还可以通过插槽将 HTML 注入到 Web Components中。

插槽​

插槽需要搭配 Shadow DOM 才能实现插入对应的位置

<slot>(插槽),将使用 Web Components 的包裹的内容添加到组件内。

<template id="span-group">
<span>文档默认内容,对吧?</span>
<p>
<!-- 创建一个插槽,会将 name 为 my-text 的内容展示出来 -->
<slot name="my-text">My default text</slot>
</p>
<slot name="image">
<div>默认的内容</div>
</slot>
</template>

创建对象时需要使用 shadow DOM

constructor() {
super()
const template = document.querySelector('template#span-group')
const content = template.content.cloneNode(true) // 使用深度克隆,其他位置引用时不会相互影响
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(content)
}

使用

<my-component>
<ul slot="my-text">
<!-- 对应名称的 my-text 会出现在对应的 html 中 -->
<li>Let's have some different text!</li>
<li>In a list!</li>
</ul>
<div>滚</div>
</my-component>

用户提供的标记称为 light DOM。使用 Shadow DOM 会将 light DOM 和 Shadow DOM 合并成为一个新的 DOM 树。

通过 slot 在 Shadow DOM 中展示的元素被称为分发节点。这些组件被插入前的样式也将会被用于他们插入后。在 Shadow DOM 中,分发节点可以通过 ::sloted() 来获取额外的样式

::slotted(img) {
float: left;
}

::sloted() 可以接受任何有效的 CSS 选择器,但它只能选择顶级节点,例如 ::slotedd(section img) 的情况,将不会作用于 this content

<image-gallery>
<section slot="image">
<img src="foo.jpg">
</section>
</image-gallery>

你可以通过 JavaScript 与 slots 进行交互去监测哪个节点被分发到哪个 slot,哪些 slot 被插入了元素,以及 slotchange 事件。

要找出哪些元素已经被分发给对应的 slots 可以使用 slot.assignedNodes() 如果你还想查看 slot 的默认内容,你可以使用 slot.assignedNodes({flatten: true}) 要找出哪些 slot 被分发的元素,可以使用 element.assignedSlot 当 slot 内的节点发生改变,即添加或删除节点时,将会出发 slotchange 事件。要注意的是,只有当slot节点自身改变才会触发,而这些 slot 节点的子节点并不会触发。

slot.addEventListener('slotchange', e => {
const changedSlot = e.target;
console.log(changedSlot.assignedNodes());
});

在元素第一次初始化时,Chrome 会触发 slotchange 事件,而 Safari 和 Firefox 则不会。

API​

Web Component 相关的 API

customElements​

customElements.define()

customElements.get()

const MyElement = customElement.get('my-element')
// get 后,可以直接获取到 customElement 的类,实例化后即可添加到 DOM 中
document.body.appendChild(new MyElement)

customElements.upgrade()

customElements.whenDefined()

customElements.whenDefined('my-element')
.then(() => {
})

shadowRoot​

进阶使用​

模板元素​

除了使用 this.shadowRoot.innerHTML 来向一个元素的 shadow root 添加 HTML,你也可以使用 <template>。template 保存HTML 供以后使用。它不会被渲染,并只有确保内容是有效的才会进行解析。template 中的 JavaScript 不会被执行,也不会获取任何外部资源,默认情况下它是隐藏的。

当一个 web component 需要根据不同的情况来渲染不同的标记时,可以用不同的模板来完成:

class MyElement extends HTMLElement {
constructor() {
const shadowRoot = this.attachShadow({mode: 'open'});
// 将两个 template 放在 shadow root 内,但这两个模板都是隐藏的,最开始只渲染了 div#container
this.shadowRoot.innerHTML = `
<template id="view1">
<p>This is view 1</p>
</template>
<template id="view1">
<p>This is view 1</p>
</template>
<div id="container">
<p>This is the container</p>
</div>`
}
connectedCallback() {
// 获取模板内容
const content = this.shadowRoot.querySelector('#view1').cloneNode(true);
this.container = this.shadowRoot.querySelector('#container');
// 将模板内的内容插入到模板内
this.container.appendChild(content.content);
}
}

扩展原生元素​

自定义元素允许使用扩展原生内置元素,支持增强已经存在的 HTML 元素,例如 <img> 和 <buttons>。

扩展现有 HTML 元素的会继承了元素的所有属性和方法。这允许对现有元素进行逐步的增强。这意味着即使在不支持自定义元素的浏览器中,它仍是可用的。它只会降级到默认的内置行为。而如果它是一个全新的 HTML 标签,那它将会完全无法使用。

例如,我们想要增强一个 <button>。

class WordCount extends HTMLButtonElement {
constructor() {
super();
// button 中默认内容为正确,点击后切换内容
this.textContent = '正确'
this.onclick = () => {
if (this.innerText === '正确') {
this.textContent = '错误'
} else {
this.textContent = '正确'
}
}
}
}
customElements.define('toggle-button', WordCount, { extends: 'button' });

我们的 web component 可以扩展 HTMLButtonElement,比 HTMLElement 更具体。当使用 customElements.define() 的时候还需要添加一个额外的参数 {extends: 'button'} 来表示我们的类扩展的是 <button> 元素。但是这是必要的,因为多个元素可能共享一个 DOM 接口。例如 <q> 和 <blockquote> 都共享 HTMLQuoteElement 接口。

这个增强后的 button 可以通过 is 属性来被使用

<button is="my-button">

现在它将被我们的 MyElement 类增加,如果它加载在一个不支持自定义元素的浏览器中,它将降级到一个标准的按钮,真正的渐进式增强。

注意,在扩展现有元素时,部分元素不能使用 Shadow DOM。这只是一种扩展原生 HTML 元素的方法,它继承了所有现有的属性、方法和事件,并提供了额外的功能。当然可以在组件中修改元素的 DOM 和 CSS,但是尝试创建一个 Shadow root 将会抛出一个错误。

扩展内置元素的另一个好处就是,这些元素也可以应用于子元素被限制的情况。例如 thead 元素只允许 tr 作为其子元素,因此<awesome-tr> 元素将呈现无效标记。这种情况下,我们可以拓展内置的 tr 元素。并像这样使用它:

<table>
<thead>
<tr is="awesome-tr"></tr>
</thead>
</table>

封装​

自定义按钮​

将组件进行封装,使组件可以直接通过 script 调用

const template = document.createElement('template');
template.innerHTML = `
<style>
button {
width: 60px;
height: 30px;
cursor: pointer;
color: blue;
border: 0;
border-radius: 5px;
background-color: #F0F0F0;
}
</style>
<div>
<button id="btn">Add</button>
<p id="message"><slot name="my-text">My Default Text</slot></p>
<ul id="text-list"></ul>
</div>
`;
const Texts = [
'My lady, Hello!',
'BuiBuiBui',
'BiliBili',
'Haiwei is NO.1'
]
class MyButton extends HTMLElement {
constructor () {
super()
const content = template.content.cloneNode(true);
const button = content.querySelector('#btn');
const textList = content.querySelector('#text-list');
this.$button = button;
this.$message = content.querySelector('#message');
button.addEventListener('click', (evt) => {
const li = document.createElement('li');
li.innerText = Texts[Math.floor(Math.random() * 4)];
textList.appendChild(li);
this.dispatchEvent(
new CustomEvent('onClick', {
detail: 'Hello fom within the Custom Element'
})
)
})
this.attachShadow({ mode: 'open' }).appendChild(content);
}
get text () {
return this.getAttribute('text');
}
set text (value) {
this.setAttribute('text', value);
}
static get observedAttributes() {
return ['text'];
}
attributeChangedCallback(name, oldVal, newVal) {
this.render();
}
render() {
this.$message.innerText = this.text;
}
}
window.customElements.define('my-button', MyButton)

在 index.html 中直接引入即可使用

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Component</title>
<script src="./MyButton.js"></script>
</head>
<body>
<my-button>
<p slot="my-text">Another text from outside</p>
</my-button>
</body>
<script>
const mybutton = document.querySelector('my-button');
mybutton.addEventListener('onClick', (value) => {
console.log(value)
mybutton.text = value.detail
});
</script>
</html>

图片懒加载​

这是一个图片懒加载的组件 lazy-img 自定义组件主要以元素 img 标签进行实现

<lazy-img
src="path/to/image.jpg"
width="480"
height="320"
delay="500"
margin="0px"></lazy-img>

测试​

与为 Angular 和 React 这样的框架编写测试相比,测试 web components 既简单又直接。不需要转换或者复杂的设置,只需要创建元素,并将其添加到 DOM 中并运行测试。 这里有一个使用 Mocha 的测试

import 'path/to/my-element.js';

describe('my-element', () => {
let element;
beforeEach(() => {
element = document.createElement('my-element');
document.body.appendChild(element);
});
afterEach(() => {
document.body.removeChild(element);
});
it('should test my-element', () => {
// run your test here
});
});

在这里,第一行引入了 my-element.js 文件,该文件将我们的 web component 通过 ES6 模块对外暴露。这意味着我们测试文件也需要作为一个 ES6 模块加载到浏览器中。这需要以下的index.html能够在浏览器中运行测试。除了Mocha,这个设置还加载了WebcomponentsJS polyfill,Chai用于断言,以及Sinon用于监听和模拟。

<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="../node_modules/mocha/mocha.css">
<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<script src="../node_modules/sinon/pkg/sinon.js"></script>
<script src="../node_modules/chai/chai.js"></script>
<script src="../node_modules/mocha/mocha.js"></script>

<script>
window.assert = chai.assert;
mocha.setup('bdd');
</script>
<script type="module" src="path/to/my-element.test.js"></script>
<script type="module">
mocha.run();
</script>

</head>
<body>
<div id="mocha"></div>
</body>
</html>

在加载完所需的 scripts 后,我们暴露 chai.assert 作为一个全局变量,因此我们可以在测试中简易的使用 assert(),并设置 Mocha 来使用 BDD 接口。然后加载测试文件,并调用 mocha.run() 运行测试。

请注意,在使用ES6模块化时,还需要将 mocha.run() 放在type="module"的script中。因为 ES6 模块在默认情况下是延迟执行的。如果 mocha.run() 放在一个常规的 script 标签中,他将会在加载 my-element.test.js 之前执行。

参考文章​

作者文章名称
CodeXWebComponent是个什么东西?
山竹和阿瓜Web Component可以取代你的前端框架吗?
MDN 官方文档使用 templates and slots