什么是 Web Components?
Web Components 并非单一的技术,而是基于现有 Web 标准的一系列技术规范的集合,使得开发者可以构建出浏览器原生支持的、可复用的 HTML 标签。
主要包含以下三种技术规范:
- Custom Elements:开发者可以使用它来创建新的 HTML 标签。
- Shadow DOM:一组 JavaScript API,用于将创建的 DOM Tree 插入到现有的元素中,且 DOM Tree 不能被外部修改。即提供组件封装、样式隔离的能力。
- HTML Template:通过
template和slot标签来编写模板,可以让开发者灵活编写自己的组件。
解决了什么问题?
作为前端开发者你是否遇到过以下的问题:
-
想要开发一个组件库,技术选型的时候难免会纠结于使用哪款前端框架,比如公司前端团队有多个技术栈,选择了 React 就无法兼顾使用 Vue 的团队。当然最稳妥的方式是给使用不同框架的团队定制一套同样的组件库。但是这样无疑加大了工作量,并且日后的可维护性和扩展性也大大降低。
-
如果仅仅是开发一些简单的前端页面,使用流行的前端框架似乎有点大材小用了;或者您是狂热的 vanillajs 拥趸,不想使用任何前端框架。但是又想借助组件化的开发思想,尽可能地复用代码,提高开发效率。原生 HTML 标签孱弱的封装复用能力以及 CSS 样式隔离这个世纪性难题又让我们不得不借助一个框架。
那么是否存在着一种方案,能够解决以上问题呢?即仅使用原生 JavaScript 写一套代码就能开发出一个可复用的、兼容不同框架的自定义组件呢?答案或许就是 Web Components。
起步
在 CS 宇宙中,万物起源于 Hello, World。所以,我们先创建一个名为 <hello-world> 的标签:
class HelloWorld extends HTMLElement {
constructor() {
super()
this.addEventListener('click', () => {
console.log(this.innerHTML)
})
}
}
window.customElements.define('hello-world', HelloWorld)
<hello-world>I am in a hello world tag!</hello-world>
打开浏览器开发者工具,你就能看到一个 <hello-world> 标签啦:
custom elements 主要通过 CustomElementRegistry 接口来定义,该接口的 define 方法用来注册一个 custom element。方法的函数签名是 define(name, class, extends),接收以下三个参数:
name: 所创建元素的名称class: 用于定义元素行为的类extends: 可选参数,指定所创建元素继承于哪个原生元素,可以继承任意原生元素
在上面的示例中,我们首先声明了一个继承于 HTMLElement 的类————HelloWorld, 并通过 customElements.define 定义了一个名称为 <hello-world> 的自定义标签。
自定义元素的一个特性就是在类声明中 this 指向 DOM 元素本身,比如,在这个示例中,this 就指向我们的 <hello-world> 。这就意味着我们可以在声明元素类的时候可以使用所有的 DOM Api,比如,通过 this.addEventListener 添加事件,通过 this.querySelectorAll(xxx) 来查询节点等。
自定义元素的规则:
- 自定义元素不能是自闭和的,比如不能这样使用自定义元素:
<hello-world />。 - 自定义元素的名称必须是一组用短横线(dash)
-分隔、符合 DOMString 标准 的字符串,用于区分原生 HTML 元素。例如,<my_tab>、<mytab>这种就不符合规范。 - 不可以多次声明同名的元素,否则浏览器会抛出
DOMException错误。
在 起步中,我们使用 Web Components 提供的 Api 创建了一个简单的自定义标签,<hello-world>。不过,除了打开浏览器开发者工具可以看到名称和普通元素不一样之外,好像和原生 <div> 没什么区别嘛。 如果只是创建一个包裹任意元素的标签,直接用 <div> 不就完事儿了?
别着急,刚才我们只是接触了 Web Components 的其中一种技术 ———— Custom Elements,接下来,我们将逐步了解剩下的两种技术规范。
Shadow DOM
现在给你一个这样的需求:不借助任何第三方库,开发一个标签,里面的内容是 hello xxx,其中 xxx 是该标签 name 属性反转后的字符串,并且默认的文字颜色是绿色。比如,<p name="liam"></p>,那么最后标签里面的文字是 hello mail。
看起来似乎很简单:无非就是通过各种 DOM Api 来获取 DOM 节点、属性、改变其样式、innerHTML 呗。
具体代码就不占篇幅了,仔细想想,这个解决方案是不是有很多问题:
- 复用性太差,几乎是一次性的,想要复用只能进行源码级别的复制粘贴
- 共用一个上下文,样式隔离困难,内部的样式会和外部的冲突,外部的样式也可以覆盖内部,展示的效果可能和预期有差距
那么,是否存在一种机制可以解决上述问题呢?我们首先来看下 Google developers 上关于 Shadow DOM 的一个解释:
Shadow DOM is just normal DOM with two differences: 1) how it's created/used and 2) how it behaves in relation to the rest of the page. Normally, you create DOM nodes and append them as children of another element. With shadow DOM, you create a scoped DOM tree that's attached to the element, but separate from its actual children. This scoped subtree is called a shadow tree. The element it's attached to is its shadow host. Anything you add in the shadows becomes local to the hosting element, including . This is how shadow DOM achieves CSS style scoping.
总结下来,使用 Shadow DOM 我们可以创建一个具有独立作用域的 DOM 树,在这个 DOM 树内部所有元素的结构、行为、样式均不受外部的影响。至此,上文提到的问题的答案已经呼之欲出了,那就是使用 Shadow DOM 来解决样式隔离、组件复用的问题。
创建 Shadow DOM
通过 Element.attachShadow 方法可以给任意元素创建一个 Shadow DOM,该方法的返回值我们称之为一个 shadow root,shadow root 是附着在一个『宿主』元素里的文档片段,我们往这个片段里面添加的任意元素都将与外界隔离。
拿上文的需求举例:
class ReverseTag extends HTMLElement {
constructor() {
super();
const $box = document.createElement("p");
let userName = "default name";
if (this.hasAttribute("name")) {
userName = this.getAttribute("name").split("").reverse().join("");
}
$box.innerText = `Hello ${userName}`;
const shadowRoot = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
style.textContent = `
p {
color: green;
}
`;
shadowRoot.appendChild($box);
shadowRoot.appendChild(style);
}
}
customElements.define("reverse-tag", ReverseTag);
<reverse-tag name="liam"></reverse-tag>
<p>This is an Out Side P tag</p>
<style>
p {
color: #bbf;
}
</style>
打开浏览器,可以看到名称字符串被反转了,并且颜色为绿色,用于对照的普通 p 标签和自定义组件内部的 p 标签,两者样式互不影响:
mode
细心的读者可能注意到上文创建 Shadow Root 的方法 Element.attachShadow() 中传入了一个对象作为参数:{mode: open}。查看相关 Api 文档,发现 Element.attachShadow() 这个方法接收一个 shadowRootInit 对象作为参数,该对象可以赋值两个属性:mode 和 delegatesFocus。我们重点说一下这个 mode 属性。
mode 属性可以赋值为 open 或者 closed。该属性指定了 Shadow Root 的 DOM 树的封装模式,当 mode 为 open 的时候,可以在 Shadow Root 外层通过 JavaScript DOM Api 来获取内部元素,例如 Element.shadowRoot,反之,当 mode 属性为 closed 的时候,我们创建了一个看似闭合的 shadow tree,在外层无法通过 JavaScript DOM Api 来获取内部元素。
我们将上文的 <reverse-tag> 改为 closed 模式,并且尝试将其 Shadow DOM 打印出来:
// ...
const shadowRoot = this.attachShadow({ mode: "open" });
// ...
console.log(this.shadowRoot);
打开浏览器控制台,发现打印出的值为 null:
一个比较典型的例子就是原生元素 <video>, 由于浏览器用了 closed 模式实现了这个标签,所以我们无法通过 JavaScript 来获取 shadow DOM。
注意事项
注意,并不是所有的 HTML 标签都 可以添加 Shadow DOM,原因如下:
- 该元素已有自己内部的 shadow DOM,例如
<textarea>、<input>等。 - 该元素添加 shadow DOM 是无意义的,如
<img>。
组件化
OK,到这里,我们已经探索了很多关于 Web Components 的知识了,现在我们可以做一些稍微复杂的组件了,比如, 参考 Google developers 做一个 Tab 组件。
首先需要确定的是 DOM 结构和接口设计。使用过任意 Tab 组件的开发者都知道,一个 Tab 组件大多由以下两个部分组成:
- 点击切换项目的按钮
- Tab 展示容器
接口设计以及样式方面,由于仅仅用来学习理解 Web Components,所以这里一切从简,仅实现基本效果即可,故笔者只暴露出了 selected 这个接口用来确定初始选定 Tab。
最终呈现出来的组件使用效果如下:
<my-tabs>
<button>Tab1</button>
<button>Tab2</button>
<button>Tab3</button>
<section>Panel-1<section>
<section>Panel-2<section>
<section>Panel-3<section>
</my-tabs>
有了结构,我们心里肯定已经有了大概的组件开发思路了:
customElements.define(
"my-tabs",
class extends HTMLElement {
constructor() {
super();
const $box = document.createElement("xxx");
const shadowRoot = this.attachShadow({ mode: "open" });
const style = document.createElement("style");
$box.innerHTML = `
// 结构...
`;
style.textContent = `
// 一些样式...
`;
shadowRoot.appendChild($box);
shadowRoot.appendChild(style);
}
}
);
通过观察上文的组件接口设计、开发思路,心细的读者可能会发现以下问题:
-
首先是结构和样式的代码全部使用 JavaScript 模板字符串来编写,抛开兼容性(需要浏览器至少支持 ES2015)不说,这样的开发效率相比 jsx 和类似于小程序、Vue 的模板来说,实在是过于低效了。
-
看起来
<my-tabs>这个容器组件似乎是使用了什么黑魔法让我们的<button>和<section>能够各司其职,「本分」地安置在需要他的地方(切换标签和展示内容)。
template
对于问题一,为解决组件结构、样式代码使用模板字符串编写效率低下的问题,Web Components 在 HTML 规范中引入了 <template> 标签。
可以把 <template> 标签看作是一个特殊的 <div>,我们可以往里面包裹内容,甚至是编写内部样式,只不过和普通 <div>有所不同的是,<template> 标签里面的所有内容默认不会展示到网页上,直到我们通过 JavaScript 的 API 获取到这段模板代码,将其插入到 DOM 树中。所以我们可以这样简化我们的组件代码:
<template id="tpl">
<style>
/* 样式... */
</style>
<div id="tabs"></div>
<div id="panels"></div>
</template>
接着在定义组件的类的构造函数中,我们通过 DOM API 获取到 template 实例,获取到实例后,一般不能直接对模板内的元素进行修改,要调用 Element.content.cloneNode 进行一次拷贝,因为页面上的模板并不是一次性的,可能其他的组件也要引用。
constructor() {
super();
const tpl = document.getElementById('tpl')
const content = tpl.content.cloneNode(true)
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(content)
}
slot
对于问题二,有过 Vue 或者小程序开发经验的朋友应该会条件反射地联想到 slot 这个东西,没错,在 Web Components 中,同样存在着 slot 可以让我们分发内容到组件的指定位置中去,赋予了组件更高的可扩展性。并且,Vue 的 slot Api 设计灵感 正是源自于 Web Components。
Vue implements a content distribution API inspired by the Web Components spec draft, using the element to serve as distribution outlets for content.
我们可以在 <template> 里的任意位置插入 <slot> 标签,并且通过设置 name 属性来给一个插槽命名。 由此,上面的例子,我们可以写成这样:
<template id="tpl">
<style>
/* 样式... */
</style>
<div id="tabs">
<slot id="tabsSlot" name="title"></slot>
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
</template>
在使用的时候,可以这样将对应的 DOM 放入插槽中去:
<my-tabs background>
<button slot="title">Tab 1</button>
<button slot="title" selected>Tab 2</button>
<button slot="title">Tab 3</button>
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</my-tabs>
到这里,我们已经将以上两个问题解决了,剩下的就是把我们的 tab 组件完善,由于篇幅有限,我将代码放到 codesandbox 上,读者可以自行查看这个组件的源码。
总结
随着前端技术的不断更新迭代,我们可以看到开发者对提升前端开发效率的不断探索,React、Vue 等 framework 就是社区给出的答卷,私以为 jsx 扩充了 JavaScript,template 语法扩充了 HTML,虽然实现方式不一样,但是他们的初衷我认为有很重要的一点就是让开发者更快、更容易地创建易于复用的自定义组件,尽可能达到视图、逻辑分离。
而通过上文对 Web Components 的简单探索,我们惊喜地发现其具有组件复用、自带样式隔离的特点,并且,最重要的一点就是 Web Components 没有运行时,可以和任意现有的 Web Framework 共存,真正做到了组件一次编写,到处运行。因此,W3C 目前正在大力推广 Web Components,可以大胆预想等到 Web Components 这套 API 规范成为标准被绝大多数浏览器支持后,开发者再也不用耗费时间在框架选择上,而是聚焦于组件本身。可以这么说,对于提升前端开发效率、体验,Web Components 就是官方给出的答卷。或许,Web Components 就是未来前端组件化开发的标准答案呢?
那么,
未来来了么?
通过上文的简单上手实践我们可以发现,Web Components 的一些 API 实在是过于底层了,编写一个小组件需要使用很多次 document.querySelector、appendChild 等 DOM API,和几大主流的 Web Framework 相比,开发体验像是回到了 jQuery 那个刀耕火种的时代。目前看来,Web Components 距离大规模投入生产开发还有很长一段路要走。