Shadow DOM 101

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.

介绍

Web 组件是一系列前沿规范,它:

  1. 使得构建部件(widget)成为可能
  2. …重用更为可靠
  3. …即便后续版本的组件修改了内部实现细节也不会使页面出错。

这是否意味着你得决定什么时候用 HTML/JavaScript,什么时候用 Web 组件?不!HTML 和 JavaScript 可以制作交互式可视化内容, 部件也是交互式可视化内容。在开发部件的过程中自然而然的就会利用你的 HTML 和 JavaScript 技巧。Web 组件标准就是以此为目的而设计的。

若是使用别的技术来构建部件却也说不通。比如,我就肯定不会推荐你用 <canvas> 来写部件。它确实可靠——如果你修改绘制的内容也不会破坏页面——但它对可访问性(accessibility),索引(indexing),组合(composition),分辨率无关(resolution independence)都不友好。

但有个根本问题,导致 HTML 和 JavaScript 构建出来的部件难以使用:部件中的 DOM 树并没有封装起来。 封装的缺乏意味着文档中的样式表会无意中影响部件中的某些部分; JavaScript 可能在无意中修改部件中的某些部分;你书写的 ID 也可能会把部件内部的 ID 覆盖。

缺乏封装的一个明显缺点在于:如果你更新了库或者部件的 DOM 更改了内部细节,你的样式和脚本就可能在不经意间遭到破坏。

Web 组件由四部分组成:

  1. Templates
  2. Shadow DOM
  3. Custom Elements
  4. Packaging

Shadow DOM 解决了 DOM 树的封装问题。Web 组件的四部分被设计成配合工作,但你也可以选择 Web 组件中的某个部分来使用。该教程将教会你如何使用 Shadow DOM。

注意: Chrome 25+ 支持 Shadow DOM,但 API 需要加 webkit 前缀。 在 Chrome 的最新版本中增加了无前缀的 API,可以通过开启 about:flags 下的 "实验性网络平台功能"来使用。

Hello, Shadow World

有了 Shadow DOM,元素就可以和一个新类型的节点关联。这个新类型的节点称为 shadow root。与一个 shadow root 关联的元素称作一个 shadow host。shadow host 的内容不会渲染;shadow root 的内容会渲染。

比如,你拥有如下的标记:

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

因此,相比显示以下内容

页面将会呈现如下内容

不仅如此,若页面中的 JavaScript 想获得按钮的 textContent 是什么,它不会得到 “こんにちは、影の世界!”,而是 “Hello, world!”,因为 shadow root 下的 DOM 子树被封装了起来。

介绍一个(可能不被遵守的)经验法则, 你不应该把内容放到 Shadow DOM 中。内容必须放入文档内以便屏幕阅读器,搜索引擎,扩展等类似程序可以访问到。 在创建一个吸引人的,可重用的部件时,那些无意义的标记要放进 Shadow DOM 中,可内容还得留在页面里。

当然,这并不是必须遵守的;在 web 上你可以按照自己的喜好来做。但千万别过火。

从展现中分离内容

现在我们来看看如何使用 Shadow DOM 来将内容从展现中分离出来。假设我们拥有如下姓名卡:

Hi! My name is
Bob

以下是标记,它并没有使用 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 树缺乏封装,整个姓名卡的结构都暴露给了文档。若是页面里其他的元素不经意间使用了同样的类名来设置样式或操作脚本,那么我们可就有苦日子过了。

我们完全可以避免这样的情况发生

第一步:隐藏展现细节

从语义上讲,我们可能只关心如下内容:

  • 这是一个姓名卡。
  • 名称是 “Bob”。

首先,我们先按照最接近我们关心的语义的方式来书写标记:

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

接下来我们把所有和展现相关的样式和 div 都放入一个 <template> 元素内:

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

  … 和上面一样 …

</style>
<div class="outer">
  <div class="boilerplate">
    Hi! My name is
  </div>
  <div class="name">
    Bob
  </div>
</div>
</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);
</script>
Templates,和 Shadow DOM 一样,也是一个完善中的规范。Chrome Canary 支持 <template> 元素。你也可以选择使用 innerHTMLappendChildgetElementById 这样的方法或属性来填充 shadow root。本文主要讨论 Shadow DOM,所以不会涉及太多 template 元素的工作原理。如果你想了解更多 <template> 的内容,查看 HTML 的新 Template 标签.

我们建立了一个 shadow root,姓名卡被再次渲染。如果你右键点击标签,选择检查元素,你将看到漂亮并富于语义的标记:

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

这便印证了,通过使用 Shadow DOM,我们可以将展现细节隐藏在姓名卡中。展现细节被封装在了 Shadow DOM 中。

第二步:从展现中分离内容

现在我们的姓名卡可以从页面中隐藏展现细节了,但展现和内容却没有分离, 因为虽然内容(就是姓名“Bob”)显示在了页面里,但这个姓名是从 shadow root 中复制过来的。 如果我们想修改姓名卡的姓名,就得修改两个地方,这就可能造成它们不同步。

