深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

 

shadow DOM不是超级英雄电影中的恶棍,也不是DOM的黑暗面。 shadow DOM只是一种解决文档对象模型(或简称DOM)中缺少的树封装方法。

网页通常使用来自外部源的数据和小部件,如果它们没有封装,那么样式可能会影响HTML中不必要的部分,迫使开发人员使用特定的选择器和!important 规则来避免样式冲突。

尽管如此,在编写大型程序时,这些努力似乎并不是那么有效,并且大量的时间被浪费在防止CSS和JavaScript的冲突上。 Shadow DOM API旨在通过提供封装DOM树的机制来解决这些问题。

Shadow DOM是用于创建Web组件的主要技术之一,另外两个是自定义元素和HTML模板。 Web 组件的规范最初是由Google提出的,用于简化Web小部件的开发。

虽然这三种技术旨在协同工作,不过你可以自由地分别使用每种技术。本教程的范围仅限于shadow DOM。

 

什么是DOM?

在深入研究如何创建shadow DOM之前,了解DOM是什么非常重要。 W3C文档对象模型(DOM)提供了一个平台和语言无关的应用程序编程接口(API),用于表示和操作存储在HTML和XML文档中的信息。

通过使用DOM,程序员可以访问、添加、删除或更改元素和内容。 DOM将网页视为树结构,每个分支以节点结束,每个节点包含一个对象,可以使用JavaScript等脚本语言对其进行修改。

考虑以下HTML文档:

<html>
  <head>
    <title>Sample document</title>
  </head>
  <body>
    <h1>Heading</h1>
    <a href="https://example.com">Link</a>
  </body>
</html>
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

此图中所有的框都是节点。

用于描述DOM部分的术语类似于现实世界中的家谱树:

  • 给定节点上一级节点是该节点的父节点
  • 给定节点下一级节点是该节点的子节点
  • 具有相同父级的节点是兄弟节点
  • 给定节点上方的所有节点(包括父节点和祖父节点)都称为该节点的祖先
  • 最后,给定节点下所有的节点都被称为该节点的后代

节点的类型取决于它所代表的HTML元素的类型。 HTML标记被称为元素节点。嵌套标签形成一个元素树。元素中的文本称为文本节点。文本节点可能没有子节点,你可以把它想象成是一棵树的叶子。

为了访问树,DOM提供了一组方法,程序员可以用这些方法修改文档的内容和结构。例如当你写下document.createElement('p');时,就在使用DOM提供的方法。没有DOM,JavaScript就无法理解HTML和XML文档的结构。

下面的JavaScript代码显示了如何使用DOM方法创建两个HTML元素,将一个嵌套在另一个内部并设置文本内容,最后把它们附加到文档正文:

const section = document.createElement('section');
const p = document.createElement('p');
p.textContent = 'Hello!';
section.appendChild(p);
document.body.appendChild(section);

 

这是运行这段JavaScript代码后生成的DOM结构:

<body>
  <section>
    <p>Hello!</p>
  </section>
</body>

 

什么是 shadow DOM?

封装是面向对象编程的基本特性,它使程序员能够限制对某些对象组件的未授权访问。

在此定义下,对象以公共访问方法的形式提供接口作为与其数据交互的方式。这样对象的内部表示不能直接被对象的外部访问。

Shadow DOM将此概念引入HTML。它允许你将隐藏的,分离的DOM链接到元素,这意味着你可以使用HTML和CSS的本地范围。现在可以用更通用的CSS选择器而不必担心命名冲突,并且样式不再泄漏或被应用于不恰当的元素。

实际上,Shadow DOM API正是库和小部件开发人员将HTML结构、样式和行为与代码的其他部分分开所需的东西。

Shadow root 是 shadow 树中最顶层的节点,是在创建 shadow DOM 时被附加到常规DOM节点的内容。具有与之关联的shadow root的节点称为shadow host。

你可以像使用普通DOM一样将元素附加到shadow root。链接到shadow root的节点形成 shadow 树。通过图表应该能够表达的更清楚:

深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

术语light DOM通常用于区分正常DOM和shadow DOM。shadow DOM和light DOM被并称为逻辑DOM。light DOM与shadow DOM分离的点被称为阴影边界。 DOM查询和CSS规则不能到达阴影边界的另一侧,从而创建封装。

 

Shadow DOM 为封装而生。它可以让一个组件拥有自己的「影子」DOM 树,这个 DOM 树不能在主文档中被任意访问,可能拥有局部样式规则,还有其他特性。

 

