本系列其他译文请看[JS工作机制 - 小白1991的专栏 - 掘金 (juejin.cn)] (juejin.cn/column/6988…
本章阅读指数:3
本地讲影子DOM,影子DOM可能是未来的一种组件化方向,但是目前没有大规模应用。视情况阅读。
影子DOM即Shadow DOM
概述
网络组件是一种不同的技术,可以让你创建可复用的自定义元素。这些元素是被封装的,跟其他的代码没有关系,可以在网络应用中使用它们。 我们有4个网络组件的标准:
-
Shadow DOM
-
HTML Templates
-
Custom elements
-
HTML Imports 这一章我们讨论Shadow DOM,也即影子DOM。
影子DOM是构建基于组件的应用的工具。它给这些问题提供了一个解决方案: -
隔离 DOM: 组件的DOM是独立的(比如document.querySelector()就不会返回组件影子DOM里的节点)。这样就可以使用最简单的CSS选择器,而不用担心命名冲突。
-
局部CSS:影子DOM内部的CSS,作用范围是在DOM内部。局部的样式不会泄漏,也不会被外部影响。
-
组合: 为你的组件设计一个声明式的,基于标签的API。
影子DOM
如果还不熟悉影子DOM的概念和API,可以看看这里developer.mozilla.org/en-US/docs/….
和普通DOM相比,影子DOM有两点不同:
- 个其他页面建立和使用关联的形式
- 跟其他页面关联的变现形式
一般情况下,你可以创建一个DOM节点,然后添加到其他元素上的子节点上。至于影子DOM,则创建一个局部的DOM树,添加到元素的子节点,这个树是和元素的实际子节点分离的。这个局部的子树,叫做shadow tree。宿主元素则叫shadow host。包括 <style>
在内的所有在 shadow 树下创建的任何标签都只作用于宿主元素内部。此即 shadow DOM 如何实现 CSS 局部样式化的原理。
创建影子树
shadow root 是附加到宿主元素的文档片段。宿主元素得到它的影子DOM时,就添加了一个shadow root。调用element.attachShadow(),可以为元素创建一个影子DOM。
var header = document.createElement('header');
var shadowRoot = header.attachShadow({mode: 'open'});
var paragraphElement = document.createElement('p');
paragraphElement.innerText = 'Shadow DOM';
shadowRoot.appendChild(paragraphElement);
这个 声明定义了一些不能挂载shadow tree的元素。
影子DOM的组合
组合是影子DOM最重要的特性之一。
写HTML时,需要组合来构建网络应用。构建应用,需要合并和嵌套不同的构建块(元素)比如<div>
, <header>
, <form>
等等。
元素组合定义了诸如为何 <select>
,<form>
,<video>
及其它元素是可扩展的且接受特定的 HTML 元素作为子元素以便用来对这些元素进行特殊处理。
比如,<select>
元素知道如何把 <option>
元素渲染成为带有预定义选项的下拉框组件。
Shadow DOM 引入如下功能,可以用来组合元素。
Light DOM
这是组件书写的用户标记。这个DOM在组件影子DOM之外。它是元素的真实子节点。 假如你创建了一个自定义组件,它继承了原生的HTML button,并在内部添加了图像和文本。
<extended-button>
<!-- the image and span are extended-button's light DOM -->
<img src="boot.png" slot="image">
<span>Launch</span>
</extended-button>
“extended-button”中的HTML就是Light DOM,这是被用户添加的。 "extended-button"组件就是影子DOM。影子DOM定义了内部结构,局部CSS,封装可很多实现细节。
扁平DOM树
浏览器分发 light DOM 的结果即,由用户在 Shadow DOM 内部创建的 HTML 内容,这些 HTML 内容构成了自定义组件的结构,渲染出最后的产品界面。扁平树即开发者在开发者工具中看到的内容和页面的渲染结果。
<extended-button>
#shadow-root
<style>…</style>
<slot name="image">
<img src="boot.png" slot="image">
</slot>
<span id="container">
<slot>
<span>Launch</span>
</slot>
</span>
</extended-button>
Templates
如果你需要在页面中重复使用相同的标记结构,那么最好使用一些template。以前就能这么做,但是现在使用<template>
更加的方便。<template>
不会被渲染,但是可以在JS中引用。
比如:
<template id="my-paragraph">
<p> Paragraph content. </p>
</template>
这段代码不会被渲染。你需要在JS中引用,然后把它添加一个dom上
var template = document.getElementById('my-paragraph');
var templateContent = template.content;
document.body.appendChild(templateContent);
也有一些其他的手段实现类似的效果,但是相对来讲,<template>
的支持性更好。
看看它的支持情况:
Templates 可以自己工作,也可以在定制元素时使用效果更佳。
这一章我们讨论浏览器提供的customElement 接口允许开发者自定义标签内容的渲染。
现在定义一个内容是影子DOM是template的组件,<my-paragraph>
customElements.define('my-paragraph',
class extends HTMLElement {
constructor() {
super();
let template = document.getElementById('my-paragraph');
let templateContent = template.content;
const shadowRoot = this.attachShadow({mode: 'open'}).appendChild(templateContent.cloneNode(true));
}
});
我们使用了Node.cloneNode()方法来创建一个template的副本,然后添加到shadow root上。
在template中,我们可以使用<style>
来包含一些样式,这些样式会被组件封装起来。当然,在将其附加到一个标准DOM之前,它是不生效的。
<template id="my-paragraph">
<style>
p {
color: white;
background-color: #666;
padding: 5px;
}
</style>
<p>Paragraph content. </p>
</template>
这样,我们就可以在页面中这么使用它了:
Slots
template 有一些弊端,最大的问题是它的内容是静态的,我们无法渲染变量/数据,无法像标准的HTML模板那样使用。 可以解决这个问题。 可以将理解为占位符,可以让你放置你自己的HTML元素。这样,你可以创建通用性的HTML模板,并且添加来进行定制。 看看使用slot是怎么工作的:
<template id="my-paragraph">
<p>
<slot name="my-text">Default text</slot>
</p>
</template>
如果在标记中引用该元素的时候没有定义slot内容,或者浏览器不支持插槽,则 <my-paragraph>
只会包含默认的 "Default text" 内容。
为了定义slot内容,我们需要在中包含HTML结构。我们使用一个slot属性,它的值就是你想插入的slot的名字。
看下代码:
<my-paragraph>
<span slot="my-text">Let's have some different text!</span>
</my-paragraph>
可以插入slot的元素叫Slotable 已经插入进去的元素,叫做slotted
上面的例子中<span>
就是slotted元素。它具有一个slot属性,值为'my-text',和模板中插槽定义的 name 属性值相等。
浏览器渲染之后,我们能看到这样的扁平树结构:
<my-paragraph>
#shadow-root
<p>
<slot name="my-text">
<span slot="my-text">Let's have some different text!</span>
</slot>
</p>
</my-paragraph>
注意#shadow-root只是表示,有一个影子DOM。
样式
使用影子DOM的组件,可以被主页面定义样式,也可以自己定义,还可以提供一些钩子让用户重写。
组件定义样式
影子DOM的重要特性之一是局部CSS
- 外部页面的CSS选择器不会影响到你的组件内部
- 组件内部定义的样式,也不会影响页面的其他部分。他们只作用于宿主元素的范围。 在影子DOM中使用CSS选择器,只会影响组件内部。这样,你就可以使用一些重名的id/类名,不用担心在页面其他部分产生命名冲突。要知道,简单的CSS选择器会有更好的性能。 看看下面的#shadow-root如何定义样式:
#shadow-root
<style>
#container {
background: white;
}
#container-items {
display: inline-flex;
}
</style>
<div id="container"></div>
<div id="container-items"></div>
例子中的样式,都只局限于#shadow-root内部。你还可以使用标签加载样式脚本,同样只是局部生效。
:host 伪类
:host允许你选择并且样式化寄宿了shadow tree的元素。
<style>
:host {
display: block; /* by default, custom elements are display: inline */
}
</style>
使用:host需要注意一个事情,父页面的样式规则具有更高的权重,:host定义的规则权重要低一些。这样,就允许用户从外部来重写顶层样式。同样,:host只会在shadow root的上下文中生效。
:host()功能模式允许你关联匹配的宿主元素。这样你可以封装用户的交互和状态的行为,并且基于宿主节点样式化内部节点。
<style>
:host {
opacity: 0.4;
}
:host(:hover) {
opacity: 1;
}
:host([disabled]) { /* 宿主元素拥有 disabled 属性的样式. */
background: grey;
pointer-events: none;
opacity: 0.4;
}
:host(.pink) > #tabs {
color: pink; /* 当宿主元素含有 pink 类时的选项卡样式. */
}
</style>
使用:host-context()伪类的主题和元素
:host-context()伪类找出宿主元素或者宿主元素任意的祖先元素匹配 <selector>
。
常见的使用场景是定制主题。很多人会给<html>
或者 <body>
上应用样式,来实现定制主题。
<body class="lightheme">
<custom-container>
…
</custom-container>
</body>
当是.lightheme子节点的时候,:host-context(.lightheme)将会样式化它。
:host-context(.lightheme) {
color: black;
background: white;
}
从外部样式化组件的宿主元素
开发者可以从外部通过把标签名作为选择器来样式化组件宿主元素,如下:
custom-container {
color: red;
}
相比影子DOM内部的样式,外部样式的邮件及更高。 例如,如果用户这么写选择器:
custom-container {
width: 500px;
}
就会覆盖掉组件的样式规则:
:host {
width: 300px;
}
样式化组件只能做到这一步了。但是如果我们想样式化组件内部怎么办?我们需要CSS自定义属性
使用CSS自定义属性创建样式钩子
如果组件开发者使用CSS自定义属性提供了样式钩子,我们就可以改变它的内部样式。这概念有点像。 看一个例子:
<!-- main page -->
<style>
custom-container {
margin-bottom: 60px;
- custom-container-bg: black;
}
</style>
<custom-container background>…</custom-container>
影子DOM内部:
:host([background]) {
background: var( - custom-container-bg, #CECECE);
border-radius: 10px;
padding: 10px;
}
由于用户提供了背景颜色,所有组件会使用相同的颜色,也就是#CECECE。 作为组件的作者,你有责任让后面开发之道哪些CSS自定义属性可以使用。将它们作为组件公共接口的一部分。
插槽JS API
影子 DOM API 可能用来操作插槽
slotchange事件
当slot 分发的节点改变了,就会触发这个事件。比如,我们添加/删除了light dom的子节点
var slot = this.shadowRoot.querySelector('#some_slot');
slot.addEventListener('slotchange', function(e) {
console.log('Light DOM change');
});
为了维护light DOM其他类型的变化,你可以使用MutationObserver。我们之前章节中已经讨论过。
assignedNodes()方法
知道slot关联了什么元素有时候很有用。slot.assignedNodes()方法可以告诉你slot渲染了什么元素。
flatten: true}
选项会返回插槽的默认内容(若没有分发任何节点)。
看一个例子:
Default content
我们假设它在组件之中。 看一下使用这个组件有什么不同,以及调用assignedNodes()返回什么: 首先,我们添加自己的内容到slot<my-container>
<span slot="slot1"> container text </span>
</my-container>
调用assignedNodes()将会返回[ container text ],注意这个结果是一个节点数组。
在第二个案例中,我们放一个空内容
assignedNodes()
将会返回一个空数组[]
但是此时如果你传入了{flatten: true}参数,你将会得到一个默认结果:[<p>Default content</p>]
为了方位slot内部的元素,你可以调用assignedNodes()去看看你的元素被分配给了哪个组件slot。
事件模型
当影子DOM事件冒泡时发生了什么? 目标事件被调整为维护影子DOM的封闭性。当一个事件被重定位,就好像来自于组件本身,而不是组件中的影子DOM中的元素。 这里有传播出 Shadow DOM 的事件列表(还有一些只能在 Shadow DOM 内传播):
-
Focus Events: blur, focus, focusin, focusout -
Mouse Events: click, dblclick, mousedown, mouseenter, mousemove, etc.
-
Wheel Events: wheel
-
Input Events: beforeinput, input
-
Keyboard Events: keydown, keyup
-
Composition Events: compositionstart, compositionupdate, compositionend
-
DragEvent: dragstart, drag, dragend, drop, etc.
自定义事件
自定义事件默认不会被影子DOM传递出去。如果你想分发一个自定义事件,并且想传播它,你需要增加bubbles: true和composed: true作为选项。 看看如何分发这样一个事件:
var container = this.shadowRoot.querySelector('#container');
container.dispatchEvent(new Event('containerchanged', {bubbles: true, composed: true}));
浏览器支持
通过检查attachShadow属性是否存在,可以判断当前浏览器是否支持影子DOM:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;