Shadow DOM 基础知识

Dominic Cooney
Dominic Cooney

简介

Web 组件是一组前沿标准,具有以下特点:

  1. 让用户能够构建微件
  2. …可以可靠地重复使用
  3. …并且如果组件的下一个版本更改了内部实现细节,也不会破坏页面。

这是否意味着您必须决定何时使用 HTML/JavaScript,以及何时使用 Web 组件?不需要!HTML 和 JavaScript 可以制作交互式视觉内容。微件是交互式视觉元素。在开发 widget 时,利用您的 HTML 和 JavaScript 技能是明智之举。Web 组件标准旨在帮助您实现这一目标。

但有一个根本问题导致使用 HTML 和 JavaScript 构建的 widget 难以使用:widget 内的 DOM 树未与页面的其余部分封装在一起。这种缺乏封装的情况意味着,您的文档样式表可能会意外应用于微件内部的部分;您的 JavaScript 可能会意外修改微件内部的部分;您的 ID 可能会与微件内部的 ID 重叠;等等。

Web 组件由三个部分组成:

  1. 模板
  2. Shadow DOM
  3. 自定义元素

Shadow DOM 解决了 DOM 树封装问题。Web 组件的四个部分旨在协同工作,但您也可以选择要使用的 Web 组件部分。本教程介绍了如何使用 Shadow DOM。

Hello, Shadow World

借助 Shadow DOM,元素可以获得与其关联的新节点。这种新节点称为影子根。具有关联影子根的元素称为影子宿主。系统不会呈现 shadow host 的内容,而是呈现 shadow root 的内容。

例如,如果您有如下标记:

<button>Hello, world!</button>
<script>
var host = document.querySelector('button');
var root = host.createShadowRoot();
root.textContent = 'こんにちは、影の世界!';
</script>

那么,

<button id="ex1a">Hello, world!</button>
<script>
function remove(selector) {
  Array.prototype.forEach.call(
      document.querySelectorAll(selector),
      function (node) { node.parentNode.removeChild(node); });
}

if (!HTMLElement.prototype.createShadowRoot) {
  remove('#ex1a');
  document.write('<img src="SS1.png" alt="Screenshot of a button with \'Hello, world!\' on it.">');
}
</script>

您的网页

<button id="ex1b">Hello, world!</button>
<script>
(function () {
  if (!HTMLElement.prototype.createShadowRoot) {
    remove('#ex1b');
    document.write('<img src="SS2.png" alt="Screenshot of a button with \'Hello, shadow world!\' in Japanese on it.">');
    return;
  }
  var host = document.querySelector('#ex1b');
  var root = host.createShadowRoot();
  root.textContent = 'こんにちは、影の世界!';
})();
</script>

不仅如此,如果页面上的 JavaScript 询问按钮的 textContent 是什么,它不会得到“こんにちは、影の世界!”,而是会得到“Hello, world!”,因为 Shadow DOM 根下的 DOM 子树是封装的。

将内容与呈现分离

现在,我们将了解如何使用 Shadow DOM 将内容与呈现形式分离开来。假设我们有以下名称标签:

<style>
.ex2a.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.ex2a .boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.ex2a .name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="ex2a outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

以下是标记。这是您今天会写的内容。它不使用 Shadow DOM:

