为了更好的阅读体验,可以点击阅读原文进行阅读
尽量复用已有代码是每个开发者无师自通的本领,在前端领域,所有热门的框架无疑都拥有一个共同的特点:组件化开发。因为在Web Component
之前并没有原生的组件化解决方案,所以我们不得不依赖一些工程化手段来做到这一点。而目前主流浏览器都已很好地支持了Web Component标准,这是一种原生的组件化开发方案。它允许我们创建一个包含html结构、js脚本和css样式的可复用组件,而不用担心冲突问题。
自定义元素和扩展内置元素
我们可以通过两种方式来创建一个封装的组件:自定义元素
和扩展内置元素
。两者的区别就是我们需要从头开始完整的实现自定义元素的行为,而扩展内置元素会使得组件继承内置元素已有的特性,例如继承自li
元素就会有相应的list-style
,但是要定义它们的话都需要使用window.customElements.define方法,该方法接受三个参数,分别组件名称、组件实现类及继承的元素(可选),下面是简单的示例:
// 自定义元素类继承自HTMLElement,且在define时不需要传入第三个参数
class MyElement extends HTMLElement {}
window.customElements.define("my-component", MyElement)
// 扩展内置元素继承自对应的DOM类,并且在define时需要传入第三个参数声明继承自哪个内置元素
class MySpan extends HTMLSpanElement {}
window.customElements.define("my-span", MySpan, { extends: "span" })
接下来在html文件中引入这个js文件就可以使用组件了:
<!-- 直接以标签的形式使用自定义元素即可 -->
<my-element />
<!-- 通过指定is属性来声明需要使用的扩展内置元素 -->
<span is="my-span"></span>
生命周期回调
每个自定义组件都有自己的生命周期,以下是4个主要的生命周期回调方法:
connectedCallback
:当自定义元素第一次被连接到文档 DOM 时被调用。disconnectedCallback
:当自定义元素与文档 DOM 断开连接时被调用。adoptedCallback
:当自定义元素被移动到新文档时被调用。attributeChangedCallback
:当自定义元素的一个属性被增加、移除或更改时被调用。
其中有一点需要注意的是,如果要使用attributeChangedCallback监听属性的变化,需要在类中使用observedAttributes
属性声明要监听的属性列表,就像下面这样:
class MyElement extends HTMLElement {
// 通过attributesChangedCallback监听组件的size和color属性的变化
static observedAttributes = ["size", "color"]
connectedCallback() {
console.log("自定义组件已被连接到DOM")
}
disconnectedCallback() {
console.log("自定义组件已断开和DOM的连接")
}
adoptedCallback() {
console.log("自定义组件已被移动到新文档")
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`${name}属性已由${oldValue}变更为${newValue}`)
}
}
使用Shadow DOM
和其他框架一样,Web Component最重要的目标就是封装
,那么封装好的组件就不应该被外部环境所影响,这就需要有一种机制来完成自定义元素的结构和样式隔离。Shdow Root就很好地做到了这一点,简单直译的话,我们可以称之为影子根,我们可以在这个影子根中添加任意的HTML结构和css样式而不受外部的影响,先来看一个简单的使用示例:
class CustomComponent extends HTMLElement {
// 一般我们在自定义元素连接到DOM的回调中创建影子根并添加结构与样式等而不是在constructor中
// 在此回调中,可以通过this.attachShadow创建一个影子根元素,之后就可以像普通DOM节点一样操作它
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" })
const span = document.createElement("span")
span.textContent = "I'm a span tag!"
shadow.appendChild(span)
}
}
customElements.define("custom-component", CustomComponent)
attachShadow方法的第二个参数是一个包含mode属性的对象,该属性可选值为oepn或close,当设置为open时,可以通过影子根宿主节点的shadowRoot属性访问到影子根元素,设置为close时则无法访问(示例中就是可以通过custom-component这个节点的shadowRoot属性访问到影子根元素)
注意:设置mode属性并不应该被视为强大的安全机制,浏览器插件等方式可绕过此限制,此参数更应该被认为是一种建议,建议不要修改组件内的结构与逻辑
声明好自定义元素并在文档中使用时,它的结构就像这样:
此时如果在外部添加任何样式都是影响不到Shadow DOM内部的元素的,如果想要为其内部元素添加样式我们共有两种方式可用:编程式
及声明式
,其中声明式样式需要涉及到template,我们后面再介绍。如果要编程式地添加样式,我们可以在Shadow DOM内插入style或link标签,也可以使用使用CSSStyleSheet来构造样式,如下:
class CustomComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" })
const span = document.createElement("span")
span.textContent = "I'm a span tag!"
shadow.appendChild(span)
// 通过style标签添加样式,也可以使用link标签href外部css文件
const style = document.createElement("style")
style.textContent = `span{ color: red; }`
shadow.appendChild(style)
// 通过CSSStyleSheet构造样式
const sheet = new CSSStyleSheet()
sheet.replaceSync(`span{ font-weight: bold; }`)
shadow.adoptedStyleSheets = [sheet]
}
}
customElements.define("custom-component", CustomComponent)
此时的HTML结构及样式表如图所示:
使用template
关于template标签:如果想要多次使用某一块HTML标记结构而不想重复书写,使用template模板是一个很好的选择。此元素及其内容不会在DOM中展示,但可以使用script引用。如果我们在body内写入如下HTML结构:
<template>
<span>I'm a span tag in template!</span>
</template>
这时我们可以发现页面上并没有显示任何内容,在devtool中的DOM结构如下图:
利用这一特性,刚好就可以将template标签和Shadow DOM来配合使用,我们可以在自定义元素类中将模板中的结构及内容添加到自定义元素的影子根。现在假如我们还是要定义一个和之前一样的自定义组件,里边包含一个span元素,字体是红色并加粗,利用模板就可以很容易做到这一点:
<template id="custom-component-template">
<style>
span {
color: red;
font-weight: bold;
}
</style>
<span>I'm a span tag in template!</span>
</template>
<!-- 此组件将展示tempalte的内容 -->
<custom-component />
class CustomComponent extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: "open" })
const template = document.getElementById("custom-component-template")
const templateNode = template.content.cloneNode(true)
shadow.appendChild(templateNode)
}
}
customElements.define("custom-component", CustomComponent)
现在我们就可以重复利用这个封装好的custom-component组件了,并且可以将模板中的结构和内容复用到多个组件
使用slot
通过使用模板大大降低了我们定义可复用结构的难度,但是有时候这个可复用结构并不是一成不变的,比如上面的结构,有时候我们希望只有一个span元素,有时候我们希望其中是一个li列表(我们的示例比较简单,实际情况中可能大部分结构都是固定的,但某一部分我们希望它们可以拥有一些“动态”的特性),那么借助slot
插槽元素就可以做到这一点:
<template id="custom-component-template">
<style>
::slotted([slot="content"]) {
color: red;
font-weight: bold;
}
</style>
<!-- 这里可能包含复杂的其他固定结构 -->
<slot name="content">slot不可用或未被填充</slot>
</template>
这里假设我们还是使用template的结构和内容直接定义了一个custom-component组件,现在如果我们想要将slot标签部分替换成span元素只需要像下面这样使用:
<custom-component>
<span slot="content">I'm a span tag revealed by slot!</span>
</custom-component>
而如果想要将slot替换成一个li列表,就可以像下面这样使用:
<custom-component>
<ul slot="content">
<li>I'm a li tag revald by slot!</li>
<li>I'm a li tag revald by slot!</li>
</ul>
</custom-component>
可以发现结合slot使得组件的定义和组合变得更加方便了!同时也有一个细节需要注意,就是定义替换slot的元素的样式时需要使用::slotted伪元素
应用:实现popup-tip
现在我们可以基于Web Component来实现一个popup-tip组件,要求当鼠标放置到元素上方时右侧弹出文字提示,提示内容可以通过属性来控制,点击元素时能随机修改文字颜色,如图:
完整实现如下:
class PopupTip extends HTMLElement {
connectedCallback() {
const poperText = this.getAttribute("poper-text")
const template = document.getElementById("component-popup-text-elements")
const templateNode = template.content.cloneNode(true)
const shadow = this.attachShadow({ mode: "open" })
if (poperText) {
templateNode.querySelector(".poper-text").innerHTML = poperText
}
shadow.appendChild(templateNode)
const rn = () => Math.floor(Math.random() * 255 + 1)
this.shadowRoot.querySelector(".popup-box").addEventListener("click", function () {
const randColor = Array(3)
.fill(0)
.map((_) => rn())
.join(",")
this.style = `color: rgb(${randColor})`
})
}
}
customElements.define("popup-tip", PopupTip)
<html>
<head>
<script type="text/javascript" src="popup-tip.js"></script>
</head>
<body style="padding: 100px">
<template id="component-popup-text-elements">
<style type="text/css">
.popup-box {
user-select: none;
cursor: pointer;
position: relative;
display: inline-block;
padding: 8px 12px;
border-radius: 5px;
background-color: #f5f5f5;
}
.popup-box .poper-text {
position: absolute;
top: 50%;
right: -10px;
opacity: 0;
font-size: 14px;
color: white;
padding: 3px 10px;
border-radius: 3px;
transform: translate(100%, -50%);
transition: opacity 200ms;
background-color: black;
}
.popup-box .poper-text::before {
content: "";
position: absolute;
left: -10px;
top: 50%;
display: inline-block;
border: 5px solid black;
border-top-color: transparent;
border-left-color: transparent;
border-bottom-color: transparent;
transform: translateY(-5px);
}
.popup-box:hover .poper-text {
opacity: 1;
}
</style>
<div class="popup-box">
<div class="poper-text"></div>
<slot name="content"></slot>
</div>
</template>
<popup-tip poper-text="This is a tip!">
<span slot="content">popup-tip component</span>
</popup-tip>
</body>
</html>
参考文献
MDN: Web Components
原文地址:梁高强的博客-原生组件化方案:Web Component