内建 shadow DOM

你是否曾经思考过复杂的浏览器控件是如何被创建和添加样式的?

比如 <input type="range">

浏览器在内部使用 DOM/CSS 来绘制它们。这个 DOM 结构一般来说对我们是隐藏的,但我们可以在开发者工具里面看见它。比如,在 Chrome 里,我们需要打开「Show user agent shadow DOM」选项。

然后 <input type="range"> 看起来会像这样:

深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

你在 #shadow-root 下看到的就是被称为「shadow DOM」的东西。

我们不能使用一般的 JavaScript 调用或者选择器来获取内建 shadow DOM 元素。它们不是常规的子元素,而是一个强大的封装手段。

在上面的例子中,我们可以看到一个有用的属性 pseudo。这是一个因为历史原因而存在的属性,并不在标准中。我们可以使用它来给子元素加上 CSS 样式,像这样:

<style>
/* 让滑块轨道变红 */
input::-webkit-slider-runnable-track {
  background: red;
}
</style>

<input type="range">

重申一次,pseudo 是一个非标准的属性。按照时间顺序来说,浏览器首先实验了使用内部 DOM 结构来实现控件,然后,在一段时间之后,shadow DOM 才被标准化来让我们,开发者们,做类似的事。

接下来,我们将要使用现代 shadow DOM 标准,它在 DOM spec 和其他相关标准中可以被找到。

 

Shadow tree

一个 DOM 元素可以有以下两类 DOM 子树:

  1. Light tree(光明树) —— 一个常规 DOM 子树,由 HTML 子元素组成。我们在之前章节看到的所有子树都是「光明的」。
  2. Shadow tree(影子树) —— 一个隐藏的 DOM 子树,不在 HTML 中反映,无法被察觉。

如果一个元素同时有以上两种子树,那么浏览器只渲染 shadow tree。但是我们同样可以设置两种树的组合。我们将会在后面的章节 Shadow DOM 插槽,组成 中看到更多细节。

影子树可以在自定义元素中被使用,其作用是隐藏组件内部结构和添加只在组件内有效的样式。

比如,这个 <show-hello> 元素将它的内部 DOM 隐藏在了影子里面:

<script>
customElements.define('show-hello', class extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({mode: 'open'});
    shadow.innerHTML = `<p>
      Hello, ${this.getAttribute('name')}
    </p>`;
  }
});
</script>

<show-hello name="John"></show-hello>

 

这就是在 Chrome 开发者工具中看到的最终样子,所有的内容都在「#shadow-root」下:

深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

首先,调用 elem.attachShadow({mode: …}) 可以创建一个 shadow tree。

这里有两个限制:

  1. 在每个元素中,我们只能创建一个 shadow root。
  2. elem 必须是自定义元素,或者是以下元素的其中一个:「article」、「aside」、「blockquote」、「body」、「div」、「footer」、「h1…h6」、「header」、「main」、「nav」、「p」、「section」或者「span」。其他元素,比如 <img>,不能容纳 shadow tree。

mode 选项可以设定封装层级。他必须是以下两个值之一:

  • 「open」 —— shadow root 可以通过 elem.shadowRoot 访问。任何代码都可以访问 elem 的 shadow tree。
  • 「closed」 —— elem.shadowRoot 永远是 null。我们只能通过 attachShadow 返回的指针来访问 shadow DOM(并且可能隐藏在一个 class 中)。浏览器原生的 shadow tree,比如 <input type="range">,是封闭的。没有任何方法可以访问它们。

attachShadow 返回的 shadow root,和任何元素一样:我们可以使用 innerHTML 或者 DOM 方法,比如 append 来扩展它。

我们称有 shadow root 的元素叫做「shadow tree host」,可以通过 shadow root 的 host 属性访问:

// 假设 {mode: "open"},否则 elem.shadowRoot 是 null
alert(elem.shadowRoot.host === elem); // true

 

封装

Shadow DOM 被非常明显地和主文档分开:

  1. Shadow DOM 元素对于 light DOM 中的 querySelector 不可见。实际上,Shadow DOM 中的元素可能与 light DOM 中某些元素的 id 冲突。这些元素必须在 shadow tree 中独一无二。
  2. Shadow DOM 有自己的样式。外部样式规则在 shadow DOM 中不产生作用。

 

比如:

<style>
  /* 文档样式对 #elem 内的 shadow tree 无作用 (1) */
  p { color: red; }
</style>

<div id="elem"></div>

