浅谈 ShadowDOM

为什么会有 Shadow DOM

你在实际的开发中很可能遇到过这样的需求:实现一个可以拖拽的滑块,以实现范围选择、音量控制等需求。

除了直接用组件库,聪明的你肯定已经想到了多种解决办法。如在数据驱动框架 React/Vue/Angular 下,你可能会找到或编写对应的组件,通过相应数据状态的变更,完成相对复杂的交互;如在小快灵的项目下,用 jQuery 的 Widget 也是一个不错的选择;在或者,你可以点开你的 HTML + JavaScript + CSS 技能树,纯手工打造一个。这都是不难完成的任务。

当然,在完成之后,你可能会考虑对组件做一些提炼,下次再遇到同样的需求,你就可以气定神闲地“开箱即用”。

这里是 Clair 组件库对这个需求的封装。

我们不妨从这个层面再多想一步。其实由于 HTML 和 CSS 默认都是全局可见的,因此,尤其是纯手工打造的组件,其样式是很容易受到所在环境的干扰的;由于选择器在组件层没有统一的保护手段,也会造成撰写时候的规则可以被随意修改;事件的捕获和冒泡过程会和所在环境密切相关,也可能会引起事件管理的混乱。

根据一般意义上“封装”的概念,我们希望相对组件来讲,DOM 和 CSS 有一定的隐藏性;如非必要,外部的变化对于内部的有一定的隔离;同时,外界可以通过且仅可以通过一些可控的方法来影响内部,反之亦然。

针对这些问题,其实浏览器提供了一种名叫 Shadow DOM 的解决方案。这个方案目前与 Custom Elements、HTML Templates、CSS changes 和 JSON, CSS, HTML Modules 并列为 Web Components 标准

Shadow DOM 的概念

我们仍以上面的滑块作为例子。在最新的 Chrome 浏览器上,你可以输入如下代码来实现上面的功能:

<input type="range" disabled min="20" max="100" defaultValue="30" />

请打开 DevTools 中的“show user agent shadow DOM”:

在开发者工具中开启 “show user agent shadow DOM”

在 DevTools 的 Elements 标签中,我们可以看到这个“组件”的实现细节。

shadow dom 标签结构开发者工具展示

上面的 input range,可以看作是浏览器内置的一个组件。它是利用 Shadow DOM 来完成的一个组件。类似的,还有 Audio、Video 等组件。读者可以做类似的实验。

为了搞清 Shadow DOM 的机制,我们需要先厘清几个概念:

  1. Shadow DOM: 是一种依附于文档原有节点的子 DOM,具有封装性。
  2. Light DOM: 指原生的 DOM 节点,可以通过常规的 API 访问。Light DOM 和 Shadom DOM 常常一起出现。这也是很有意思的一个比喻。一明一暗,灯下有影子。
  3. Shadow Trees:Shadow DOM 的树形结构。一般地,在 Shadow Trees 的节点不能直接被外部 JavaScript 的 API 和选择器访问到,但是浏览器会对这些节点做渲染。
  4. Shadow Host:Shadow DOM 所依附的 DOM 节点。
  5. Shadow Root: Shadow Trees 的根节点。外部 JavaScript 如果希望对 Shadow Dom 进行访问,通常会借助 Shadow Root。
  6. Shadow Boundary:Shadow Tree 的边界,是 JavaScript 访问、CSS 选择器访问的分界点。
  7. content:指原本存在于 Light DOM 结构中,被 <content> 标签添加到影子 DOM 中的节点。自 Chrome 53 以后,content 标签被弃用,转而使用 templateslot 标签。
  8. distributed nodes:指原本位于 Light DOM,但被 content 或 template+slot 添加到 Shadow DOM 中的节点。
  9. template:一致标签。类似我们经常用的 <script type='tpl'>,它不会被解析为 dom 树的一部分,template 的内容可以被塞入到 Shadow DOM 中并且反复利用,在 template 中可以设置 style,但只对这个 template 中的元素有效。
  10. slot:与 template 合用的标签,用于在 template 中预留显示坑位。如:
<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 相关的概念,我们来了解一下相关的特性,以便更好地使用 Shadow DOM:

  1. DOM 的封装性:在不同的 Shadow Trees 中无法选择另外 Shadow Tree 中的元素,只有获取对应的 Shadow Tree 才能对其中的元素进行操作。

  2. 样式的封装性: 原则上,在 Shadow Boundary 外的样式,无法影响 Shadow DOM 的样式;而对于 Shadow Tree 内部的样式,可以由自身的 style 标签或样式指定;不同的 Shadow Tree 元素样式之间,也不会相互影响。 对于需要影响的、以 Shadow Boundary 分离的样式,需要由特殊的方案显示指定,如::host 选择器,:host-context() 选择器,::content() 选择器等等。

  3. JavaScript 事件捕获与冒泡: 传统的 JavaScript 事件捕获与冒泡,由于 Shadow Boundary 的存在,与一般的事件模型有一定的差异。

    在捕获阶段,当事件发生在 Shadow Boundary 以上,Shadow Boundary 上层可以捕获事件,而 Shadow Boundary 下层无法捕获事件。 在冒泡阶段,当事件发生在 Shadow Boundary 以下,Shadow Boundary 上层会以 Shadow Host 作为事件发生的源对象,而 Shadow Boundary 下层可以获取到源对象。

    事件 abort、 error、 select 、change 、load 、reset 、resize 、scroll 、selectstart 不会进行重定向而是直接被干掉。

    读者可以从这个例子里感受一下。

  4. 多个 Shadow Tree 同时共用一个 Shadow Host,只会展示最后一个 Shadow Tree。

如何使用 Shadow DOM

了解了上述基础知识之后,我们可以试着利用 Shadow DOM 做些事情了。

  1. 创建 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.shadowRootnull。外部不可能再拿到 sr 的实例。此时外部很难操作到 sr 下的 Shadow DOM,仅可以依靠 Shadow 内部的元素来进行操作。

  1. 在 Shadow DOM 内部来操作 Shadow Host 的样式

: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>
  1. 跨越 Shadow Boundary 的样式 ::part()

对于 ::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 在最新版本已经无效。

  1. 定义一个组件
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 方法,将组件引入过来。之后,就可以在代码中使用了。

组件生命周期大致经过以下几个阶段:

  1. constructor 会在元素创建后而尚未被附加到文档上之前被调用。我们用 constructor 来设置初始状态、事件监听以及 shadow DOM。
  2. connectCallback 会在元素被添加到 DOM 中后被调用。此时非常适合执行初始化代码,比如获取数据或是设置默认属性。
  3. disconnectedCallback() 会在元素从 DOM 中被移除后调用。可以利用 disconnectedCallback 来移除事件监听器或取消定时循环事件。
  4. attributeChangedCallback 会在元素的受监控的属性变动时被调用。

兼容性

目前 Shadow dom 有两个主流的标准,V0 和 V1,V0 已经被废弃,当前的版本为 V1。以下是当前(2019 年 10 月)的主流浏览器支持情况:

Shadow dom V1 浏览器支持情况

小结

本文介绍了 Shadow DOM 的标准内容。这里或多或少的涉及到了 WebComponents 标准的其他内容,我们会在后面的文章,详细介绍其他相关标准的内容。在翻阅 Shadow DOM 历史资料的过程中,发现很多标准中定义的方法发生了变化甚至废弃,建议大家以官方最新的标准为准。

参考资料

  1. https://meowni.ca/posts/part-theme-explainer/
  2. https://www.html5rocks.com/zh/tutorials/webcomponents/shadowdom-201/
  3. https://drafts.csswg.org/css-shadow-parts/
  4. https://www.cnblogs.com/yangguoe/p/8486046.html
  5. https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements