组件已经成为了页面组成的基本单元。日常开发中,我们会将一个完整的页面拆分为若干个组件,这些组件又会由一些更小的基础组件组成。之所以这么做是为了提升代码的可维护性以及可复用性,通常都是使用MVVM自带的组件功能进行封装。
有时不同产品间会使用不同的MVVM框架,有的使用Vue,有的使用React。即时是同一个框架不同的版本也可能无法兼容,例如Vue2和Vue3,这就导致了原本的组件库无法在不同技术栈的项目中使用,因为他们的组件规范都是由所用框架定义的,并不能通用。Web框架更新迭代速度很快,新框架层出不穷,几乎应用每种技术栈都需要重新封装一套组件库。
这个时候我们就需要开发一套与框架无关的通用组件库,而Web Component提供了原生的组件支持,无需依赖任何框架,很好的解决了这个问题。随着越来越多的产品开始逐渐放弃对老旧浏览器的兼容,Web Component的使用也越来越普及。
Web Component提供一套封装可复用内容的功能,也就是组件。它由三项主要技术组成:自定义元素、Shadow Dom、HTML模版。接下来我们详细介绍这三项技术。
自定义元素(Custom Element)
自定义标签允许用户定义任意标签,例如可以定义一个 <custom-button></custom-button> 标签。在使用前,我们必须先注册,浏览器才能正确识别:
class CustomButton extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const span = document.createElement("span");
span.classList.add("btn-text");
span.innerText = "button";
const style = document.createElement("style");
style.textContent = `
.btn-text {
color: red;
font-size: 16px;
}
`;
// 加入到自定义元素节点内,this指向到就是自定义元素对应的dom对象
this.appendChild(span);
this.appendChild(style);
}
}
// 注册组件
customElements.define("custom-button", CustomButton);
自定义元素通过类语法定义,规范中对自定义元素的构造函数内容有一定的限制,如果使用构造函数需要遵循以下规则:
super()必须是构造函数中的第一个语句- 构造函数中不允许出现
return语句(除了return;或return this;) - 构造函数不能使用
document.write()或document.open()方法。 - 构造函数中不允许操作自定义元素自身DOM(即通过
this调用DOM API),否则浏览器可能会抛出Failed to construct 'CustomElement’的异常,这些操作应该放到connectedCallback()回调中处理,但需要注意connectCallback()可以触发多次,需要一个保护机制防止多次触发。
自定义类需要指定一个基类(HTMLElement或者其他内置元素类)以获取基本的DOM功能,可以在connectedCallback() 回调中通过DOM API创建自定义元素里的具体内容。上面代码中我们定义了一个custom-button元素继承自HTMLElement,里面包含红色的‘button’文案,显示效果如下:
这里需要注意,为了区分自定义元素和原生元素,自定义元素的名称必须带‘-’,不能是单个单词,否则无法注册。此外自定义元素也不可以使用自闭合标签的形式。
这里也可将基类改为内置元素类,例如将HTMLElement改为HTMLButtonElement ,就可以实现对内置元素进行扩展:
class CustomButton extends HTMLButtonElement {
constructor() {
super();
}
connectedCallback() {
const span = document.createElement("span");
span.classList.add("btn-text");
span.innerText = "button";
const style = document.createElement("style");
style.textContent = `
.btn-text {
color: red;
font-size: 16px;
}
`;
// 加入到自定义元素节点内,this指向到就是自定义元素对应的dom对象
this.appendChild(span);
this.appendChild(style);
}
}
// 这里需要配置extends指定继承自哪个元素
customElements.define("custom-button", CustomButton, { extends: "button" });
扩展内建元素在使用上也有些区别,不能直接使用自定义标签,如果跟上面一样直接使用 是不会生效的,而是要使用基类元素,通过is属性指定为自定义元素:
<button is="custom-button"></button>
最终渲染结果也是<button>标签,拥有<button> 的默认行为,同时又拥有自定扩展的功能。
注意:自定义元素创建后更改
is属性不会改变元素行为,不能通过is动态切换
Shadow Dom
Shadow Dom的官方定义如下:
一组 JavaScript API,用于将封装的“影子”DOM 树附加到元素(与主文档 DOM 分开呈现)并控制其关联的功能。通过这种方式,你可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
上面我们定义了一个custom-button,它的内部元素包含一个.btn-text选择器,如果此时HTML中还有另一个元素也包含同样的class会怎样呢:
<custom-button></custom-button>
<div class="btn-text">text</div>
可以看到,自定义元素外部带有btn-text选择器的其他元素也被应用了同样的样式。执行document.getElementsByClassName('btn-text') 也会将内部span元素返回,这显然不是我们希望的效果。组件内部的样式不应该污染外部样式,组件内部的元素也不应该被外部直接访问,这时候就需要使用到Shadow Dom。
它相当于一个沙箱,只要将自定义元素的内容放在Shadow DOM中,不仅可以防止内部样式污染外部,也可以阻止外部访问内部元素。使用Shadow DOM改造后的custom-button代码如下:
class CustomButton extends HTMLElement {
connectedCallback() {
// 为自定义元素创建shadow dom
const shadow = this.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.classList.add("btn-text");
span.innerText = "button";
const style = document.createElement("style");
style.textContent = `
.btn-text {
color: red;
font-size: 16px;
}
`;
shadow.appendChild(span);
shadow.appendChild(style);
}
}
customElements.define("custom-button", CustomButton);
可以看到,使用Shadow DOM后,外部的同名class无法生效,并且自定义元素下多了一个shadow-root节点(也就是Shadow DOM的根节点)。这里有一些相关术语需要了解:
- Shadow host:一个常规 DOM 节点,Shadow DOM 会被附加到这个节点上。
- Shadow tree:Shadow DOM 内部的 DOM 树。
- Shadow boundary:Shadow DOM 结束的地方,也是常规 DOM 开始的地方。
- Shadow root: Shadow tree 的根节点。
以上面的代码为例, 就是Shadow host,我们自定义创建的内容与Shadow root组成Shadow Tree附着到 节点。
Shadow DOM不仅可以用于自定义元素,一些内置元素也可以创建Shadow DOM,具体可以查询相关文档。
此时我们再次通过document.getElementsByClassName('btn-text') 查询,只会返回div.btn-text。如果想访问怎么办呢?在使用attachShadow创建Shadow DOM时,传递了一个mode属性,这个属性的作用就是就是设置是否放开外部访问,如果设置为open,那么外部就可以通过元素的shadowRoot接口进行访问:
如果设置为closed,那么访问shadowRoot则会返回null,禁止外部访问。
模版与插槽
上面代码中,自定义元素的内容都是通过DOM API创建,当内容复杂时就需要写一大堆的创建逻辑。Web Component提供了模版<template> 可以在里面以常规HTML元素书写可复用内容,模版内容不会展示在页面中,但是可以通过js读取,我们将自定义元素的内容用模版的方式实现:
// html
<template id="custom-button-template">
<style>
.btn-text {
color: red;
}
</style>
<span class="btn-text">button</span>
</template>
// js
class CustomButton extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "closed" });
// 通过content属性拿到模版内容,而不包含template元素本身
const content = document.getElementById("custom-button-template").content;
// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
// 当然也可以不拷贝直接讲模版内容移到自定义元素中,但这样就其他组件就没法复用这个模版了
shadow.appendChild(content.cloneNode(true));
}
}
customElements.define("custom-button", CustomButton);
此外Web Component还提供了插槽 提升模版灵活度,上面代码中里的文案就可以通过插槽从外部插入:
// html
<template id="custom-button-template">
<style>
.btn-text {
color: red;
}
</style>
<span class="btn-text">
<slot></slot>
</span>
</template>
<custom-button>button</custom-button>
// js
class CustomButton extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "closed" });
// 通过content属性拿到模版内容,而不包含template元素本身
const content = document.getElementById("custom-button-template").content;
// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
shadow.appendChild(content.cloneNode(true));
}
}
customElements.define("custom-button", CustomButton);
如果有需要多个插槽时可以使用具名插槽,通过name属性标记不同的插槽,例如给 再添加一个icon插槽就可以这样写:
<!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>
<template id="custom-button-template">
<style>
.btn-text {
color: red;
}
</style>
<slot name="icon"></slot>
<span class="btn-text">
<slot></slot>
</span>
</template>
<custom-button>
<span slot="icon">i</span>
button
</custom-button>
<div class="btn-text">text</div>
<script src="./comp.js"></script>
</body>
</html>
Attribute与Property
通常我们使用组件时会通过props传递各种类型的配置。而web component只是一个自定义元素,所以它和普通元素一样,并没有开箱即用的props功能,但是可以通过attribute和property来替代。例如给添加一个width配置:
class CustomButton extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "closed" });
this.setWidth(this.getAttribute("width"));
// 通过content属性拿到模版内容,而不包含template元素本身
const content = document.getElementById("custom-button-template").content;
// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
shadow.appendChild(content.cloneNode(true));
}
setWidth(width) {
if (!isNaN(+width)) {
this.style.width = width + "px";
} else {
this.style.width = width || "auto";
}
}
}
// html
<custom-button width="60">button</custom-button>
此时如果改变width属性的值,按钮宽度并不会变化,想要监听属性的变化可以通过 static get observedAttributes: () => string[] 这个静态方法指定需要监听的属性列表,这样就可以在attributeChangedCallback: (name: string, oldVal: string, newVal: string) => void生命周期回调中获取变化后的属性值。将上面width改成响应式后的代码:
class CustomButton extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "closed" });
this.setWidth(this.getAttribute("width"));
// 通过content属性拿到模版内容,而不包含template元素本身
const content = document.getElementById("custom-button-template").content;
// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
shadow.appendChild(content.cloneNode(true));
}
setWidth(width) {
if (!isNaN(+width)) {
this.style.width = width + "px";
} else {
this.style.width = width || "auto";
}
}
static get observedAttributes() {
return ["width"];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === "width") {
this.setWidth(newVal);
}
}
}
// html
<custom-button width="60">button</custom-button>
width通过attribute的形式传给组件,但attribute只能接收string类型的值一些复杂的对象配置无法通过这种方式传递。这种情况下可以通过DOM对象property进行传值:
class CustomButton extends HTMLElement {
// 定义实例属性
config = {
fontSize: "14px",
color: "#ddd",
};
connectedCallback() {
const shadow = this.attachShadow({ mode: "closed" });
this.setWidth(this.getAttribute("width"));
// 通过content属性拿到模版内容,而不包含template元素本身
const content = document.getElementById("custom-button-template").content;
// 深拷贝一份content作为Shadow DOM内容,只有深拷贝才能复制完整的DOM结构,浅拷贝只能复制元素本身,不包含子元素
shadow.appendChild(content.cloneNode(true));
}
setWidth(width) {
if (!isNaN(+width)) {
this.style.width = width + "px";
} else {
this.style.width = width || "auto";
}
}
setStyle() {
Object.keys(this.config).forEach((key) => {
this.style[key] = this.config[key];
});
}
// 外部调用更新配置
setConfig(config) {
this.config = config;
this.setStyle();
}
static get observedAttributes() {
return ["width"];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === "width") {
this.setWidth(newVal);
}
}
}
// html
<custom-button id="btn" width="60">button</custom-button>
<script>
document.getElementById('btn').setConfig({...})
</script>
另一种方式则是以JSON字符串的形式传递复杂值,在组件内部进行解析转换,但JSON无法传递函数类型的属性,所以还是推荐使用property的形式传递复杂值。
事件
Web Component中,通过自定义事件CustomEvent 进行通信:
class CustomButton extends HTMLElement {
config = {
fontSize: "14px",
color: "#ddd",
};
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
const content = document.getElementById("custom-button-template").content;
const node = content.cloneNode(true);
node.querySelector(".btn-text").addEventListener("click", () => {
this.dispatchEvent(
new CustomEvent("text-click", {
detail: {
text: "text",
},
})
);
});
...
}
}
// html
<script>
document
.querySelector("custom-button")
.addEventListener("text-click", (e) => console.log(e.detail));
</script>
内部抛出自定义事件,外部监听该事件以获取内部传递出的数据。
生命周期
自定义元素类有以下几种生命周期回调:
connectedCallback:当自定义元素第一次被连接到文档 DOM 时被调用。disconnectedCallback:当自定义元素与文档 DOM 断开连接时被调用。adoptedCallback:当自定义元素被移动到新文档时被调用。attributeChangedCallback:当自定义元素的一个属性被增加、移除或更改时被调用。
其中attributeChangedCallback上面已经介绍过了,需配合observedAttributes使用,另外几个回调执行顺序如下:
class CustomButton extends HTMLElement {
connectedCallback() {
console.log("connectd");
}
disconnectedCallback() {
console.log("disconnected");
}
adoptedCallback() {
console.log("adopted");
}
}
// html
<script>
const customBtn = document.querySelector("custom-button");
document.querySelector("#delete").addEventListener("click", () => {
customBtn.remove();
});
document.querySelector("#move").addEventListener("click", () => {
document.querySelector(".btn-text").appendChild(customBtn);
});
</script>
刷新页面,当CustomButton构造函数被执行后connectedCallback被调用。接着点击move按钮,将CustomButton移动到另一个DOM中,此时会先出发disconnectedCallback,再触发adoptedCallback。
实现一个倒计时组件
最后我们利用web component封装一个倒计时组件作为练习。组件支持设置倒计时时长(单位:秒)、开始、暂停、前缀配置。时长变化重新倒计时,倒计时单位最大为天,最小为秒,以x天x小时x分钟x秒到形式展示,最终实现效果如下:
具体实现代码如下:
class CountDown extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.autoStart = this.getAttribute("auto-start") !== null;
this.seconds = +this.getAttribute("seconds");
this.timer = null;
const content = this.initTemplate(this.seconds);
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = content;
if (this.autoStart) {
this.start();
}
}
start(skipFirst) {
const $time = this.shadowRoot.querySelector("#time");
const render = () => {
$time.innerText = this.formatTime(this.seconds--);
};
if (skipFirst) {
render();
}
if (this.timer) {
clearInterval(this.timer);
}
this.timer = setInterval(() => {
if (this.seconds === 0) {
this.dispatchEvent(new CustomEvent("finished"));
clearInterval(this.timer);
}
render();
}, 1000);
}
stop() {
if (this.timer) {
clearInterval(this.timer);
}
}
formatTime(seconds) {
if (seconds === 0) {
return "0秒";
}
const days = Math.floor(seconds / 86400); // 1天有86400秒
seconds %= 86400;
const hours = Math.floor(seconds / 3600); // 1小时有3600秒
seconds %= 3600;
const minutes = Math.floor(seconds / 60); // 1分钟有60秒
seconds %= 60;
let result = "";
if (days > 0) {
result += days + "天";
}
if (hours > 0) {
result += hours + "小时";
}
if (minutes > 0) {
result += minutes + "分钟";
}
if (seconds > 0) {
result += seconds + "秒";
}
return result;
}
initTemplate(seconds) {
return `
<style>
:host {
display: block;
}
.count-down {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #000;
font-weight: 600;
}
.count-down__prefix {
color: #ddd;
font-weight: 400;
}
</style>
<div class="count-down">
<div class="count-down__prefix">
<slot name="prefix"></slot>
</div>
<div id="time">
${this.formatTime(seconds)}
</div>
</div>
`;
}
static get observedAttributes() {
return ["seconds"];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === "seconds") {
this.seconds = +newVal;
this.start(true);
}
}
}
customElements.define("count-down", CountDown);
html:
<!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>
<count-down id="countDown" seconds="100000" auto-start>
<span slot="prefix">😊距离开学还有:</span>
</count-down>
<button id="start">start</button>
<button id="stop">stop</button>
<button id="random">random</button>
<script src="./comp.js"></script>
<script>
const countDown = document.querySelector("#countDown");
countDown.addEventListener("finished", () => {
console.log("开学啦!");
});
document.querySelector("#start").addEventListener("click", () => {
countDown.start();
});
document.querySelector("#stop").addEventListener("click", () => {
countDown.stop();
});
document.querySelector("#random").addEventListener("click", () => {
countDown.setAttribute("seconds", Math.ceil(Math.random() * 200 + 10));
});
</script>
</body>
</html>