<script>
  elem.attachShadow({mode: 'open'});
    // shadow tree 有自己的样式 (2)
  elem.shadowRoot.innerHTML = `
    <style> p { font-weight: bold; } </style>
    <p>Hello, John!</p>
  `;

  // <p> 只对 shadow tree 里面的查询可见 (3)
  alert(document.querySelectorAll('p').length); // 0
  alert(elem.shadowRoot.querySelectorAll('p').length); // 1
</script>

 

  1. 文档里面的样式对 shadow tree 没有任何效果。
  2. ……但是内部的样式是有效的。
  3. 为了获取 shadow tree 内部的元素,我们可以从树的内部查询。

 

参考

 

总结

Shadow DOM 是创建组件级别 DOM 的一种方法。

  1. shadowRoot = elem.attachShadow({mode: open|closed}) —— 为 elem 创建 shadow DOM。如果 mode="open",那么它通过 elem.shadowRoot 属性被访问。
  2. 我们可以使用 innerHTML 或者其他 DOM 方法来扩展 shadowRoot

Shadow DOM 元素:

  • 有自己的 id 空间。
  • 对主文档的 JavaScript 选择器隐身,比如 querySelector
  • 只使用 shadow tree 内部的样式,不使用主文档的样式。

Shadow DOM,如果存在的话,会被浏览器渲染而不是所谓的 「light DOM」(普通子元素)。在 Shadow DOM 插槽,组成 章节中我们将会看到如何组织它们。

 

概况

本文章假设你对 DOM(文档对象模型)有一定的了解,它是由不同的元素节点、文本节点连接而成的一个树状结构,应用于标记文档中(例如  Web 文档中常见的 HTML 文档)。请看如下示例,一段 HTML 代码:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Simple DOM example</title>
  </head>
  <body>
      <section>
        <img src="dinosaur.png" alt="A red Tyrannosaurus Rex: A two legged dinosaur standing upright like a human, with small arms, and a large head with lots of sharp teeth.">
        <p>Here we will add a link to the <a href="https://www.mozilla.org/">Mozilla homepage</a></p>
      </section>
  </body>
</html>

 

这个片段会生成如下的 DOM 结构:

 

深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

 

Shadow DOM 允许将隐藏的 DOM 树附加到常规的 DOM 树中——它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的 DOM 元素一样。

 

深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)
深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

 

这里,有一些 Shadow DOM 特有的术语需要我们了解:

  • Shadow host:一个常规 DOM节点,Shadow DOM 会被附加到这个节点上。
  • Shadow tree:Shadow DOM内部的DOM树。
  • Shadow boundary:Shadow DOM结束的地方,也是常规 DOM开始的地方。
  • Shadow root: Shadow tree的根节点。

 

你可以使用同样的方式来操作 Shadow DOM,就和操作常规 DOM 一样——例如添加子节点、设置属性,以及为节点添加自己的样式(例如通过 element.style 属性),或者为整个 Shadow DOM 添加样式(例如在 <style> 元素内添加样式)。不同的是,Shadow DOM 内部的元素始终不会影响到它外部的元素(除了 :focus-within),这为封装提供了便利。

注意,不管从哪个方面来看,Shadow DOM 都不是一个新事物——在过去的很长一段时间里,浏览器用它来封装一些元素的内部结构。以一个有着默认播放控制按钮的 <video> 元素为例。你所能看到的只是一个 <video> 标签,实际上,在它的 Shadow DOM 中,包含来一系列的按钮和其他控制器。Shadow DOM 标准允许你为你自己的元素(custom element)维护一组 Shadow DOM。

 

基本用法

可以使用 Element.attachShadow() 方法来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性,值可以是 open 或者 closed

let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

 

open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM,例如使用 Element.shadowRoot 属性:

let myShadowDom = myCustomElem.shadowRoot;

如果你将一个 Shadow root 附加到一个 Custom element 上,并且将 mode 设置为 closed,那么就不可以从外部获取 Shadow DOM 了——myCustomElem.shadowRoot 将会返回 null。浏览器中的某些内置元素就是如此,例如<video>,包含了不可访问的 Shadow DOM。

 

备注: 正如这篇文章所阐述的,处理 closed Shadow DOM 实际上很简单,往往也很值得这么做。

 

如果你想将一个 Shadow DOM 附加到 custom element 上,可以在 custom element 的构造函数中添加如下实现(目前,这是 shadow DOM 最实用的用法):

let shadow = this.attachShadow({mode: 'open'});

 

