Shadow DOM 301

Advanced Concepts & DOM APIs

HTML5 Rocks

This article discusses APIs that are not yet fully standardized and still in flux. Be cautious when using experimental APIs in your own projects.

本文将讨论更多有关 Shadow DOM 应用的精彩内容!文中内容基于在 Shadow DOM 101Shadow DOM 201 中讨论的概念。

在 Chrome 中,开启 about:flags 页面中的"Enable experimental Web Platform features"就可以体验本文介绍的所有内容。

使用多个 shadow root

假如你举办了一场聚会,要是把所有人都聚集在同一间屋子里会显得拥挤不堪。你希望能将人们按组分散到不同的房间内。托管 Shadow DOM 的元素也具有这样的能力,也就是说,宿主元素能够在同一时间内托管多个 shadow root。

让我们来试着将多个 shadow root 托管到同一个宿主元素里会发生什么:

<div id="example1">Light DOM</div>
<script>
var container = document.querySelector('#example1');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();
root1.innerHTML = '<div>Root 1 FTW</div>';
root2.innerHTML = '<div>Root 2 FTW</div>';
</script>
Attaching multiple shadow trees
Light DOM

在开发者工具中,开启 "Show Shadow DOM" 以便观察 ShadowRoots。

尽管我们已经为宿主元素附加上了一个 shadow 树,但最终显示的内容却是 "Root 2 FTW"。这是因为最后被加入到宿主元素中的 shadow 树获胜。就渲染而言,它就像后进先出(LIFO)的栈一样。可以检查开发者工具来验证这一行为。

添加进宿主元素中的 shadow 树按照它们的添加顺序而堆叠起来,从最先加入的 shadow 树开始。最终渲染的是最后加入的 shadow 树。

最近添加的树称为 younger tree。之前添加的树称为 older tree。在本例中,root2 是 younger tree,root1 是 older tree。

如果只有最后加入的 shadow 树才能被渲染,那么使用多个 shadow 的意义何在?别着急,让我们来认识下 shadow 插入点(insertion points)。

Shadow 插入点

"Shadow 插入点" (<shadow>) 与普通插入点 (<content>)均为占位符。不过,相比作为宿主内容的占位符,它们算得上是其他 shadow 树的宿主。它是 Shadow DOM 的基石!

正如你想象得到的,在兔子洞( rabbit hole)中陷的越深,事情变得越复杂。有鉴于此,规范对于同时存在多个 <shadow> 的行为作了明确的解释:

如果一个 shadow 树中存在多个 <shadow> 插入点,那么仅第一个被确认,其余的被忽略。

回过头来看看之前的例子,第一个 shadow root1 不在邀请名单中。增加一个 <shadow> 插入点来把它召回:

<div id="example2">Light DOM</div>
<script>
var container = document.querySelector('#example2');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();
root1.innerHTML = '<div>Root 1 FTW</div><content></content>';
root2.innerHTML = '<div>Root 2 FTW</div><shadow></shadow>';
</script>
Shadow insertion points
Light DOM

这个例子中有些有趣的地方:

  1. "Root 2 FTW" 依然渲染在 "Root 1 FTW" 上面。原因和我们放置 <shadow> 插入点的位置有关。如果你想颠倒顺序,那就移动插入点:root2.innerHTML = '<shadow></shadow><div>Root 2 FTW</div>';
  2. 注意此时在 root1 中存在一个 <content> 插入点。正因为如此,文本节点 "Light DOM" 也一并显示出来。

<shadow> 里究竟渲染了什么?

有些时候,了解一个 <shadow> 中渲染的旧的 shadow 树很有用。你可以通过 .olderShadowRoot 获取到那棵树的引用:

root2.olderShadowRoot === root1 //true

获取宿主元素的 shadow root

如果一个元素托管着 Shadow DOM,你可以使用 .shadowRoot 来访问它的 youngest shadow root

var root = host.createShadowRoot();
console.log(host.shadowRoot === root); // true
console.log(document.body.shadowRoot); // null

如果不想别人乱动你的 shadow,那就将 .shadowRoot 重定义为 null:

Object.defineProperty(host, 'shadowRoot', {
  get: function() { return null; },
  set: function(value) { }
});

有点取巧,但是很有效。最后要牢记的是,虽然 Shadow DOM 很棒,但它并没有被设计成一个安全特性。不要想着依赖它来实现完整的内容隔离。

在 JS 中构建 Shadow DOM

如果你偏好在 JS 中构建 DOM,尽可以使用 HTMLContentElementHTMLShadowElement 接口。

<div id="example3">
  <span>Light DOM</span>
</div>
<script>
var container = document.querySelector('#example3');
var root1 = container.createShadowRoot();
var root2 = container.createShadowRoot();

var div = document.createElement('div');
div.textContent = 'Root 1 FTW';
root1.appendChild(div);

 // HTMLContentElement
var content = document.createElement('content');
content.select = 'span'; // selects any spans the host node contains
root1.appendChild(content);

var div = document.createElement('div');
div.textContent = 'Root 2 FTW';
root2.appendChild(div);

// HTMLShadowElement
var shadow = document.createElement('shadow');
root2.appendChild(shadow);
</script>

这个例子与前面部分的版本基本一样。唯一的区别是在这里我使用了 select 将新增加的 <span> 提取出来。

使用插入点

从宿主元素中选择并"分发"到 shadow 树中的节点称为……鼓声响起……分布式节点!当插入点邀请它们时便可以越过 shadow 边界。

