Shadow DOM 基础知识

Dominic Cooney
Dominic Cooney

简介

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

  1. 支持构建 widget
  2. …可靠地重复使用
  3. …如果组件的下一个版本更改了内部实现细节,也不会破坏页面。

这是否意味着您必须决定何时使用 HTML/JavaScript,何时使用 Web 组件?不执行!HTML 和 JavaScript 可用于制作交互式视觉内容。微件是可交互的视觉内容。在开发微件时,利用您的 HTML 和 JavaScript 技能非常有用。Web Components 标准旨在帮助您实现这一点。

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

Web 组件由三个部分组成:

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

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

Hello, Shadow World

借助 Shadow DOM,元素可以获得与其关联的新类型节点。这种新类型的节点称为“阴影根”。与影子根关联的元素称为影子宿主。系统不会呈现阴影主机的内容,而是呈现阴影根的内容。

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

<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!”,因为影子根下的 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 访问。现在,我们就执行此操作来填充阴影根目录:

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

现在,我们已设置阴影根,系统会再次渲染名称标记。如果您右键点击名称标记并检查该元素,就会发现它是语义标记:

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

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

第 2 步:将内容与演示文稿分离开来

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

HTML 元素是可组合元素,例如,您可以在表格中放置按钮。我们需要用到组合:姓名标签必须是红色背景、“Hi!” 文本和姓名标签上的内容的组合。

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

如果我们将 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>

阴影根设置代码保持不变。影子根目录中会添加哪些内容:

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

与当今 Web 上的现状相比,这是一个巨大的改进,因为您的名称更新代码可以依赖于简单且一致的组件结构。您的名称更新代码无需知道用于呈现的结构。如果我们考虑呈现的内容,则名称在英语中显示在第二位(在“Hi! 我的名字是”),但先说日语(在“と申します”之前)。从更新显示的名字的角度来看,这种区分在语义上没有意义,因此姓名更新代码不必了解这一细节。

额外学分:高级投影

在上面的示例中,<content> 元素从影子主机中精选了所有内容。通过使用 select 属性,您可以控制内容元素投影的内容。您还可以使用多个内容元素。

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

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

以及一个使用 CSS 选择器选择特定内容的阴影根:

<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 宿主上使用多个 shadow,或使用嵌套 shadow 进行封装,或者使用模型驱动型视图 (MDV) 和 shadow DOM 构建页面架构。而且,Web 组件不仅仅是 Shadow DOM。

我们将在后续文章中介绍这些内容。