<style>
.outer {
  border: 2px solid brown;
  border-radius: 1em;
  background: red;
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
}
.boilerplate {
  color: white;
  font-family: sans-serif;
  padding: 0.5em;
}
.name {
  color: black;
  background: white;
  font-family: "Marker Felt", cursive;
  font-size: 45pt;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>

由于 DOM 树缺乏封装,因此名称标签的整个结构都会向文档公开。如果页面上的其他元素意外使用了相同的类名进行样式设置或脚本编写,我们就会遇到麻烦。

我们就能避免不愉快的经历。

第 1 步:隐藏演示文稿详细信息

从语义上讲,我们可能只关心:

  • 这是一个名称标签。
  • 名称为“Bob”。

首先,我们编写更接近我们想要的真实语义的标记:

<div id="nameTag">Bob</div>

然后,我们将用于呈现的所有样式和 div 放入 <template> 元素中:

<div id="nameTag">Bob</div>
<template id="nameTagTemplate">
<span class="unchanged"><style>
.outer {
  border: 2px solid brown;

  … same as above …

</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div></span>
</template>

此时,只有“Bob”会被渲染。由于我们将呈现 DOM 元素移到了 <template> 元素内,因此它们不会呈现,但可以从 JavaScript 访问。现在,我们来填充 shadow root:

<script>
var shadow = document.querySelector('#nameTag').createShadowRoot();
var template = document.querySelector('#nameTagTemplate');
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

现在,我们已经设置了 shadow root,名称标签会再次呈现。如果您右键点击名称标签并检查元素,您会看到它是一个简洁的语义标记:

<div id="nameTag">Bob</div>

这表明,通过使用 Shadow DOM,我们已从文档中隐藏了名称标签的呈现细节。呈现细节封装在 Shadow DOM 中。

第 2 步:将内容与呈现方式分离

我们的名称标签现在会隐藏页面中的呈现细节,但实际上并未将呈现与内容分开,因为虽然内容(名称“Bob”)在页面中,但呈现的名称是我们复制到 shadow root 中的名称。如果我们想更改名称标签上的名称,则需要在两个位置进行更改,并且它们可能会不同步。

HTML 元素是可组合的,例如,您可以将按钮放入表格中。这里需要用到组合:名称标签必须由红色背景、“Hi!”文本和名称标签上的内容组合而成。

您作为组件作者,可以使用名为 <content> 的新元素来定义组合如何与微件搭配使用。这会在 widget 的呈现中创建一个插入点,该插入点会从 shadow host 中挑选内容,并在该点呈现。

如果我们将 Shadow DOM 中的标记更改为:

<span class="unchanged"><template id="nameTagTemplate">
<style>
  …
</style></span>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    <content></content>
  </div>
</div>
<span class="unchanged"></template></span>

渲染名称标签时,影子宿主的内容会投影到 <content> 元素出现的位置。

现在,文档的结构更简单,因为名称仅位于一个位置,即文档中。如果您的页面需要更新用户的姓名,只需编写以下代码:

document.querySelector('#nameTag').textContent = 'Shellie';

就是这么简单。浏览器会自动更新名称标签的呈现,因为我们使用 <content> 将名称标签的内容投影到相应位置。

<div id="ex2b">

现在,我们已经实现了内容和呈现的分离。内容位于文档中;呈现位于 Shadow DOM 中。 当需要渲染某些内容时,浏览器会自动使它们保持同步。

第 3 步:盈利

通过将内容和呈现方式分开,我们可以简化操作内容的代码。在名称标签示例中,该代码只需处理包含一个 <div> 而不是多个的简单结构。

现在,如果我们更改演示文稿,则无需更改任何代码!

例如,假设我们要对名称标签进行本地化。它仍然是名称标记,因此文档中的语义内容不会发生变化:

<div id="nameTag">Bob</div>

影子根设置代码保持不变。只是放入 shadow root 的内容发生了变化:

<template id="nameTagTemplate">
<style>
.outer {
  border: 2px solid pink;
  border-radius: 1em;
  background: url(sakura.jpg);
  font-size: 20pt;
  width: 12em;
  height: 7em;
  text-align: center;
  font-family: sans-serif;
  font-weight: bold;
}
.name {
  font-size: 45pt;
  font-weight: normal;
  margin-top: 0.8em;
  padding-top: 0.2em;
}
</style>
<div class="outer">
  <div class="name">
    <content></content>
  </div>
  と申します。
</div>
</template>

与当今网络上的情况相比,这是一项重大改进,因为您的名称更新代码可以依赖于组件的结构,而该结构简单且一致。您的名称更新代码不需要知道用于呈现的结构。如果我们考虑呈现的内容,在英语中,名字会出现在第二位(在“Hi! “My name is”),但在日语中,名称位于开头(在“と申します”之前)。从更新所显示名称的角度来看,这种区别在语义上毫无意义,因此名称更新代码无需了解该细节。

额外课程内容:高级投影

在上面的示例中,<content> 元素会从 shadow host 中挑选所有内容。通过使用 select 属性,您可以控制内容元素投影的内容。您也可以使用多个内容元素。

例如,如果您有一个包含以下内容的文档:

<div id="nameTag">
  <div class="first">Bob</div>
  <div>B. Love</div>
  <div class="email">bob@</div>
</div>

以及一个使用 CSS 选择器来选择特定内容的 shadow root:

<div style="background: purple; padding: 1em;">
  <div style="color: red;">
    <content **select=".first"**></content>
  </div>
  <div style="color: yellow;">
    <content **select="div"**></content>
  </div>
  <div style="color: blue;">
    <content **select=".email">**</content>
  </div>
</div>

<div class="email"> 元素与 <content select="div"> 和 <content select=".email"> 元素均匹配。Bob 的邮箱地址出现了多少次?分别是什么颜色?

答案是:Bob 的邮箱出现了一次,并且是黄色的。

原因在于,正如那些研究 Shadow DOM 的人所知,构建实际在屏幕上呈现的树就像一场盛大的派对。content 元素是邀请函,可让文档中的内容进入后台 Shadow DOM 渲染派对。这些邀请会按顺序发送;谁会收到邀请取决于邀请的收件人(即 select 属性)。内容一旦受邀,就会始终接受邀请(谁会拒绝呢?!),然后开始播放。如果再次向该地址发送邀请,由于无人居住,对方自然无法参加您的派对。

在上述示例中,<div class="email"> 同时匹配 div 选择器和 .email 选择器,但由于具有 div 选择器的内容元素在文档中出现得更早,因此 <div class="email"> 会进入黄色派对,而没有人可以进入蓝色派对。(这可能就是它如此忧郁的原因,不过同病相怜,谁知道呢。)

如果某个内容未受邀参加任何派对,则根本不会呈现。第一个示例中的“Hello, world”文本就是这种情况。如果您想实现截然不同的渲染效果,这会很有用:在文档中编写语义模型(这是页面中的脚本可以访问的内容),但出于渲染目的将其隐藏,并使用 JavaScript 将其连接到 Shadow DOM 中截然不同的渲染模型。

例如,HTML 有一个不错的日期选择器。如果您输入 <input type="date">,系统会显示一个简洁的弹出式日历。但如果您想让用户为他们的甜点岛屿假期选择一个日期范围(您知道…用红藤条制成的吊床),该怎么办?您可以通过以下方式设置文档:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

而是创建 Shadow DOM,使用表格来创建精美的日历,突出显示日期范围等。当用户点击日历中的日期时,该组件会更新 startDate 和 endDate 输入框中的状态;当用户提交表单时,这些输入元素中的值也会一并提交。

如果标签不会呈现,为什么还要在文档中添加标签?原因是,如果用户使用不支持 Shadow DOM 的浏览器查看表单,表单仍然可用,只是不那么美观。用户会看到如下内容:

<div class="dateRangePicker">
  <label for="start">Start:</label>
  <input type="date" name="startDate" id="start">
  <br>
  <label for="end">End:</label>
  <input type="date" name="endDate" id="end">
</div>

您已通过 Shadow DOM 101 课程

以上就是 Shadow DOM 的基础知识,您已通过 Shadow DOM 101 课程!您可以使用 Shadow DOM 执行更多操作,例如,您可以在一个 shadow host 上使用多个 shadow,或使用嵌套 shadow 进行封装,或者使用模型驱动视图 (MDV) 和 Shadow DOM 构建页面。Web 组件不仅仅是 Shadow DOM。

我们将在后续帖子中进行说明。