深刻理解Shadow DOM v1

翻译:疯狂的技术宅
https://blog.logrocket.com/un...

本文首发微信公众号:前端先锋
欢迎关注,天天都给你推送新鲜的前端技术文章javascript


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

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

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

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

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

什么是DOM?

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

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

<html>
  <head>
    <title>Sample document</title>
  </head>
  <body>
    <h1>Heading</h1>
    <a href="https://example.com">Link</a>
  </body>
</html>

此HTML的DOM表示以下:github

clipboard.png

此图中全部的框都是节点。web

用于描述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 树。经过图表应该可以表达的更清楚:

clipboard.png

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

建立一个shadow DOM

要建立shadow DOM,须要用Element.attachShadow()方法将shadow root附加到元素:

var shadowroot = element.attachShadow(shadowRootInit);

来看一个简单的例子:

<div id="host"><p>Default text</p></div>
    
<script>
  const elem = document.querySelector('#host');
     
  // attach a shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});
     
  // create a <p> element
  const p = document.createElement('p');
     
  // add <p> to the shadow DOM
  shadowRoot.appendChild(p);
     
  // add text to <p> 
  p.textContent = 'Hello!';
</script>

此代码将一个shadow DOM树附加到div元素,其idhost。这个树与div的实际子元素是分开的,添加到它之上的任何东西都将是托管元素的本地元素。

clipboard.png

Chrome DevTools中的 Shadow root。

注意#host中的现有元素是如何被shadow root替换的。不支持shadow DOM的浏览器将使用默认内容。

如今,在将CSS添加到主文档时,样式规则不会影响shadow DOM:

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
 
  // attach a shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  // set the HTML contained within the shadow root
  shadowRoot.innerHTML = '<p>Shadow DOM</p>';
</script>
 
<style>
  p {color: red}
</style>

在light DOM中定义的样式不能越过shadow边界。所以,只有light DOM中的段落才会变为红色。

clipboard.png

