首先,人如其名,web components 可以翻译为 “网页原生组件”,它是一种 “新的” 被各大浏览器支持的一种 HTML 标准。
为什么 “新的” 要加引号?
答:因为这套标准技术实际上很早就存在了,例如当我们在 html 中添加一个视频标签 <video>
,那么网页实际运行中会出现一个视频播放器,而视频播放器中有 N 多个并非我们添加的元素,例如 播放暂停按钮、进度条 等。这些元素实际上共同构成了 <video>
组件。
只不过 <video>
组件(元素)是由 html5 为我们内置提供的,而现在,通过 web components 标准,我们可以自己不同的功能需求,像编写 React 类组件那样来编写自己的网页原生组件。
为什么没有说像 Vue 组件 ?
答:Vue2 的组件更像是 html 代码片段,而 web components 代码是一个类组件。
为什么没有说像 React hooks 组件?
答:web components 代码表现形式为 JS 中的一个类,不像 hooks 组件那样是一个函数。
Web Components 学习过程并不复杂,通常半天时间足够就学会了,下面开始。
Web Components 适用场景:
如果从开发组件的便捷度来讲,我个人觉得,目前 Web Components 也就是达到了 React 类组件的 60% 功能。
所以 Web Components 目前根本无法代替 React/Vue 。
但是以下 2 个场景,挺适合 Web Components 的。
-
场景1:对老的原生 HTML 项目的改造。
一些老的原生 html 项目如果想整体改造成 React/Vue 成本或许很大,但是局部地方可以改造成 Web Components,即方便又不至于成本很大,是个不错的方案。
-
场景2:编写一个同时可以用在 原生 HTML、React 和 Vue 中的组件
React 和 Vue 目前都支持 Web Components,所以 Web Components 确实可以做到一套组件代码同时运行在不同前端框架中。
由于 Web Components 本质上就是原生 HTML,那么理论上除 React/Vue 以外其他任何前端框架也都是会支持。
基于 Web Components 的第三方组件库:Quark
目前比较出名的是 哈啰 公司开源的 Quark
组件库。
Quark 组件库官网:https://quark-design.hellobike.com/
以下为 Quark 的官方介绍:
Quark 是一款基于 Web Components 的跨框架 UI 组件库。 它可以同时在任意框架或无框架中使用。
我使用过 Quark 组件,实话实说,Quark 组件在某些功能细节方面比不了 Antd。
我个人觉得 Quark 组件也就达到了 Antd 的 70%,但这已经很了不得了。
接下来开始学习 Web Components,大体上我们将分成 2 个部分来讲解:
- Web Components 的 3 个核心项
- Web Components 组件的 4 个生命周期函数
Web Components 由以下 3 个核心项构成:
- Custom elements(自定义元素)
- Shadow DOM(影子DOM)
- HTML templates(HTML模板)
以上 3 个核心项和 React 类组件都是可以一一对应的。
假定我们想要创建一个名为 ColorButton
的组件,那么:
如果是 React 类组件,代码套路为:
//color-button.js
class ColorButton extendS React.Component{
...
}
export default ColorButton
而对应 Web Components,则代码套路为:
//color-button.js
class ColorButton extendS HTMLElement{
...
}
customElements.define('color-button', ColorButton)
代码对比:
- 它们都是由 class 定义的类,Web Components 继承于 HTMLElement
- 最终导出该组件的方式不同,Web Components 是通过
customElement
的.define()
函数导出
customElement ?
上述代码中的 customElement
实际上是 window.customElements
。
customElement 实际上是 window 上一个默认存在的只读属性,该属性实际上是对 CustomElementRegistry
的引用。
关于 CustomElementRegistry 的详细介绍,请查阅:
https://developer.mozilla.org/zh-CN/docs/Web/API/CustomElementRegistry
.define('color-button', ColorButton) ?
该方法的第 1 个参数为自定义组件的正式名称,名称规范为:
- 不允许使用单个单词,例如
colorbutton
是不被允许的 - 每个单词都是全小写,驼峰命名 的方式也是不被允许的
- 多个单词之间使用
-
连接,例如本示例中的color-button
最终,将来网页上使用该组件时:
<color-button></color-button>
额~ 实际上这只是为了方便理解 “勉强” 攀扯上的对应关系。
为什么把 shadow 翻译为 影子?
对于前端开发人员而言,shadow 更多想到的是 CSS 中的阴影,但是在此处我们应将 shadow 翻译为 影子。
此处的 影子 含义和张艺谋的电影《影》是相同即,即 “隐藏在背后的那个人”。
Shadow DOM
含义就是:隐藏在自定义组件中的那个 DOM 根节点。
因此 Shadow DOM 确实可以勉强对应 React 类组件中 return 最外层的那个 DOM 元素。
需要强调的是:每一个自定义组件的 Shadow DOM 都是一个与主文档 document 完全独立的 DOM。
这种独立体现在:相互不干扰的 CSS 样式
关于如何在自定义组件中引入和添加 CSS 样式,我们会在稍后的示例中讲解。
给组件附加一个 Shadow Dom
每一个 Web Components 组件的构造函数中,我们都需要 附加(attach) 上它的 影子 DOM。
class ColorButton extendS HTMLElement{
constructor(){
super()
this.attachShadow({mode: 'open'})
}
}
customElements.define('color-button', ColorButton)
.attachShadow()的几个知识点:
- .attachShdow() 方法用于附加一个 影子DOM 到当前组件中
- 在该组件内部可以通过 this.shadowRoot 找到刚刚添加的影子 DOM
- 但每一个 Web Components 中永远只会存在一个影子 DOM
- 这就意味着即使你调用多次 .attachShadow() 方法,那么永远只会是最后一次那个生效
.attachShadow({mode: 'open'}) 参数说明:
在上面我们已经讲过,每一个 Web Components 内部的影子 DOM 都是与主文档 document 相互独立的。
而上面参数 mode
只有 2 个可选项:
- "open":允许外部的 JS 可以访问该 影子DOM的内元素,当然也包括可以修改元素
- "closed":禁止外部的 JS 访问该 影子DOM 的内部元素,当然你也无法修改,例如
<video>
就不允许外部 JS 访问其内部元素
但是请记住,无论自定义组件怎么设置 mode 的值,自定义组件内部是永远可以访问主 DOM 文档的。
主 DOM 文档 是指:document.body
this.shadowRoot:该组件对应的影子DOM根节点
当我们在组件内部执行过 this.attachShadow({mode: 'open'})
后,我们就可以通过 this.shadowRoot
获取到本组件对应的影子DOM 的根节点。
这个影子DOM根节点拥有普通 DOM 节点的各种属性和方法,例如向其添加一个别的 DOM 元素:
const container = document.createElement('div')
container.setAttribute('class', 'color-button')
this.shadowRoot.appendChild(container)
同时,shadowRoot 也拥有一些自己特有的属性和方法。
具体的可查阅:https://developer.mozilla.org/zh-CN/docs/Web/API/ShadowRoot
就目前我们已学习到的知识,已经足够可以去编写一个简单示例了。
示例文件目录结构如下:
- /index.html
- /color-button/index.js
- /color-button/index.css
自定义组件 JS 代码:/color-button/index.js
class ColorButton extends HTMLElement {
constructor(){
super()
this.attachShadow({mode: 'open'})
const link =document.createElement('link')
link.setAttribute('rel', 'stylesheet')
link.setAttribute('href', './color-button/index.css')
this.shadowRoot.appendChild(link)
const container = document.createElement('div')
container.setAttribute('class', 'color-button')
const circle = document.createElement('span')
circle.setAttribute('class', 'circle')
circle.style.backgroundColor = this.getAttribute('color') || '#333'
container.appendChild(circle)
const label = document.createElement('span')
label.setAttribute('class', 'label')
label.innerText = this.getAttribute('label') || ''
container.appendChild(label)
this.shadowRoot.appendChild(container)
}
}
customElements.define('color-button', ColorButton)
在上面的代码中,我们创建添加一个 <link>
标签,将当前组件所需要的 CSS 样式引入进来。
这里补充 2 点。
补充1:样式文件路径
引入 .css 文件的相对路径并不是当前组件 index.js 相对路径,而是未来使用该组件的 index.html 的相对路径。
如果你写的组件是给其他人用的,那么你可以不使用 <link>
,改为使用内置 CSS 样式标签 <style>
。
补充2:CSS资源请求次数
你可能会担心如果创建多份该组件,那么每一个组件内部都有一个 <link>
,那是不是浏览器会需要请求多次 css 样式文件?
无须担心,经过实际尝试,无论创建多少个该组件实例,始终只会请求一次该组件对应的 CSS 资源文件。
自定义组件 CSS 代码:/color-button/index.css
.color-button {
display: flex;
justify-content: center;
align-items: center;
padding: 5px;
width: 100px;
height: 50;
border: 1px solid #666;
border-radius: 3px;
cursor: pointer;
}
.color-button .circle {
flex-grow: 0;
display: inline-block;
width: 18px;
height: 18px;
margin-right: 5px;
border-radius: 50%;
}
.color-button .label {
flex-grow: 1;
display: inline-block;
font-size: 16px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
-webkit-user-select: none;
user-select: none;
}
引入并使用自定义组件:/index.html
<!DOCTYPE html>
<html lang="zh-CN">
<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>Web Components Sample</title>
<script defer src="./color-button/index.js"></script>
</head>
<body>
<color-button color="red" label="Red"></color-button>
<color-button color="yellow" label="Yellow"></color-button>
<color-button color="green" label="Green"></color-button>
<color-button color="#336699" label="#336699"></color-button>
</body>
</html>
引入自定义组件:
<script defer src="./color-button/index.js"></script>
这里有一个重要提示:一定要添加 defer
标记,以保证该 JS 是在网页整体解析完成后才去执行该的。
如果不加 defer
标记,则无法使用自定义组件。
使用自定义组件:
<color-button color="red" label="Red"></color-button>
<color-button color="yellow" label="Yellow"></color-button>
...
这里就补充一点:自定义组件一定要是成对闭合的标签 <color-button></<color-button>
。
也就是说,上面代码不可以改成下面:
<color-button color="red" label="Red" />
<color-button color="yellow" label="Yellow" />
...
在 react 中我们可以使用这种形式,但是在原生网页中是不行的
上面那样的写法,最终会被网页解析为:
<color-button color="red" label="Red">
<color-button color="yellow" label="Yellow">
...
</color-button>
</color-button>
好了,到目前位置我们已经编写出了一个简单的 Web Components 自定义组件。
在上面的组件代码中,我们创建的每一个元素,都是通过 JS 来创建的,当组件比较复杂时,这种纯 JS 创建方式就显得麻烦了。
主要是代码看着比较多。
补充:添加定义自定义组件之前先检查是否有重名
上面的示例中我们都是通过 customElements.define('color-button', ColorButton)
将 color-button
添加定义到网页中的。
但是假定网页本身已引入了其他人写的自定义 web components 组件库,并且你不清楚自己的组件是否跟别人重名,换句话说就是同名组件已经被定义添加过了。
那么为了严谨,我们可以通过 customElements.whenDefined()
来提前检查一下。
.whenDefined()
本身是一个 Promise 函数,具体代码套路如下:
customElements.whenDefined('class-container').then(() => {
customElements.define('class-container', ClassContainer)
}).catch((err) => {
console.log(err)
})
- 如果名为
class-container
的组件已经被定义过了,则会进入catch( (err)=>{ ... } )
- 否则就会正常执行
.then(() =>{ ... })
我们继续往下学习。
为了简化 JS 创建自定义组件的过程,HTML 模板就应运而生。
对应的是 2 个标签:
<template>
:模板根标签<slot>
:模板中的插槽标签
模板和插槽 这2个词的概念无需解释,会 React/Vue 的人都知道啥是。
但是这里强调的是:目前 web components 的 HTML templates 还没发展到像 React/Vue 那样可以直接应用到组件代码中的。
你必须把 HTML模板代码 提前写到要应用该组件的 HTML 中。
当然,你可以使用 JS 的方式向 主文档(body) 添加 <template>
内容。
const myTemplate = document.createElement('template')
myTemplate.innerHTML = `
<span>hello</span>
`
document.body.appendChild(myTemplate)
最关键的是 <template>
标签内并不支持 动态变量,<template>
只适用于那些固定不变的 DOM 结构。
编写一个 <template>
示例
之前我们编写的 color-button
组件不适用于模板标签,但我们还用它举例。
<body>
<template id="color-button-template">
<link rel="stylesheet" href="./color-button/index.css" />
<div class="color-button">
<span class="circle"></span>
<span class="label"></span>
</div>
</template>
<color-button color="red" slot="Red"></color-button>
</body>
请注意,我们给该模板标签添加了一个 id,其值为 "color-button-template"
自定义组件内使用模板内容
class ColorButton extends HTMLElement {
constructor(){
super()
this.attachShadow({mode: 'open'})
const template = document.querySelector('#color-button-template')
this.shadowRoot.appendChild(template.content.cloneNode(true))
}
}
customElements.define('color-button', ColorButton)
代码解释:
-
我们通过 const template = document.querySelector('#color-button-template') 找到
<template>
元素 -
而查找到的 template 实际类型为 HTMLTemplateElement,该类型继承于 HTMLElement,只不过它比 HTMLElement 多了一个
.content
的属性 -
该
.content
属性对应的是<template>
中包含的子项的 根 DOM 节点如果从网页中审查元素,该 根 DOM 节点名为
#document-fragment
,自定义组件对应的为#shadow-root
。 -
最终,我们
this.shadowRoot.appendChild(template.content.cloneNode(true))
,将全新复制的一份 模板内容 添加到当前组件中。
???问题来了
那我们原本 color-button
组件中使用的属性变量值怎么传递到 template
中?
答:没有办法,你只能在组件内部通过 JS 获取对应元素,再针对性修改其属性或值(innerText)。
不用怀疑,之前都说过了,<template>
只适用于那些固定不变的 DOM 模板结构。
但是,在 <template>
内部可以通过添加 插槽<slot>
标签来实现一些动态占位和替换。
注意是:占位和替换!
插槽标签(<slot>
)简单示例
<body>
<template id="color-button-template">
<link rel="stylesheet" href="./color-button/index.css" />
<div class="color-button">
<slot name="label">null</slot>
</div>
</template>
<color-button color="red">
<span slot="label" class="label">Red</span>
</color-button>
</body>
代码解读:
- 在模板中,我们通过添加
<slot name="label">null</slot>
,创建了一个 插槽,它的占位内容为字符串 "null" - 在组件使用中,我们通过向组件内部增加
<span slot="label" class="label">Red</span>
,将实际的元素传入进去 - 上面 2 者中通过 slot 对应的名字 "label" 进行匹配,实现占位和替换
小总结:
- 在自定义类组件,通过不断执行
this.shadowRoot.appendChild(xxxx)
这种形式创建的组件,虽然 JS 代码多,但是灵活。 - 而 模板标签 + 插槽标签,适用于那些 DOM 内容变化不多的场景。
- 当然,2 者是可以结合使用的
这里实际上是套用了 React/Vue 组件中的 生命周期函数 名称,准确来说应该称呼为:生命周期回调函数
原生组件的 4 个生命周期函数:
-
connectedCallback:当组件第一次被添加到 DOM 文档后调用
-
disconnectedCallback:当组件从 DOM 文档移除后调用
-
adoptedCallback:当组件在 DOM 文档中被移动到其他 DOM 节点时调用
-
attributeChangedCallback:当组件的某个属性发生改变后调用
这里的属性改变 包含:新增、移除、修改属性值 这 3 种情况
各个生命周期函数用途:
和我们平时在开发 React/Vue 组件时,一些常规的用途几乎相同。
例如 ,当某组件从 DOM 中移除,但组件本身此时并没有销毁,那就可以在 disconnectedCallback
函数中添加一些销毁 或 取消侦听操作。
这里重点说一下:connectedCallback 和 attributeChangedCallback
我们上面举得示例中,都是直接将组件 <color-button>
添加到 body 内,但如果是靠 JS 来动态添加和修改组件属性,那么就需要用到组件的生命周期回调函数了。
connectedCallback:
对于有些场景, JS 动态生成添加的自定义组件,在其类的构造函数中是无法通过 this.getAttribute() 获取属性值的,我们只能将这部分代码移动到 connectedCallback 回调函数中。
换句话说,在有些场景中,我们不再在类组件的构造函数中创建和添加 DOM 元素,而是改为在 connectedCallback() 中添加。
我在实际的项目中就遇到过这种情况,但不是说 100% 一定会出现这样的情况。
上面给这么多文字添加了加粗,实际上就是希望你能注意到。
因为我当初遇到了,查了很久才找到这样的解决办法。
attributeChangedCallback:
该生命周期回调函数的用法比其他的稍微特殊一点,因为它还需要一个配套的属性名监听函数。
以 color-button 组件为例,我们需要监控 color 和 label 这 2 个属性,那么我们需要额外做的是:
- 在类组件中,重写它的静态方法
observedAttributes()
- 之后,就可以在类组件的 attributeChangedCallback 函数中正确监控这 2 个属性了
具体代码如下:
static get observedAttributes() {
return ['color','label']
}
attributeChangedCallback(activeName, oldValue, newValue) {
if(activeName === 'color'){
...
}else if(activeName === 'label'){
...
}
}
有些时候,为了避免组件初始化时的一些不必要监听,可以在 attributeChangedCallback 内部增加一些排除。
attributeChangedCallback(activeName, oldValue, newValue) {
if (oldValue === null || oldValue === newValue) return
...
}
最后,我们用 JS 演示一下如何动态添加自定义组件。
<!DOCTYPE html>
<html lang="zh-CN">
<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>Web Components Sample</title>
<script defer src="./color-button/index.js"></script>
</head>
<body>
<script>
const arr = [
{ color: 'red', label: 'Red' },
{ color: 'yellow', label: 'Yellow' },
{ color: 'green', label: 'Green' },
{ color: '#336699', label: '#336699' }
]
const mydiv = document.createElement('div')
arr.forEach((item) => {
const colorButton = document.createElement('color-button')
colorButton.setAttribute('color', item.color)
colorButton.setAttribute('label', item.label)
mydiv.appendChild(colorButton)
})
document.body.appendChild(mydiv)
</script>
</body>
</html>
以上就是我在学习和使用 Web Components 的一些心得。
如果想深入学习,我建议是去查看 Quark
https://github.com/hellof2e/quark-design 组件库的源码,看一下他们是怎么使用 Web Components 开发的。