HTML 元素是可组合的 — 比如说你可以把一个按钮放进一个表格里。 在这儿我们就需要使用组合:姓名卡必须将红色背景,“Hi!”文本,和标签内容组合在一起。

作为组件作者的你定义了一个 <content> 元素来完成部件的组合工作。 这为部件的展现创建了一个插入点(insertion point),而该插入点将挑选 shadow host 里的内容显示到该点所在的位置上。

如果我们把 Shadow DOM 里的标记做如下修改:

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

当姓名卡渲染后,shadow host 的内容便投射(projected)到 <content> 元素出现的地方。

现在文档的结构简单了,因为名称只出现在了一个地方——就在文档里。如果想更新用户名称,你只需写:

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

这就足够了。浏览器会自动更新渲染的姓名卡,因为我们把姓名卡的内容投射到了 <content> 元素内。

下面是使用 Shadow DOM 的实例:

Bob

我们实现了分离内容和展现的目的。内容在文档内;展现在 Shadow DOM 里。 当需要更新的时候,浏览器会自动保持它们的同步。

第三步:福利

通过分离内容和展现,我们可以简化操作内容的代码——在姓名卡的例子中,代码只需要和包含一个 <div> 标签的简单结构打交道,而不必操作多个标签。

如果此时我们修改展现,就不用动任何代码了!

比方说,我们想将姓名卡本地化。因为它仍然是一个姓名卡,所以它在文档中的语义内容没有改变:

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

设置 shadow root 的代码保持不变。只是放进 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>

现在我们有了一个日文的姓名卡:

Bob

背景图片来自于 Mike Dowman, 基于 Creative Commons license 重用。

在当今的 web 环境下这是一个巨大的进步,因为更新名字的代码可以依赖于简单且一致的组件的结构, 而无需知晓用于渲染的结构。 比如说从渲染的角度考虑,在英文中名称应该出现在第二行(在 “Hi! My name is” 之后),但是在日文中需要出现在第一行(在 “と申します” 之前)。从更新名称的角度来说,这个区别对于语义没有任何意义,因此名称更新代码并不需要了解这些细节。

额外福利:高级投射

在上面的例子中,<content> 元素挑选了 shadow host 的所有内容。通过使用 select 特性,你可以控制 content 元素投射的内容。你也可以使用多个 content 元素。

比如说,如果你有一个包含如下内容的文档:

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

shadow root 使用 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>

注意: select 只能选择 host 节点的直接子元素。也就是说,你不能选择后代元素(例如 select="table tr")。

<div class="email"> 元素同时被 <content select="div"><content select=".email"> 元素所匹配。那 Bob 的电子邮件地址会出现多少次,都是什么颜色?

Bob
B. Love

答案是:Bob 的电子邮件地址仅显示一次,并且是黄色的。

为什么会这样?那些了解 Shadow DOM 原理的人们都知道,把最终渲染在屏幕上的内容构建成一棵树,就像举办一个盛大的舞会。 content 元素就是将文档中的内容邀请到 Shadow DOM 渲染舞会的请柬。 这些请柬按顺序发放;谁能收到邀请取决于请柬的地址(即 select 特性。), 一旦收到邀请,便会接受请柬(有谁不会呢?!)立即动身。如果接下来又有一封请柬发送到该地址,可现在家里没人,这个舞会也就去不了了。

在上面的例子中,<div class="email"> 同时匹配 div 选择器和 .email 选择器,但因为含有 div 选择器的 content 元素在文档中的位置靠前, <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,在里面使用表格生成一个能够高亮日期范围的日历。 当用户点击日历中的日期,组件会更新输入框的起始日期和结束日期;当用户提交表单,两个输入框元素的值就会被提交。

你可能会奇怪,为什么我明知道 label 元素不会被渲染还去写它呢? 原因在于如果用户通过一个不支持 Shadow DOM 的浏览器来访问表单, 那么表单还是保证可用的,只是不太漂亮。最终看起来可能是这样的:


你通过了 Shadow DOM 101

这里介绍的是 Shadow DOM 的基础——你通过了 Shadow DOM 101! 你可以使用 Shadow DOM 实现更多事情,比方说在一个 shadow host 内使用多个 shadow DOM, 或是出于封装的缘故来嵌套 shadow DOM,或是使用 Model-Driven Views (MDV) 和 Shadow DOM 来架构页面。 而且 Web 组件可不仅仅包含 Shadow DOM。使用 Web 组件的另一部分:自定义元素,你就可以用声明的方式来设置部件的 Shadow DOM,而不必去写脚本了。

我们将在后续文章中解释这些内容。好了,请在Google+ 上关注 Web 组件吧。

感谢 Eric Bidelman,Darin Fisher,Dimitri Glazkov,Alex Komoroske,Alex Russell,和 Paul Irish 对本教程早期版本提出的建议。

Comments

0