Web Component
前言
Web Component 可以看作是一种为了重用界面组件的基于浏览器的技术方案。 主要由Shadow DOM、Tempaltes 标签、自定义元素、HTML 导入(Imports) 这四部分标准组成, 但是要注意这四个标准每个都可单独使用,不一定结合在一起,但是就跟葫芦娃一样只有组合一起使用才能发挥出最大的效果,Web Component。 这篇文章先分别介绍四个标准,然后组合一起实现一个 Web Component 的完整例子,快上车。
Shadow DOM
或多或少在工作当中会有这样的体验:
- 我们在给元素起
id名时总要小心翼翼的,生怕一不留神就重复了,导致一些难以追踪的问题出现。 - 给元素起
class名也是一样的,当单个页面变得很复杂而为了避免重复,本来一个好好的<div class='title'>很可能就变成了<div class='title-level-2'>(这个问题其实 css-modules 是个不错的解决方案)。 - 或者有时候我们希望其他的样式和脚本根本就不要操作页面中的一些元素,因为这些元素只会出现在特定的场景中,并不希望任何人的脚本或者样式会影响到它们。
还好有了 Shadow DOM,它也正是封装 Web Component 的关键要素。标准给 HTMLElement 实例添加了一个 attachShadow 方法,利用这个方法,我们可以把 Shadow DOM 附加到一个已经存在的 HTML 元素上。
<html>
<head></head>
<body>
<p id="hostElement"></p>
<script>
const host = document.querySelector('#hostElement')
// attachShadow 返回一个 shadowRoot 对象,表示的是
// 元素里 DOM 子树的 root 元素。这个 DOM 子树与文档树是分离的。
// mode 表示封装模式,open 表示可以通过 element.shadowRoot
// 获取上面说的 DOM 子树的 root 元素,另一个可能的参数是 closed。
const shadowRoot = host.attachShadow({ mode: 'open' })
shadowRoot.innerHTML = '<span>Hello World</span>'
shadowRoot.innerHTML += '<style>span { color: red; }</style>'
</script>
</body>
</html>
ShadowRoot 里的元素会作为实际渲染的元素显示出来,这样 p#hostElement 会显示为红色的 Hello World,而且外面的样式不会影响里面的,里面的样式也不会影响外面的元素。
Template 标签
浏览器新增了一个 template 标签,写在标签里的任何子元素浏览器都不会去渲染也不会去解析,直到你通过脚本的方式让浏览器去"激活"这部分内容。
<template>
<style type="text/css">
div {
border: solid 1px black;
width: 100px;
height: 100px;
}
</style>
<script>
alert(123)
</script>
</template>
<div></div>
<script>
setTimeout(() =>
document.body.appendChild(document.importNode(document.querySelector('template').content, true))
, 2000)
</script>
如果你运行这段代码,最开始浏览器什么都不会有,过两秒后会 alert 一个 123,然后页面才会出现有黑色边框的正方形。这里涉及一个重要的属性,就是 templateElement.content 的属性,它的值是 Document Fragment 类型的对象。
其实 template 元素经常会跟 slot 标签搭配使用,这个我们待会再讲。
自定义元素
<x-product data-name='web-component'></x-product>
<script>
class XProduct extends HTMLElement {
constructor() {
super()
const shadowRoot = this.attachShadow({ mode: 'open' })
const p = document.createElement('p')
// 获取自定义元素上的 data-name 属性值
p.textContent = this.getAttribute('data-name')
shadowRoot.appendChild(p)
}
}
customElements.define('x-product', XProduct)
</script>
直接上代码会好解释很多,自定义元素首先当然需要继承自 HTMLElement 类,constructor 方法是在元素被创建或者更新的时候调用的。我们通过调用 attachShadow 方法让 Shadow DOM 附着到自定义元素上。然后又动态创建了一个 p 元素,其内容是自定义元素上的 data-name 属性值。最后调用 customElements.define 方法完成自定义一个 HTML 元素 (不能多次注册同一标记,浏览器了解某一新标签后也不能再撤回了)。另外需要注意标准要求自定义元素名需要有一个连字符,而且使用的时候不能自我封闭,必须编写封闭标签(<x-product></x-product>)。
扩展 HTMLElement 可确保自定义元素继承完整的 DOM API,并且添加到类的任何属性与方法都会成为元素 DOM 接口的一部分。
一定需要在 constructor 里调用 this.attachShadow 方法吗?当然不是必须的,这里只是稍微演示了一下 Shadow DOM 跟自定义元素结合在一起会发生什么。你可以直接写:
constructor() {
super()
const p = document.createElement('p')
// 获取自定义元素上的 data-name 属性值
p.textContent = this.getAttribute('data-name')
this.appendChild(p)
}
这样会直接把 p 标签插入到 x-product 元素中,页面的显示效果跟上面是一样的,但是这样就没 Shadow DOM 的优势了。
Web Component
上面的代码可以看到我们是手动创建了一个 p 标签,然后插入到 shadowRoot 中的,如果有很多元素的话我们不可能每个都手动创建再插入,这个时候就该用到 template 标签了。正如我们最开始说的,虽然这些标准可以独立使用,但是结合在一起的话会发挥出更大的作用。下面看一个比较完整的例子:
<html>
<head></head>
<body>
<style type="text/css">
p {
color: red;
}
</style>
<template id='x-product-template'>
<style type="text/css">
div {
border: solid 1px #ccc;
width: 200px;
height: 200px;
}
</style>
<div>
<span id='name'></span>
<slot name='fill-text'>None</slot>
</div>
</template>
<x-product data-name='web-component'>
<p slot='fill-text'>Hello world</p>
</x-product>
<script>
class XProduct extends HTMLElement {
constructor() {
super()
const shadowRoot = this.attachShadow({ mode: 'open' })
const content = document.getElementById('x-product-template').content
content.getElementById('name').textContent = this.getAttribute('data-name')
shadowRoot.appendChild(document.importNode(content, true))
}
connectedCallback() {
console.log('connected')
}
disconnectedCallback() {
console.log('disconnected')
}
}
customElements.define('x-product', XProduct)
</script>
</body>
</html>
slot,槽的意思。其作用看代码就很清晰了,它可以有个name属性及预定义元素。当在自定义元素中有其他子元素时,子元素可以有个slot属性,表示想匹配替换哪个slot元素。- 自定义元素其实除了必不可少的
constructor方法之外,还可以定义其他几个原型方法,比如这里的connectedCallback表示当元素被插入到文档中会触发的回调,disconnectedCallback表示元素从文档中移除时触发的回调等等。
HTML 导入(Import)
再想象一个场景:你依照 Web Component 标准开发了一个功能完备又炫酷的组件,恰好这个组件很多人都需要用,难道这个时候大家只能把 template 和你写的脚本都复制粘贴到每个页面里吗?
显然这很不优雅,所以 HTML Import 就出现了。
<head>
<link rel="import" href="/path/to/imports/stuff.html">
</head>
将 link 标签的 rel 设置为 import,href 指向的是任意页面元素 (HTML/CSS/JS) 组成的文件,一行简单的代码就能将你的组件导入到页面里。href 当然可以是网络上的 URL,不过其他域的资源需要允许 CORS。
如果想引入某个组件到你的页面里,一般来说这么用就够了,但是 HTML Import 所包含的东西却远没这么简单,下面再稍微介绍一些其他的特性。
如果想访问导入的内容,通过 link 元素的 import 属性:
const content = document.querySelector('link[rel="import"]').import
导入的内容并不在主文档中,仅仅作为主文档的附属存在,但是导入中的脚本会在包含导入文档的 window 上下文中运行。因此你可以将导入中的 HTML 元素或者样式加入到当前文档中。
<style type="text/css">
div {
color: red;
}
</style>
<script>
// importDoc 是导入文档的引用
var importDoc = document.currentScript.ownerDocument;
// mainDoc 是主文档(包含导入的页面)的引用
var mainDoc = document;
// 获取导入中的第一个样式表,复制,
// 将它附加到主文档中。
var styles = importDoc.querySelector('style');
mainDoc.head.appendChild(styles.cloneNode(true));
</script>
当然如果你的导入文档中已经用 customElements.define 定义了一些组件,那么你无需任何操作就能在页面中使用它们,因为正如上面说的,导入中的脚本会在当前文档中执行。还有一些关于导入的子导入及管理依赖等等其他方面的知识这里就不展开篇幅了。
总结
可以看到上面比较详细而又不详尽的介绍了 Web Component 的相关知识,鉴于 Web Component 的兼容性及流行度,可能大家目前并不会实际使用在项目里(虽然有一些 polyfill, 比如 Polymer),只是想稍作了解,掌握 Web 的开发方向,所以这篇文章就是帮助大家快速理解其中的核心概念。我这里仅仅是抛砖引玉,如果大家有兴趣继续深入研究里面的每个 API 的话可以再去 MDN 上看看文档和相关的规范。