从概念上讲很奇怪的是,插入点并不会真正的移动 DOM。宿主元素的节点保持不动。插入点仅仅是将节点从宿主元素重新投射(re-project)到 shadow 树中。这是展现/渲染层面的事情:"把节点移动到这" "把节点渲染在这个位置。"

你无法遍历 <content> 中的 DOM。

例如:

<div><h2>Light DOM</h2></div>
<script>
var root = document.querySelector('div').createShadowRoot();
root.innerHTML = '<content select="h2"></content>';

var h2 = document.querySelector('h2');
console.log(root.querySelector('content[select="h2"] h2')); // null;
console.log(root.querySelector('content').contains(h2)); // false
</script>

瞧!h2 并不是 Shadow DOM 的子节点。这便引出了另一个结论:

插入点的功能极其强大。把它想象成一个为你的 Shadow DOM 创建"声明式 API" 的方法。宿主元素可以包含任意标记,但除非我使用插入点将它们引入到 Shadow DOM 中,否则它们毫无意义。

Element.getDistributedNodes()

我们虽然无法遍历 <content>,但 .getDistributedNodes() API 却允许我们查询一个插入点的分布式节点:

<div id="example4">
  <h2>Eric</h2>
  <h2>Bidelman</h2>
  <div>Digital Jedi</div>
  <h4>footer text</h4>
</div>

<template id="sdom">
  <header>
    <content select="h2"></content>
  </header>
  <section>
    <content select="div"></content>
  </section>
  <footer>
    <content select="h4:first-of-type"></content>
  </footer>
</template>

<script>
var container = document.querySelector('#example4');

var root = container.createShadowRoot();

var t = document.querySelector('#sdom');
var clone = document.importNode(t.content, true);
root.appendChild(clone);

var html = [];
[].forEach.call(root.querySelectorAll('content'), function(el) {
  html.push(el.outerHTML + ': ');
  var nodes = el.getDistributedNodes();
  [].forEach.call(nodes, function(node) {
    html.push(node.outerHTML);
  });
  html.push('\n');
});
</script>

Element.getDestinationInsertionPoints()

.getDistributedNodes() 类似,你可以在分布式节点上调用它的 .getDestinationInsertionPoints() 来查看它被分发进了哪个插入点中:

<div id="host">
  <h2>Light DOM</h2>
</div>

<script>
  var container = document.querySelector('div');

  var root1 = container.createShadowRoot();
  var root2 = container.createShadowRoot();
  root1.innerHTML = '<content select="h2"></content>';
  root2.innerHTML = '<shadow></shadow>';

  var h2 = document.querySelector('#host h2');
  var insertionPoints = h2.getDestinationInsertionPoints();
  [].forEach.call(insertionPoints, function(contentEl) {
    console.log(contentEl);
  });
</script>

工具:Shadow DOM Visualizer

要了解 Shadow DOM 背后的黑魔法很困难。我还记得第一次尝试理解它的情形。

为了使 Shadow DOM 的渲染过程更加形象化,我用 d3.js 写了一个工具。左边框中的标记都是可编辑的。你可以把自己的代码粘贴进去,然后观察它们是如何工作的,插入点是如何将宿主的子节点混入 shadow 树中。

Shadow DOM Visualizer
启动 Shadow DOM Visualizer

尝试一下,再告诉我你的想法!

事件模型

有些事件会越过 shadow 边界,有些不会。在越过 shadow 边界的情况中,事件目标会因为维护由 shadow root 上边界提供的封装而进行调整。也就是说,事件会被重定向,使它看起来是从宿主元素上发出,而并非是 Shadow DOM 的内部元素

访问 event.path 来查看调整后的事件路径。

如果你的浏览器支持 Shadow DOM (它支持),你应该能在下方看到一个用于可视化事件的测试区。黄色的元素属于 Shadow DOM 中的标记。蓝色的元素属于宿主元素。环绕在 "I'm a node in the host" 的黄色边框表明了它是一个分布式节点,通过 shadow 的 <content> 插入点混入在 Shadow DOM 中。

"Play Action" 按钮表示可以进行多种尝试。你可以点击它们来观察 mouseoutfocusin 事件是如何冒泡到主页面的。

I'm a node in the host



Play Action 1

  • 这个很有意思。你会看到一个 mouseout 事件从宿主元素 (<div data-host>) 传递到蓝色的节点。即便它是个分布式节点,但它始终处于宿主中,而不是在 Shadow DOM 里。随后继续移动鼠标至黄色区域内,再次导致蓝色的节点触发 mouseout 事件。

Play Action 2

  • 这是发生在宿主元素(发生的非常晚)上的一次 mouseout 事件。通常你会看到 mouseout 事件会在所有的黄色块上触发。但是这一次不同,这些元素都在 Shadow DOM 的内部,事件的冒泡不会超出它的上边界。

Play Action 3

  • 注意当你点击输入框时,focusin 并没有发生在输入框上,而是在
  • 点自身上。事件被重定向了!

始终停止的事件

以下事件永远无法越过 shadow 边界:

  • abort
  • error
  • select
  • change
  • load
  • reset
  • resize
  • scroll
  • selectstart

总结

我希望你能认同 Shadow DOM 的功能令人难以置信的强大。这是有史以来第一次,我们有了合适的封装,不必再使用问题重重的 <iframe> 或其他古老的技巧。

Shadow DOM 是个难以驯服的猛兽,但是它却值得被加入到 web 平台中。花点时间去了解它,学习它,提出问题。

如果你想学习更多内容,看看 Dominic 的入门文章 Shadow DOM 101 和我的 Shadow DOM 201: CSS & Styling

感谢 Dominic CooneyDimitri Glazkov 对本教程的审阅。

Comments

0