Shadow DOM 301

高级概念和 DOM API

本文将详细介绍您可以使用 Shadow DOM 实现的更多精彩功能!它以 Shadow DOM 101 和 Shadow DOM 201 中讨论的概念为基础。

使用多个 shadow root

如果你在举办派对,所有人都挤在同一个房间里会很闷。 您希望能够将多组人员分配到多个会议室。托管 Shadow DOM 的元素也可以这样做,也就是说,它们可以一次托管多个影子根。

让我们看看如果尝试将多个影子根附加到宿主,会发生什么情况:

<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>

尽管我们已经附加了影子树,但渲染的内容仍是“Root 2 FTW”。 这是因为最后添加到宿主的影子树会胜出。就渲染而言,它是一个 LIFO 堆栈。检查开发者工具可验证此行为。

那么,如果只有最后一个阴影会参与渲染,使用多个阴影有什么意义呢?输入影子插入点。

影子插入点

“影子插入点”(<shadow>) 与普通插入点 (<content>) 类似,都是占位符。不过,它们不是宿主内容的占位符,而是其他影子树的宿主。 这是 Shadow DOM 盗梦空间!

您可能已经想到,越深入探究,情况就越复杂。因此,规范非常明确地说明了当多个 <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>

此示例有几个有趣之处:

  1. “Root 2 FTW”仍会在“Root 1 FTW”上方呈现。这是因为我们放置 <shadow> 插入点的位置。如果您想反向操作,请移动插入点:root2.innerHTML = '<shadow></shadow><div>Root 2 FTW</div>';。
  2. 请注意,现在 root1 中有一个 <content> 插入点。这会使文本节点“Light DOM”也参与渲染。

<shadow> 处呈现的内容是什么?

有时,了解在 <shadow> 处呈现的旧影子树会很有用。您可以通过 .olderShadowRoot 获取对该树的引用:

**root2.olderShadowRoot** === root1 //true

获取主机的 shadow root

如果某个元素托管了 Shadow DOM,您可以使用 .shadowRoot 访问其最年轻的影子根:

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

如果您担心有人会越过您的阴影,请将 .shadowRoot 重新定义为 null:

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

虽然有点取巧,但确实有效。最后,请务必记住,虽然 Shadow DOM 非常棒,但它并非设计为一项安全功能。请勿依赖它来实现完整的内容隔离。

在 JS 中构建 Shadow DOM

如果您更喜欢在 JS 中构建 DOM,HTMLContentElement 和 HTMLShadowElement 提供了相应的接口。

<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 tree 中的节点称为…(请击鼓)…分发节点!当插入点邀请它们进入时,它们可以跨越影子边界。

插入点在概念上很奇怪,因为它们不会实际移动 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 的子级。这就引出了另一个小知识点:

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
</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 tree。

Shadow DOM 可视化工具
启动 Shadow DOM Visualizer

欢迎试用,并与我分享你的感受和想法!

事件模型

有些事件会跨越 shadow 边界,有些则不会。如果事件跨越边界,系统会调整事件目标,以维持影子根的上边界提供的封装。也就是说,事件会被重新定位,看起来像是来自宿主元素,而不是 Shadow DOM 的内部元素。

播放操作 1

  • 这个很有意思。您应该会看到从宿主元素 (<div data-host>) 到蓝色节点的 mouseout。虽然它是分布式节点,但它仍在宿主中,而不是在 ShadowDOM 中。将鼠标进一步向下移动到黄色区域,会再次导致蓝色节点上出现 mouseout。

Play Action 2

  • 主机上会显示一个 mouseout(在最末尾)。通常,您会看到所有黄色块都会触发 mouseout 事件。不过,在这种情况下,这些元素位于 Shadow DOM 内部,并且事件不会通过其上边界冒泡。

Play Action 3

  • 请注意,当您点击输入时,focusin 不会显示在输入上,而是显示在宿主节点本身上。它已重新定位!

始终停止的事件

以下事件绝不会跨越影子边界:

  • abort
  • 错误
  • 选择
  • 更改
  • 负荷
  • 重置
  • resize
  • scroll
  • selectstart

总结

希望您会同意 Shadow DOM 的功能非常强大。我们首次实现了适当的封装,而无需使用 <iframe> 或其他旧技术的额外负担。

Shadow DOM 确实是一个复杂的怪兽,但它是一个值得添加到 Web 平台的怪兽。 花点时间熟悉一下。轻松掌握。踊跃提问。

如果您想了解更多信息,请参阅 Dominic 的介绍性文章 Shadow DOM 101 和我的 Shadow DOM 201:CSS 和样式一文。