相反,你添加到shadow DOM的CSS对于hosting元素来讲是本地的,不会影响DOM中的其余元素:

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>p {color: red}</style>`;
 
</script>

clipboard.png

你还能够将样式规则放在外部样式表中,以下所示:

shadowRoot.innerHTML = `
  <p>Shadow DOM</p>
  <link rel="stylesheet" href="style.css">`;

要获取 shadowRoot 附加到的元素的引用,使用host属性:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  console.log(shadowRoot.host);    // => <div id="host"></div>
</script>

要执行相反操做并获取对元素托管的shadow root的引用,能够用元素的shadowRoot属性:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  console.log(elem.shadowRoot);    // => #shadow-root (open)
</script>

shadowRoot mod

当调用Element.attachShadow()方法来附加shadow root时,必须经过传递一个对象做为参数来指定shadow DOM树的封装模式,不然将会抛出一个TypeError。该对象必须具备mode属性,其值为 openclosed

打开的shadow root容许你使用host元素的shadowRoot属性从root外部访问shadow root的元素,以下例所示:

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
 
  // attach an open shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
  // Nodes of an open shadow DOM are accessible
  // from outside the shadow root
  elem.shadowRoot.querySelector('p').innerText = 'Changed from outside the shadow root';
  elem.shadowRoot.querySelector('p').style.color = 'red';
</script>

clipboard.png

可是若是mode属性的值为“closed”,则尝试从root外部用JavaScript访问shadow root的元素时会抛出一个TypeError

<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
 
  // attach a closed shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'closed'});
 
  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
 
  elem.shadowRoot.querySelector('p').innerText = 'Now nodes cannot be accessed from outside';
  // => TypeError: Cannot read property 'querySelector' of null 
</script>

当mode设置为closed时,shadowRoot属性返回null。由于null值没有任何属性或方法,因此在它上面调用querySelector()会致使TypeError。浏览器一般用关闭的 shadow roo 来使某些元素的实现内部不可访问,并且不可从JavaScript更改。

要肯定shadow DOM是处于open仍是closed模式,你能够参考shadow root的mode属性:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'closed'});
 
  console.log(shadowRoot.mode);    // => closed
</script>

从表面上看,对于不但愿公开其组件的shadow root 的Web组件做者来讲,封闭的shadow DOM看起来很是方便,然而在实践中绕过封闭的shadow DOM并不难。一般彻底隐藏shadow DOM所需的工做量超过了它的价值。

并不是全部HTML元素均可以托管shadow DOM

只有一组有限的元素能够托管shadow DOM。下表列出了支持的元素:

+----------------+----------------+----------------+
|    article     |      aside     |   blockquote   |
+----------------+----------------+----------------+
|     body       |       div      |     footer     |
+----------------+----------------+----------------+
|      h1        |       h2       |       h3       |
+----------------+----------------+----------------+
|      h4        |       h5       |       h6       |
+----------------+----------------+----------------+
|    header      |      main      |      nav       |
+----------------+----------------+----------------+
|      p         |     section    |      span      |
+----------------+----------------+----------------+

尝试将shadow DOM树附加到其余元素将会致使“DOMException”错误。例如:

document.createElement('img').attachShadow({mode: 'open'});    
// => DOMException

<img>元素做为shadow host是不合理的,所以这段代码抛出错误并不奇怪。你可能会收到DOMException错误的另外一个缘由是浏览器已经用该元素托管了shadow DOM。

浏览器自动将shadow DOM附加到某些元素

Shadow DOM已存在很长一段时间了,浏览器一直用它来隐藏元素的内部结构,好比<input><textarea><video>

当你在HTML中使用<video>元素时,浏览器会自动将shadow DOM附加到包含默认浏览器控件的元素。但DOM中惟一可见的是<video>元素自己:

clipboard.png

要在Chrome中显示此类元素的shadow root,请打开Chrome DevTools设置(按F1),而后在“elements”部分下方选中“Show user agent shadow DOM”:

clipboard.png

选中“Show user agent shadow DOM”选项后,shadow root节点及其子节点将变为可见。如下是启用此选项后相同代码的显示方式:

clipboard.png

在自定义元素上托管shadow DOM

Custom Elements API 建立的自定义元素能够像其余元素同样托管shadow DOM。请看如下示例:

<my-element></my-element>
<script>
  class MyElement extends HTMLElement {
    constructor() {
 
      // must be called before the this keyword
      super();
 
      // attach a shadow root to <my-element>
      const shadowRoot = this.attachShadow({mode: 'open'});
 
      shadowRoot.innerHTML = `
        <style>p {color: red}</style>
        <p>Hello</p>`;
    }
  }
 
  // register a custom element on the page
  customElements.define('my-element', MyElement);
</script>

此代码了建立一个托管shadow DOM的自定义元素。它调用了customElements.define()方法,元素名称做为第一个参数,类对象做为第二个参数。该类扩展了HTMLElement并定义了元素的行为。

在构造函数中,super()用于创建原型链,而且把Shadow root附加到自定义元素。当你在页面上使用<my-element>时,它会建立本身的shadow DOM:

clipboard.png

请记住,有效的自定义元素不能是单个单词,而且名称中必须包含连字符( - )。例如,myelement不能用做自定义元素的名称,并会抛出 DOMException 错误。

样式化host元素

一般,要设置host元素的样式,你须要将CSS添加到light DOM,由于这是host元素所在的位置。可是若是你须要在shadow DOM中设置host元素的样式呢?

这就是host()伪类函数的用武之地。这个选择器容许你从shadow root中的任何地方访问shadow host。这是一个例子:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>
      :host {
        display: inline-block;
        border: solid 3px #ccc;
        padding: 0 15px;
      }
    </style>`;
</script>

值得注意的是:host仅在shadow root中有效。还要记住,在shadow root以外定义的样式规则比:host中定义的规则具备更高的特殊性。

例如,#host { font-size: 16px; } 的优先级高于 shadow DOM的 :host { font-size: 20px; }。实际上这颇有用,这容许你为组件定义默认样式,并让组件的用户覆盖你的样式。惟一的例外是!important规则,它在shadow DOM中具备特殊性。

你还能够将选择器做为参数传递给:host(),这容许你仅在host与指定选择器匹配时才会定位host。换句话说,它容许你定位同一host的不一样状态:

<style>
  :host(:focus) {
    /* style host only if it has received focus */
  }
 
  :host(.blue) {
    /* style host only if has a blue class */
  }
 
  :host([disabled]) {
    /* style host only if it's disabled */
  }
</style>

基于上下文的样式

要选择特定祖先内部的shadow root host ,能够用:host-context()伪类函数。例如:

:host-context(.main) {
  font-weight: bold;
}

只有当它是.main的后代时,此CSS代码才会选择shadow host :

<body class="main">
  <div id="host">
  </div>
</body>

:host-context()对主题特别有用,由于它容许做者根据组件使用的上下文对组件进行样式设置。

样式钩子

