🗒️Web Component
custom element, shadow DOM, HTML template
Last updated
custom element, shadow DOM, HTML template
Last updated
作为开发人员,我们都知道“尽可能地重用代码”是个很不错的办法。Web Component 就旨在创建可复用的自定义元素,它的功能是封装起来的,并不会影响到页面上的其它代码。
Web Component 由三个主要技术组成,分别是:
自定义元素(Custom Element):一组 JavaScript API,用来定义自定义元素及其行为。
Shadow DOM:一组 JavaScript API,用来将功能封装的 Shadow DOM 树附加到页面元素上,并控制其功能。Shadow DOM 的样式和脚本是单独渲染的,所以不用担心它会和页面上的其它代码产生冲突。
HTML 模板(HTML template):用元素 <template>
和 <slot>
来写标记模板,它们不会渲染到页面上,非常适合写那些可以被自定义元素多次复用的代码结构。
在 Web 文档上,自定义元素的控制器是 CustomElementRegistry
对象,它允许我们在页面上注册一个自定义元素,并返回相关信息。可以通过 window.customElements
属性获取到它的实例,它提供了注册自定义元素和查询已注册元素的方法。
define()
定义一个新的自定义元素
get()
返回自定义元素的构造函数
upgrade()
直接升级自定义元素
whenDefined()
返回一个空的 promise,它会在自定义元素被定义的时候 resolves。
如果该元素已经被定义了,那么返回的 promise 就会被立即 fulfilled。
当我们想在页面上注册一个自定义元素的时候,可以使用 customElements.define()
方法。
它有三个参数,分别是:
DOMString,表示自定义元素的名称
名称必须包含字符 -
,不能是单个单词
DOMString 是一个 16 位无符号整数,通常解释为 UTF-16 代码单元,这完全对应于 JavaScript 的原始类型 String
当一个 Web API 接受 DOMString 时,提供的值会用 ToString()
被字符串化
对于 Symbol 以外的类型,调用 ToString()
会和 String()
函数具有相同的行为
某些 Web API 有个历史遗留问题:会把 null
转成空字符串而不是 "null"
class
对象,定义了元素的行为
可选的,是一个对象,其中 extends
属性指定了该自定义元素继承的内置元素
有两种类型的自定义元素:
autonomous custom elements,自治的自定义元素
customized built-in elements,自定义的内置元素(方便 SEO 和 A11Y)
它不继承标准的 HTML 元素。我们可以像使用普通的 HTML 元素那样使用它,比如:
注册它的时候,大约长这样:
它继承自基本的 HTML 元素。创建时就得指明它所继承的元素,使用时是写基本元素,然后通过 is
属性来指定自定义元素的名称。比如:
注册它的时候,需要显式指定所继承的元素。如下:
我们可以在自定义元素的类定义中,定义几个不同的回调,它们会在元素生命周期的不同点触发。
connectedCallback
执行时机:当自定义元素被附加到文档时
这将发生在每次移动节点的时候,可能是在元素的内容被完全解析之前
当自定义元素不再和文档连接时也会被执行,所以用 Node.isConnected
判断下
disconnectedCallback
执行时机:当自定义元素和文档 DOM 断开连接时
adoptedCallback
执行时机:当自定义元素移动到新文档时
attributeChangedCallback
执行时机:当自定义元素的属性有添加/删除/更改时
要监听哪些属性是在静态 get 方法 observedAttributes()
中指定的
使用说明:
自定义元素的渲染,是放在 constructor()
中,还是 connectedCallback()
中?
有一种说法:应该放 connectedCallback()
,因为在 constructor()
中调用 getAttribute()
会得到 null
,那时实例被创建了但是还未插入页面。但是根据我的测试,constructor()
的调用时机就是真正插入到 HTML 中时,且 getAttribute()
能正常取到值
另外,connectedCallback()
的调用时机(移动节点或移除节点时也会被调用),总觉得在语义上不适合做组件的初次渲染
所以,个人更倾向于放在 constructor()
里
关于生命周期,在使用上需要深入测试下它们会被哪些 DOM API 触发
Web 组件的封装功能能够将标签结构、样式和行为给隐藏起来,然后和页面上的其它代码分开,这样就不用担心代码冲突了而且还能保持代码整洁。Shadow DOM API 就是这其中的关键部分,它能将功能封装的 DOM 附加到元素上。
Shadow DOM 不算是一个全新的技术,因为其实浏览器有一直用它来封装元素的内部结构,比如 <video>
元素,我们在 DOM 中只看到了一个 <video>
,其实它在其 Shadow DOM 中包含了一系列按钮和其它控件。Shadow DOM 规范将此技术扩展到了自定义元素中。
在开发者工具里,开启“Show user agent shadow DOM”,就能看到浏览器自己用 shadow DOM 实现的 HTML 元素。
Shadow DOM 允许将“隐藏的 DOM 树”附加到常规 DOM 树中的元素上,这个“隐藏的 DOM 树”就是 Shadow DOM 树。Shadow DOM 树的根节点是 shadow root,我们可以使用常规的 DOM API 将任何元素附加到 shadow root 上。关系如下图:
shadow tree:Shadow DOM 内部的 DOM tree
shadow root:shadow tree 的根节点
shadow host:常规的 DOM 节点,即附加 Shadow DOM 的那个节点
shadow boundary:Shadow DOM 结束 & 常规 DOM 开始的地方
结合下文的一个例子,来感受下这 4 个术语。
我们可以用 Element.attachShadow()
将 shadow root 附加到任何元素上。比如:
open
意味着我们可以在主页面的上下文里使用 JavaScript 来访问 Shadow DOM,即 elementRef.shadowRoot
closed
则意味着我们在主页面里访问不到其 Shadow DOM,此时 elementRef.shadowRoot
会返回 null
以下元素可以被附加一个 shadow root
autonomous custom element(自治的自定义元素)
除了 <address>
之外的内容块元素:<body>
, <main>
, <aside>
, <nav>
, <article>
, <header>
, <footer>
, <h1>
~<h6>
, <section>
<div>
, <p>
, <blockquote>
<span>
至此,有两种 DOM tree 了——普通 tree 和 Shadow DOM tree,以后在使用 DOM API 时稍稍留意下
我们可以将 Shadow DOM 附加到自定义元素上(迄今为止 Shadow DOM 最有用的 application)。
最终的 DOM 树和渲染的 UI 如下:
在上面的例子中,我们使用 <style>
元素将样式应用在了 Shadow DOM 上。当然,我们也可以使用 <link>
元素来引用外部样式,代码如下:
最终的 DOM 树和渲染的 UI 如下:
说明:
<link>
元素不会阻止 shadow root 的绘制,所以在样式表加载完毕之后可能会出现视觉抖动
许多现代浏览器对 <style>
标签进行了优化以允许它们共享单个样式表,这样的话内部样式和外部样式在性能上应该是差不多的
shadow DOM 还可以附加到普通元素上
虽然很少见,但很直观,方便我们理解 shadow DOM(如下图)
当一个普通元素下同时存在普通 DOM 和 shadow DOM 时,会渲染 shadow DOM
样式关系
shadow DOM 会继承 shadow host 的样式(如下图)
外面想影响里面的样式,可以使用 CSS 变量、伪元素 ::part()
里面想影响外面的样式,只能影响到 shadow host,用伪类 :host
当内外设置了同一个属性,外面的文档样式优先,除非内部有 !important
在给 shadow root 添加子元素时,是用 innerHTML
还是 appendChild()
当和自定义元素搭配使用时,一般用后者,主要考虑到通常都会在 class 里操作 DOM,因此可顺手将其作为 class 的实例属性
事件传播
本节将介绍如何使用 <template>
和 <slot>
元素来创建灵活的模板,然后用它来填充 Web Component 中的 Shadow DOM。
<template>
HTML 的 <template>
元素是一种保存 HTML 的机制,页面在加载的时候它是不会被渲染的,但可以通过 JavaScript 在运行时访问到。虽然在加载页面的时候,解析器确实会处理 <template>
元素的内容,但这样做的目的只是为了确保内容是有效的。我们可以将 <template>
视为先暂存起来以便后续使用的内容片段(content fragment)。
<template>
对应的 DOM 接口是 HTMLTemplateElement
,它的 content 属性包含了模板所代表的 DOM 子树。
来看个单独使用 <template>
的例子,代码如下:
最终的 DOM 树和渲染的 UI 如下:
注意:HTMLTemplateElement
的 content 属性是一个只读的 DocumentFragment
,而 DocumentFragment
不是各种事件的有效目标,所以最好是克隆一份 content 的内容或是引用其内部的元素。直接使用 content 的值可能会导致不符合预期的行为,比如:
HTML 模板本身很有用,但和 Web Components 一起,会工作得更好。看个例子,如下:
最终的 DOM 树和渲染的 UI 如下:
由于我们是将模板的整个内容都附加到了自定义元素上,所以就可以在模板里写 <style>
样式了。
此时,最终的 DOM 树和渲染的 UI 会如下图所示:
注意,如果是将带 <style>
的模板附加到普通的 DOM 上,样式是不会生效的。
上面例子中定义的自定义元素 <my-p>
只能显示 "Hello world!" 文本。接下来,我们用 <slot>
对它进行下改造,用一种友好的声明方式让不同的元素实例可以显示不同的内容。
<slot>
HTML 的<slot>
元素是 Web Components 里的占位符(placeholder),它允许我们在模板中定义一个插槽(slot),然后再用任意的标记片段(markup fragment)来填充。插槽由它的 name 属性来标识的。
要想使用 <slot>
,可以像下面这样改造我们的代码。
首先,在 <template>
中定义一个 name 为 "content" 的 <slot>
。如下:
然后,在页面中使用自定义元素的时候,在它里面包含一个 slot 属性等于 "content"(即要填充的 <slot>
的 name 属性的值)的标签即可。标签的 HTML 结构可以是任意的。比如:
最终渲染的 UI 如下:
当在页面中使用自定义元素时,如果插槽的内容没有定义或是浏览器不支持插槽,那么自定义元素就会回退到只显示默认文本 "The default text."。此外,在模板中未命名的 <slot>
将填充自定义元素的所有没有 slot 属性的顶级子节点,包括文本节点。
这里用一个相对复杂的 <template>
和 <slot>
的例子做个小结。
注册自定义元素,代码如下:
HTML 模板,代码如下:
在页面中使用自定义元素,代码如下:
最终渲染的 UI 如下:
实现一个 Web Component 的基本方法,通常如下:
用 ES2015 中的 class
语法创建一个类,指定 Web Component 的功能
用 customElements.define()
注册一个新的自定义元素。此时,需要指定元素名称、实现其功能的类,有时也需要指定它所继承的父元素(自治的自定义元素 or 自定义的内置元素)
如果需要,用 Element.attachShadow()
给自定义元素附加 Shadow DOM。此时,可以使用常规的 DOM 方法给 Shadow DOM 添加子元素和事件监听器等
如果需要,用 <template>
和 <slot>
定义 HTML 模板。然后再用常规的 DOM 方法复制一份模板并将其附加到 Shadow DOM 上
最后一步,在页面的任意位置使用自定义元素,就像使用普通的 HTML 元素一样