【Web技术】400- 浅谈Shadow DOM

  • 2019 年 11 月 5 日
  • 筆記

为什么会有Shadow DOM

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

除了直接用组件库,聪明的你肯定已经想到了多种解决办法。如在数据驱动框架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。

Shadow DOM的概念

我们仍以上面的滑块作为例子。在最新的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的机制,我们需要先理清几个概念:

  1. Shadow DOM: 是一种依附于文档原有节点的子 DOM,具有封装性。
  2. Light DOM: 指原生的DOM节点,可以通过常规的API访问。Light DOM和Shadow 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 结构中,被标签添加到影子 DOM 中的节点。自Chrome 53以后,content标签被弃用,转而使用template和slot标签。
  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:

  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不会进行重定向而是直接被干掉。 读者可以从这个例子3里感受一下。
  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.shadowRoot为null。外部不可能再拿到sr的实例。此时外部很难操作到sr下的Shadow DOM,仅可以依靠Shadow内部的元素来进行操作。

2. 在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>

3. 跨越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在最新版本已经无效。

4. 定义一个组件

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

参考资料

  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

文内链接

  1. https://clair-design.github.io/component/slider
  2. https://github.com/w3c/webcomponents
  3. https://jsbin.com/kiqatolede/1/edit?html,console,output
  4. https://dom.spec.whatwg.org/#shadow-trees