shadow DOM的一个有趣地方是它可以建立“样式占位符”并容许用户填充它们。这能够经过使用CSS自定义属性来完成。咱们来看一个简单的例子:

<div id="host"></div>
 
<style>
  #host {--size: 20px;}
</style>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>p {font-size: var(--size, 16px);}</style>`;
 
</script>

这个shadow DOM容许用户覆盖其段落的字体大小。使用自定义属性表示法( — size: 20px)设置该值,而且shadow DOM用var()函数(font-size: var( — size, 16px))检索该值。在概念方面,这相似于<slot>元素的工做方式。

可继承的样式

shadow DOM容许你建立独立的DOM元素,而不会从外部看到选择器可见性,但这并不意味着继承的属性不会经过shadow边界。

某些属性(如colorbackgroundfont-family)会传递shadow边界并应用于shadow树。所以,与iframe相比,shadow DOM不是一个很是强大的障碍。

<style>
  div {
    font-size: 25px;
    text-transform: uppercase;
    color: red;
  }
</style>
 
<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
</script>

解决方法很简单:经过声明all: initial将可继承样式重置为其初始值,以下所示:

<style>
  div {
    font-size: 25px;
    text-transform: uppercase;
    color: red;
  }
</style>
 
<div><p>Light DOM</p></div>
<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>
      :host p {
        all: initial;
      }
    </style>`;
</script>

在此例中,元素被强制回到初始状态,所以穿过shadow边界的样式不起做用。

从新定位事件

在shadow DOM内触发的事件能够穿过shadow边界并冒泡到light DOM;可是,Event.target的值会自动更改,所以它看起来好像该事件源自其包含的shadow树而不是实际元素的host元素。

此更改称为事件重定向,其背后的缘由是保留shadow DOM封装。请参考如下示例:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <ul>
      <li>One</li>
      <li>Two</li>
      <li>Three</li>
    <ul>
    `;
 
  document.addEventListener('click', (event) => {
    console.log(event.target);
  }, false);
</script>

当你单击shadow DOM中的任何位置时,这段代码会将 <div id =“host”> ... </div> 记录到控制台,所以侦听器没法看到调度该事件的实际元素。

可是在shadow DOM中不会发生重定目标,你能够轻松找到与事件关联的实际元素:

<div id="host"></div>
 
<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
 
  shadowRoot.innerHTML = `
    <ul>
      <li>One</li>
      <li>Two</li>
      <li>Three</li>
    </ul>`;
   
  shadowRoot.querySelector('ul').addEventListener('click', (event) => {
    console.log(event.target);
  }, false);  
</script>

请注意,并不是全部事件都会从shadow DOM传播出去。那些作的是从新定位,但其余只是被忽略了。若是你使用自定义事件的话,则须要使用composed:true标志,不然事件不会从shadow边界冒出来。

Shadow DOM v0 与 v1

Shadow DOM规范的原始版本在 Chrome 25 中实现,当时称为Shadow DOM v0。该规范的新版本改进了Shadow DOM API的许多方面。

例如,一个元素不能再承载多个shadow DOM,而某些元素根本不能托管shadow DOM。违反这些规则会致使错误。

此外,Shadow DOM v1提供了一组新功能,例如打开 shadow 模式、后备内容等。你能够找到由规范做者之一编写的 v0 和 v1 之间的全面比较(https://hayato.io/2016/shadow...)。能够在W3C找到Shadow DOM v1的完整描述。

浏览器对Shadow DOM v1的支持

在撰写本文时,Firefox和Chrome已经彻底支持Shadow DOM v1。不幸的是,Edge还没有实现v1,Safari 只是部分支持。在 Can I use…上提供了支持的浏览器的最新列表。

要在不支持Shadow DOM v1的浏览器上实现shadow DOM,能够用shadydomshadycss polyfills。

总结

DOM开发中缺少封装一直是个问题。 Shadow DOM API为咱们提供了划分DOM范围的能力,从而为这个问题提供了一个优雅的解决方案。

如今,样式冲突再也不是一个使人担心的问题,选择器也不会失控。 shadow DOM改变了小部件开发的游戏规则,可以建立从页面其他部分封装的小部件,而且不受其余样式表和脚本的影响,这是一个巨大的优点。

如前所述,Web 组件由三个主要技术组成,而shadow DOM是其中的关键部分。但愿在阅读本文以后,你将更容易理解这三种技术是如何协同构建Web组件的。


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章


欢迎继续阅读本专栏其它高赞文章: