你在实际的开发中很可能遇到过这样的需求:实现一个可以拖拽的滑块,以实现范围选择、音量控制等需求。
除了直接用组件库,聪明的你肯定已经想到了多种解决办法。如在数据驱动框架React/Vue/Angular下,你可能会找到或编写对应的组件,通过相应数据状态的变更,完成相对复杂的交互;如在小快灵的项目下,用jQuery的Widget也是一个不错的选择;再或者,你可以点开你的HTML+JavaScript+CSS技能树,纯手工打造一个。这都是不难完成的任务。
当然,在完成之后,你可能会考虑对组件做一些提炼,下次再遇到同样的需求,你就可以气定神闲地“开箱即用”。
这里1是Clair组件库对这个需求的封装。
我们不妨从这个层面再多想一步。其实由于HTML和CSS默认都是全局可见的,因此,尤其是纯手工打造的组件,其样式是很容易受到所在环境的干扰的;由于选择器在组件层没有统一的保护手段,也会造成撰写时候的规则可以被随意修改;事件的捕获和冒泡过程会和所在环境密切相关,也可能会引起事件管理的混乱。
根据一般意义上“封装”的概念,我们希望相对组件来讲,DOM和CSS有一定的隐藏性;如非必要,外部的变化对于内部的有一定的隔离;同时,外界可以通过且仅可以通过一些可控的方法来影响内部,反之亦然。
针对这些问题,其实浏览器提供了一种名叫Shadow DOM的解决方案。这个方案目前与 Custom Elements、HTML Templates、CSS changes和JSON, CSS, HTML Modules并列为Web Components标准2。
我们仍以上面的滑块作为例子。在最新的Chrome浏览器上,你可以输入如下代码来实现上面的功能:
<input type="range" disabled min="20" max="100" defaultValue="30"/>
请打开DevTools中的“show user agent shadow DOM”:
在DevTools的Elements标签中,我们可以看到这个“组件”的实现细节。
上面的input range,可以看作是浏览器内置的一个组件。它是利用Shadow DOM来完成的一个组件。类似的,还有Audio、Video等组件。读者可以做类似的实验。
为了搞清Shadow DOM的机制,我们需要先理清几个概念:
<div id="con">
我是基础文字
<span slot="main1">
占位1
</span>
<span slot="main2">
占位2
</span>
我还是基础文字
</div>
<template id="tpl">
我是模版
<slot name="main1">
</slot>
<slot name="main2">
</slot>
我还是模版
</template>
<script>
let host = document.querySelector('#con');
let root = host.attachShadow({mode:'open'});
let con = document.getElementById("tpl").content.cloneNode(true);
root.appendChild(con);
</script>
下面这幅图,展示了上述概念的相互关系:
了解了Shadow DOM相关的概念,我们来了解一下相关的特性,以便更好地使用Shadow DOM:
了解了上述基础知识之后,我们可以试着利用Shadow DOM做些事情了。
const div = document.createElement('div');
const sr = div.attachShadow({mode: 'open'});
sr.innerHTML = '<h1>Hello Shadow DOM</h1>';
这里注意下{mode: 'open'},此后通过div.shadowRoot即可拿到sr的实例。sr可以使用一般的JavaScript API来做相关的操作。
如果这里采用{mode: 'closed'},则此时div.shadowRoot为null。外部不可能再拿到sr的实例。此时外部很难操作到sr下的Shadow DOM,仅可以依靠Shadow内部的元素来进行操作。
:host 允许你选择并样式化 Shadow Tree所寄宿的元素
<button class="red">My Button</button>
<script>
var button = document.querySelector('button');
var root = button.createShadowRoot();
root.innerHTML = '<style>' +
':host { text-transform: uppercase;font-size:30px; }' +
'</style>' +
'<content></content>';
</script>
对于::part,在允许样式的Shadow DOM,给属性part赋值,样式选择器可以使用::part(属性值)即可实现指定样式。需要注意的是,在::part()选择器后,子代选择器无效。如你不能使用::part(foo) span。
<style>
c-e::part(innerspan) { color: red; }
</style>
<template id="c-e-outer-template">
<c-e-inner exportparts="innerspan: textspan"></c-e-inner>
</template>
<template id="c-e-inner-template">
<span part="innerspan">
This text will be red because the containing shadow
host forwards innerspan to the document as "textspan"
and the document style matches it.
</span>
<span part="textspan">
This text will not be red because textspan in the document style
cannot match against the part inside the inner custom element
if it is not forwarded.
</span>
</template>
<c-e></c-e>
<script>
// Add template as custom elements c-e-inner, c-e-outer
let host = document.querySelector('c-e');
let root = host.attachShadow({mode:'open'});
let con = document.getElementById("c-e-inner-template").content.cloneNode(true);
root.appendChild(con);
</script>
::part()选择器自Chrome73开始支持。之前的版本,可以考虑^和^^选择器,^和^^选择Shadow DOM在最新版本已经无效。
class FlagIcon extends HTMLElement {
constructor() {
super();
this._countryCode = null;
}
static get observedAttributes() { return ["country"]; }
attributeChangedCallback(name, oldValue, newValue) {
// name will always be "country" due to observedAttributes
this._countryCode = newValue;
this._updateRendering();
}
connectedCallback() {
this._updateRendering();
}
get country() {
return this._countryCode;
}
set country(v) {
this.setAttribute("country", v);
}
disconnectedCallback() {
console.log('disconnected!');
}
_updateRendering() {
// Left as an exercise for the reader. But, you'll probably want to
// check this.ownerDocument.defaultView to see if we've been
// inserted into a document with a browsing context, and avoid
// doing any work if not.
}
}
customElements.define("flag-icon", FlagIcon);
const flagIcon = new FlagIcon()
flagIcon.country = "zh"
document.body.appendChild(flagIcon)自定义的组件,都需继承自HTMLElement。然后调用customElements.define方法,将组件引入过来。之后,就可以在代码中使用了。
组件生命周期大致经过以下几个阶段:
目前Shadow dom有两个主流的标准,V0和V1,V0已经被废弃,当前的版本为V1。以下是当前(2019年10月)的主流浏览器支持情况:
本文介绍了Shadow DOM的标准内容。这里或多或少的涉及到了WebComponents标准的其他内容,我们会在后面的文章,详细介绍其他相关标准的内容。在翻阅Shadow DOM历史资料的过程中,发现很多标准中定义的方法发生了变化甚至废弃,建议大家以官方最新的标准4为准。