将 Shadow DOM 附加到一个元素之后,就可以使用 DOM APIs对它进行操作,就和处理常规 DOM 一样。

var para = document.createElement('p');
shadow.appendChild(para);
etc.

 

编写简单示例

现在,让我们着手实现一个示例——<popup-info-box>(也可以查看在线示例),来说明 Shadow DOM 在 custom element 中的实际运用。它包含一个图片 icon 和一段文字,并将 icon 嵌入页面之中。每当 icon 获取到焦点时,文字会在一个弹框中显示,以提供更加详细的信息。首先,在 JavaScript 文件中,我们需要定义一个叫做 PopUpInfo 的类,它继承自 HTMLElement

class PopUpInfo extends HTMLElement {
  constructor() {
    // 必须首先调用 super方法
    super();

    // 元素的具体功能写在下面

    ...
  }
}

在上面的类中,我们将会在它的构造函数中定义元素所有的功能。当类实例化后,所有的实例元素都会有相同功能。

 

创建 shadow root

在构造函数中,我们首先将 Shadow root 附加到 custom element 上:

// 创建 shadow root
var shadow = this.attachShadow({mode: 'open'});

 

创建 shadow DOM 结构

接下来,我们会使用相关 DOM 操作来创建元素的 Shadow DOM 结构:

// 创建 span
var wrapper = document.createElement('span');
wrapper.setAttribute('class','wrapper');
var icon = document.createElement('span');
icon.setAttribute('class','icon');
icon.setAttribute('tabindex', 0);
var info = document.createElement('span');
info.setAttribute('class','info');

// 获取属性的内容并将内容添加到 info 元素内
var text = this.getAttribute('text');
info.textContent = text;

// 插入 icon
var imgUrl;
if(this.hasAttribute('img')) {
  imgUrl = this.getAttribute('img');
} else {
  imgUrl = 'img/default.png';
}
var img = document.createElement('img');
img.src = imgUrl;
icon.appendChild(img);

 

为 shadow DOM 添加样式

之后,我们将要创建 <style> 元素,并加入一些 CSS 样式:

// 为 shadow DOM 添加一些 CSS 样式
var style = document.createElement('style');

style.textContent = `
.wrapper {
  position: relative;
}

.info {
  font-size: 0.8rem;
  width: 200px;
  display: inline-block;
  border: 1px solid black;
  padding: 10px;
  background: white;
  border-radius: 10px;
  opacity: 0;
  transition: 0.6s all;
  position: absolute;
  bottom: 20px;
  left: 10px;
  z-index: 3;
}

img {
  width: 1.2rem;
}

.icon:hover + .info, .icon:focus + .info {
  opacity: 1;
}`;

 

将 Shadow DOM 添加到 Shadow root 上

最后,将所有创建的元素添加到 Shadow root 上:

// 将所创建的元素添加到 Shadow DOM 上
shadow.appendChild(style);
shadow.appendChild(wrapper);
wrapper.appendChild(icon);
wrapper.appendChild(info);

 

使用我们的 custom element

完成类的定义之后,使用元素也是一样简单,只需将 custom element 放在页面上,正如 Using custom elements 中讲解的那样:

// 定义新的元素
customElements.define('popup-info', PopUpInfo);
<popup-info img="img/alt.png" text="Your card validation code (CVC) is an extra security feature — it is the last 3 or 4 numbers on the back of your card.">

 

使用外部引入的样式

在上面的示例中,我们使用行内<style> 元素为Shadow DOM添加样式,但是完全可以通过<link> 标签引用外部样式表来替代行内样式。

例如,我们可以看下 popup-info-box-external-stylesheet 例子 (查看源代码 source code):

// 将外部引用的样式添加到 Shadow DOM 上
const linkElem = document.createElement('link');
linkElem.setAttribute('rel', 'stylesheet');
linkElem.setAttribute('href', 'style.css');

// 将所创建的元素添加到 Shadow DOM 上

shadow.appendChild(linkElem);

 

请注意, 因为<link> 元素不会打断 shadow root 的绘制, 因此在加载样式表时可能会出现未添加样式内容(FOUC),导致闪烁。

许多现代浏览器都对从公共节点克隆的或具有相同文本的<style> 标签实现了优化,以允许它们共享单个支持样式表,通过这种优化,外部和内部样式的性能表现比较接近。

 

参见

 

本文:深入理解Shadow DOM, 什么是 shadow DOM, shadow DOM入门, 影子 DOM(Shadow DOM)

Leave a Reply