[Web翻译]影子DOM v1: 自足的Web组件

1,451 阅读20分钟

原文地址:developers.google.com/web/fundame…

原文作者:developers.google.com/web/resourc…

发表时间:

TL;DR

影子DOM消除了构建Web应用的脆性。脆性来自于HTML、CSS和JS的全局性。多年来,我们发明了大量的工具(BEM, CSS-ModuleOOCSS)来规避这些问题。例如,当你使用一个新的HTML id/class时,不知道它是否会与页面使用的现有名称冲突。微妙的bug悄然而至,CSS的特殊性成为一个巨大的问题(!重要的所有东西!),样式选择器生长失控,性能会受到影响。这个列表还在继续。

影子DOM解决了CSS和DOM的问题。它将范围样式引入到Web平台。在没有工具或命名惯例的情况下,你可以将CSS与标记捆绑在一起,隐藏实现细节,并在vanilla JavaScript中编写自包含的组件

介紹

**注:已经熟悉Shadow DOM了?**本文介绍的是新的Shadow DOM v1规范。如果你一直在使用Shadow DOM,你有可能熟悉Chrome 35中的v0版本,以及webcomponents.js polyfills。概念是一样的,但v1规范有重要的API差异。这也是所有主流浏览器都同意实现的版本,Safari、Chrome和Firefox都已经实现了。继续阅读以了解新的内容,或查看历史和浏览器支持部分以获取更多信息。

影子DOM是三个Web组件标准之一:HTML模板Shadow DOM自定义元素HTML Imports曾经是列表的一部分,但现在被认为是过时的

你不必编写使用影子DOM的Web组件。但是当你这样做的时候,你就可以利用它的优势(CSS作用域、DOM封装、组成),构建可重用的自定义元素,这些元素具有弹性、高度可配置性和极强的可重用性。如果说自定义元素是创建一个新的HTML(用JS API)的方式,那么影子DOM就是你提供其HTML和CSS的方式。这两个API结合在一起,使一个组件具有独立的HTML、CSS和JavaScript。

影子DOM被设计为构建组件式应用的工具。因此,它为Web开发中的常见问题带来了解决方案。

  • 孤立的DOM。一个组件的DOM是自足的(例如document.querySelector()不会返回组件的影子DOM中的节点)。
  • Scoped CSS。在影子DOM中定义的CSS会在其范围内。样式规则不会泄露出去,页面样式也不会渗入进来。
  • 组成。为你的组件设计一个声明式的,基于标记的API。
  • 简化CSS--Scoped DOM意味着你可以使用简单的CSS选择器,更通用的id/class名称,而不用担心命名冲突。
  • 生产力--以DOM的块状而不是一个大的(全局)页面来思考应用程序。

注意:虽然你可以在Web组件之外使用影子DOM API及其好处,但我只关注建立在自定义元素上的例子。我将在所有例子中使用自定义元素v1 API。

fancy-tabs演示

在本文中,我将引用一个演示组件(<fancy-tabs>)并引用其中的代码片段。如果你的浏览器支持API,你应该可以在下面看到它的实时演示。否则,请在Github上查看完整的源代码

rawgit.com/ebidel/2d2b…

在Github上查看源代码

什么是影子DOM?

DOM的背景

HTML是网络的动力,因为它易于使用。通过声明几个标签,你可以在几秒钟内编写出一个既有表现形式又有结构的页面。然而,HTML本身并不是那么有用。人类很容易理解基于文本的语言,但机器需要更多的东西。进入文档对象模型,或DOM。

当浏览器加载一个网页时,它会做很多有趣的事情。它所做的事情之一是将作者的HTML转化为一个实时文档。基本上,为了理解页面的结构,浏览器将HTML(静态文本字符串)解析为数据模型(对象/节点)。浏览器通过创建这些节点的树来保存HTML的层次结构:DOM。DOM的酷之处在于它是你的页面的实时表示。与我们编写的静态HTML不同,浏览器生成的节点包含了属性、方法,最重要的是......可以被程序操作!这就是为什么我们能够在DOM的基础上,对其进行修改。这就是为什么我们能够直接使用JavaScript创建DOM元素。

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

产生以下HTML标记。

<body>
  <header>
    <h1>Hello DOM</h1>
  </header>
</body>

这些都是很好的。那么影子DOM是什么鬼?

DOM...在阴影里

影子DOM只是普通的DOM,有两个不同之处:1)如何创建/使用它,2)它与页面其他部分的关系。1)它的创建/使用方式和2)它与页面其他部分的关系。通常情况下,你创建DOM节点,并将其作为另一个元素的子元素来附加。使用影子DOM,你创建了一个范围内的DOM树,它与元素相连,但与它的实际子元素分开。这个范围内的子树被称为影子树。它所连接的元素就是它的影子主机。你在影子中添加的任何东西都会成为宿主元素的本地元素,包括<style>。这就是影子DOM如何实现CSS样式范围化的。

创建影子DOM

影子根是附着在 "宿主 "元素上的文档片段。附加影子根的行为就是元素获得影子DOM的方式。要为一个元素创建影子DOM,调用 element.attachShadow()

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

我使用.innerHTML来填充影子根,但你也可以使用其他DOM API。这就是网络。我们有选择的余地。

规范定义了一个不能承载影子树的元素列表。有几个原因可以让一个元素出现在列表中。

  • 浏览器已经为该元素托管了它自己的内部影子DOM(<textarea>, <input>).
  • 元素托管影子DOM(<img>)没有意义。

例如,这样做是行不通的。

document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.

为自定义元素创建影子DOM

影子DOM在创建自定义元素时特别有用。使用影子DOM将一个元素的HTML、CSS和JS分门别类,从而产生一个 "网络组件"。

例子 - 一个自定义元素将影子DOM附加到自己身上,封装了它的DOM/CSS。

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
  constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
      <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
      <div id="tabs">...</div>
      <div id="panels">...</div>
    `;
  }
  ...
});

这里有几个有趣的事情。首先是当<fancy-tabs>的实例被创建时,自定义元素会创建它自己的影子DOM。这是在constructor()中完成的。第二,因为我们正在创建一个影子根,所以<style>里面的CSS规则将被覆盖到<fancy-tabs>

注意:当你尝试运行这个例子时,你可能会注意到没有任何渲染。用户的标记似乎消失了。这是因为元素的影子DOM代替了它的子元素被渲染。如果你想显示这些子元素,你需要在影子DOM中放置一个<slot>元素来告诉浏览器在哪里渲染它们。稍后会有更多的介绍。

组成和槽

组成是影子DOM中最不被理解的特性之一,但它可以说是最重要的。

在我们的Web开发世界中,组成是我们如何构建应用程序,从HTML中声明出来。不同的构件(<div>s、<header>s、<form>s、<input>s)组合在一起形成应用程序。其中一些标签甚至可以相互配合。组成是<select><details><form><video>等本地元素如此灵活的原因。这些标签中的每一个都接受某些 HTML 作为子元素,并对它们进行一些特殊的处理。例如,<select>知道如何将<option><optgroup>呈现为下拉和多选择部件。<details>元素将<summary>渲染成一个可展开的箭头。甚至<video>也知道如何处理某些子元素。<source>元素不会被渲染,但它们会影响视频的行为。多么神奇啊

术语:光DOM与影DOM对比

影子DOM构成在Web开发中引入了一堆新的基础知识。在进入正题之前,让我们先把一些术语标准化,以便我们说的是相同的行话。

轻量级DOM

你的组件的用户所写的标记。这个DOM位于组件的影子DOM之外。它是元素的实际子代。

<better-button>
  <!-- the image and span are better-button's light DOM -->
  <img src="gear.svg" slot="icon">
  <span>Settings</span>
</better-button>

影子DOM

一个组件作者编写的DOM,它是本地的,定义了它的内部结构,范围化的CSS,并封装了你的实现细节。影子DOM是组件的局部,它定义了组件的内部结构、范围化的CSS,并封装了你的实现细节。它也可以定义如何渲染由你的组件的消费者撰写的标记。

#shadow-root
  <style>...</style>
  <slot name="icon"></slot>
  <span id="wrapper">
    <slot>Button</slot>
  </span>

扁平化的DOM树

浏览器将用户的光DOM分配到你的阴影DOM中,渲染出最终产品的结果。扁平化的树是您最终在DevTools中看到的,也是页面上呈现的。

<better-button>
  #shadow-root
    <style>...</style>
    <slot name="icon">
      <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
      <slot>
        <span>Settings</span>
      </slot>
    </span>
</better-button>

<slot>元素

Shadow DOM使用<slot>元素将不同的DOM树组合在一起。Slots是你的组件内部的占位符,用户可以用他们自己的标记来填充。通过定义一个或多个槽,你邀请外部标记在你的组件的影子DOM中呈现。本质上,你在说 "在这里渲染用户的标记"。

注意: Slots是为一个web组件创建 "声明式API "的一种方式。它们混入用户的DOM来帮助渲染整个组件,从而将不同的DOM树组合在一起

当一个<slot>邀请元素进入时,它们被允许 "穿越 "影子DOM边界。这些元素被称为分布式节点。从概念上讲,分布式节点看起来有点怪异。槽并不实际地移动DOM,而是在影子DOM内部的另一个位置渲染它。

一个组件可以在它的影子DOM中定义零个或多个槽。槽可以是空的,也可以提供后备内容。如果用户没有提供轻量级的DOM内容,槽会渲染它的后备内容。

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
  <h2>Title</h2>
  <summary>Description text</summary>
</slot>

你也可以创建命名槽。命名槽是你的影子DOM中的特定孔洞,用户可以通过名字来引用。

例如--<fancy-tabs>的影子DOM中的槽。

#shadow-root
  <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
  </div>
  <div id="panels">
    <slot id="panelsSlot"></slot>
  </div>

组件用户声明<fancy-tabs>是这样的。

<fancy-tabs>
  <button slot="title">Title</button>
  <button slot="title" selected>Title 2</button>
  <button slot="title">Title 3</button>
  <section>content panel 1</section>
  <section>content panel 2</section>
  <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
  <h2 slot="title">Title</h2>
  <section>content panel 1</section>
  <h2 slot="title" selected>Title 2</h2>
  <section>content panel 2</section>
  <h2 slot="title">Title 3</h2>
  <section>content panel 3</section>
</fancy-tabs>

如果你想知道,扁平化的树看起来是这样的。

<fancy-tabs>
  #shadow-root
    <div id="tabs">
      <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
      </slot>
    </div>
    <div id="panels">
      <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
      </slot>
    </div>
</fancy-tabs>

请注意我们的组件能够处理不同的配置,但扁平化的DOM树保持不变。我们也可以从<button>切换到<h2>。这个组件是为了处理不同类型的子代而设计的...就像<select>一样。

样式设计

风格化Web组件有很多选择。一个使用影子DOM的组件可以由主页面进行样式设计,定义自己的样式,或者提供钩子(以CSS自定义属性的形式)让用户覆盖默认值。

组件定义的样式

毫无疑问,影子DOM最有用的功能是scoped CSS

  • 来自外部页面的CSS选择器不会应用于你的组件内部。
  • 在组件内部定义的样式不会外泄。它们的作用范围是在主机元素上。

在影子DOM中使用的CSS选择器会在本地应用到你的组件中。实际上,这意味着我们可以再次使用通用的id/class名称,而不用担心在页面的其他地方发生冲突。简单的CSS选择器是影子DOM内部的最佳实践。它们对性能也有好处。

例子--在影子根中定义的样式是本地的。

#shadow-root
  <style>
    #panels {
      box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
      background: white;
      ...
    }
    #tabs {
      display: inline-flex;
      ...
    }
  </style>
  <div id="tabs">
    ...
  </div>
  <div id="panels">
    ...
  </div>

样式表也是影子树的范围。

#shadow-root
  <link rel="stylesheet" href="styles.css">
  <div id="tabs">
    ...
  </div>
  <div id="panels">
    ...
  </div>

有没有想过,当你添加multiple属性时,<select>元素是如何渲染一个多选择部件(而不是下拉)的。

<select multiple>
  <option selected>Do</option>
  <option>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select>能够根据你在它上面声明的属性来改变自己的风格。Web组件也可以通过使用:host选择器来改变自己的风格。

例子--一个组件的样式

<style>
:host {
  display: block; /* by default, custom elements are display: inline */
  contain: content; /* CSS containment FTW. */
}
</style>

:host的一个问题是,父页面中的规则比元素中定义的:host规则有更高的特殊性。也就是说,外部样式获胜。这使得用户可以从外部覆盖你的顶层样式。另外,:host只在影子根的上下文中起作用,所以你不能在影子DOM之外使用它。

:host(<selector>)的功能形式允许你在host与<selector>匹配的情况下将其作为目标。这是一个很好的方式,让你的组件封装对用户交互、状态或基于主机的内部节点的样式做出反应的行为。

<style>
:host {
  opacity: 0.4;
  will-change: opacity;
  transition: opacity 300ms ease-in-out;
}
:host(:hover) {
  opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
  background: grey;
  pointer-events: none;
  opacity: 0.4;
}
:host(.blue) {
  color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
  color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

基于上下文的造型

:host-context(<selector>)如果组件或它的任何一个祖先匹配<selector>,则匹配该组件。一个常见的用法是根据组件的周围环境来做主题。例如,许多人通过将一个类应用于<html><body>来实现主题化。

<body class="darktheme">
  <fancy-tabs>
    ...
  </fancy-tabs>
</body>

:host-context(.darktheme)会在<fancy-tabs>.darktheme的后裔时对其进行样式设计。

:host-context(.darktheme) {
  color: white;
  background: black;
}

:host-context()对主题设计很有用,但一个更好的方法是使用CSS自定义属性创建样式钩子

对分布式节点进行样式设计

::slotted(<compound-selector>)匹配分布在<slot>中的节点。

比方说,我们已经创建了一个名字徽章组件。

<name-badge>
  <h2>Eric Bidelman</h2>
  <span class="title">
    Digital Jedi, <span class="company">Google</span>
  </span>
</name-badge>

组件的影子DOM可以为用户的<h2>.title设置样式。

<style>
::slotted(h2) {
  margin: 0;
  font-weight: 300;
  color: red;
}
::slotted(.title) {
   color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
  text-transform: uppercase;
}
*/
</style>
<slot></slot>

如果你还记得以前,<slot>不会移动用户的轻量DOM。当节点被分发到<slot>中时,<slot>会渲染它们的DOM,但节点实际上是保持不变的。分布前应用的样式在分布后继续应用。然而,当轻量级DOM被分布时,它可以采用额外的样式(那些由阴影DOM定义的样式)。

另一个更深入的例子来自<fancy-tabs>

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
  <style>
    #panels {
      box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
      background: white;
      border-radius: 3px;
      padding: 16px;
      height: 250px;
      overflow: auto;
    }
    #tabs {
      display: inline-flex;
      -webkit-user-select: none;
      user-select: none;
    }
    #tabsSlot::slotted(*) {
      font: 400 16px/22px 'Roboto';
      padding: 16px 8px;
      ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
      font-weight: 600;
      background: white;
      box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
      display: none;
    }
  </style>
  <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
  </div>
  <div id="panels">
    <slot id="panelsSlot"></slot>
  </div>
`;

在这个例子中,有两个槽:一个是用于标签标题的命名槽,另一个是用于标签面板内容的槽。当用户选择一个tab时,我们将他们的选择加粗并显示其面板。这是通过选择具有selected属性的分布式节点来实现的。自定义元素的JS(这里没有显示)会在正确的时间添加该属性。

从外部对组件进行样式设计

有几种方法可以从外部对一个组件进行样式设计,最简单的方法是使用标签名作为选择器。最简单的方法是使用标签名作为选择器。

fancy-tabs {
  width: 500px;
  color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
  box-shadow: 0 3px 3px #ccc;
}

外部样式总是胜过影子DOM中定义的样式。例如,如果用户写选择器fancy-tabs { width: 500px; },它将胜过组件的规则: :host { width: 650px;}

样式化组件本身只能让你走到这一步。但如果你想对组件的内部进行样式设计,会发生什么呢?为此,我们需要CSS自定义属性。

使用CSS自定义属性创建样式钩子

如果组件的作者提供了使用CSS自定义属性的样式钩子,用户可以调整内部样式。在概念上,这个想法与<slot>类似。你创建了 "样式占位符 "供用户覆盖。

例如--<fancy-tabs>允许用户覆盖背景颜色。

<!-- main page -->
<style>
  fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
  }
</style>
<fancy-tabs background>...</fancy-tabs>

在它的影子DOM里面。

:host([background]) {
  background: var(--fancy-tabs-bg, #9E9E9E);
  border-radius: 10px;
  padding: 10px;
}

在这种情况下,由于用户提供了背景值,组件将使用黑色作为背景值,否则,它将默认为#9E9E9E

注意:作为组件作者,你有责任让开发者知道他们可以使用的CSS自定义属性。把它看作是你的组件公共界面的一部分。确保将样式钩子记录下来

进阶主题

创造封闭的影子根(应避免)

影子DOM还有另一种风味,叫做 "封闭 "模式。当你创建一个封闭的影子树时,外部的JavaScript将无法访问组件的内部DOM。这类似于<video>这样的本地元素的工作方式。JavaScript无法访问<video>的影子DOM,因为浏览器使用封闭模式的影子根来实现它。

示例--创建一个封闭的影子树。

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

其他API也会受到封闭模式的影响。

  • Element.assignedSlot / TextNode.assignedSlot返回null。
  • Event.compositionPath(),对于与影子DOM内部元素相关联的事件,返回[] 。

注意:封闭的影子根不是很有用。有些开发者会把封闭模式看作是一种人为的安全功能。但我们要清楚,这是一个安全功能。封闭模式只是防止外部JS钻入元素的内部DOM。

下面是我对为什么你永远不应该用{mode: 'closed'}来创建web组件的总结。

  1. 人为的安全感。没有什么能阻止攻击者劫持Element.prototype.attachShadow

  2. 封闭模式阻止了你的自定义元素代码访问它自己的影子DOM。这是完全失败的。相反,如果你想使用querySelector()之类的东西,你就得藏起一个引用,以便以后使用。这完全违背了封闭模式的初衷

customElements.define('x-element', class extends HTMLElement {
  constructor() {
    super(); // always call super() first in the constructor.
    this._shadowRoot = this.attachShadow({mode: 'closed'});
    this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
  }
  connectedCallback() {
    // When creating closed shadow trees, you'll need to stash the shadow root
    // for later if you want to use it again. Kinda pointless.
    const wrapper = this._shadowRoot.querySelector('.wrapper');
  }
  ...
});
  1. 封闭模式会使你的组件对终端用户的灵活性降低。当你构建Web组件时,总有一天你会忘记添加一个功能。一个配置选项。一个用户想要的用例。一个常见的例子是忘记为内部节点加入足够的样式钩子。在封闭模式下,用户没有办法覆盖默认值和调整样式。能够访问组件的内部是超级有用的。最终,如果你的组件不能满足用户的需求,用户会叉开你的组件,找到另一个,或者创建自己的组件 :(

在JS中使用插槽

影子DOM API提供了处理槽和分布式节点的实用工具。当编写一个自定义元素时,这些工具非常方便。

槽变化事件

当一个槽的分布式节点发生变化时,slotchange事件就会触发。例如,如果用户从light DOM中添加/删除子节点。

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
  console.log('light dom children changed!');
});

注意:当组件的实例第一次被初始化时,slotchange不会被触发。

要监视light DOM的其他类型的变化,你可以在你的元素的构造函数中设置一个MutationObserver

槽中正在渲染哪些元素?

有时候,知道什么元素与一个槽相关联是很有用的。调用slot.assignedNodes()来查找槽中正在渲染的元素。{flatten: true}选项还将返回一个槽的后备内容(如果没有节点被分发)。

举个例子,假设你的影子DOM看起来像这样。

<slot><b>fallback content</b></slot>
使用方法呼叫结果
<我的组件>组件文本</我的组件>slot.assignedNodes();[组件文本]
<我的组件></我的组件>slot.assignedNodes();[]
<我的组件></我的组件>slot.assignedNodes({flatten: true});[回退内容]

一个元素被分配到哪个槽位?

element.assignedSlot告诉你你的元素被分配到哪个组件槽中。

影子DOM事件模型

当一个事件从影子DOM中冒出来时,它的目标会被调整以保持影子DOM提供的封装。也就是说,事件会被重新定位,使其看起来像是来自组件,而不是来自影子DOM中的内部元素。有些事件甚至不会从影子DOM中传播出去。

跨越影子边界的事件有。

  • 焦点事件:bluefocusfocusinfocusout
  • 鼠标事件:clickdblclickmousedownmouseentermousemove等。
  • 滚轮事件:wheel
  • 输入事件:beforeinputinput
  • 键盘事件:keydownkeyup
  • 组成事件:compositionstartcompositionupdatecompositionend
  • 拖动事件:dragstartdragdragenddrop等。

技巧

如果影子树是开放的,调用event.compositionPath()将返回一个事件所经过的节点数组。

使用自定义事件

自定义DOM事件在影子树中的内部节点上发生,不会从影子边界中冒出,除非该事件是使用composition: true标志创建的。

// Inside <fancy-tab> custom element class definition:
selectTab() {
  const tabs = this.shadowRoot.querySelector('#tabs');
  tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

如果composed:false(默认),消费者将无法监听你的影子根之外的事件。

<fancy-tabs></fancy-tabs>
<script>
  const tabs = document.querySelector('fancy-tabs');
  tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
  });
</script>

处理焦点

如果你还记得影子DOM的事件模型,在影子DOM内部发射的事件会被调整为看起来像是来自托管元素。例如,让我们假设你点击一个影子根里面的<input>

<x-focus>
  #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

focus事件看起来会像来自<x-focus>,而不是<input>。同样,document.activeElement将是<x-focus>。如果影子根节点是以mode:'open'创建的(见封闭模式),你还可以访问获得焦点的内部节点。

document.activeElement.shadowRoot.activeElement // only works with open mode.

如果有多层次的影子DOM在起作用(比如一个自定义元素在另一个自定义元素中),你需要递归地钻进影子根以找到activeElement

function deepActiveElement() {
  let a = document.activeElement;
  while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
  }
  return a;
}

另一个聚焦的选项是 delegatesFocus: true 选项,它扩展了阴影树内元素的聚焦行为。

  • 如果你点击影子DOM内的一个节点,而该节点不是一个可聚焦的区域,那么第一个可聚焦的区域就会成为焦点。
  • 当影子DOM内的一个节点获得焦点时,除了焦点元素外,:focus也适用于主机。

例子 - delegatesFocus: true如何改变焦点行为。

<style>
  :focus {
    outline: 2px solid red;
  }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
  constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
      <style>
        :host {
          display: flex;
          border: 1px dotted black;
          padding: 16px;
        }
        :focus {
          outline: 2px solid blue;
        }
      </style>
      <div>Clickable Shadow DOM text</div>
      <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
      console.log('Active element (inside shadow dom):',
                  this.shadowRoot.activeElement);
    });
  }
});
</script>

结果

上面是当<x-focus>被聚焦(用户点击、tabbed into、focus()等),"可点击的阴影DOM文本 "被点击,或者内部<input>被聚焦(包括autofocus)时的结果。

如果你要设置 delegatesFocus: false,你会看到的是这样的情况。

delegatesFocus: false,内部<input>被聚焦。

delegatesFocus: false<x-focus>获得焦点(例如,它的tabindex="0")。

delegatesFocus: false,"可点击的影子DOM文本 "被点击(或元素的影子DOM中的其他空区域被点击)。

提示和技巧

这些年来,我已经学会了一两件关于编写Web组件的事情。我想你会发现其中一些技巧对编写组件和调试影子DOM很有用。

使用CSS containment

通常情况下,一个web组件的布局/样式/画法是相当自如的。在:host中使用CSS包含,以达到敷衍取胜的目的。

<style>
:host {
  display: block;
  contain: content; /* Boom. CSS containment FTW. */
}
</style>

重置可继承的样式

可继承的样式(backgroundcolorfontline-height等)继续在影子DOM中继承。也就是说,它们默认会穿透影子DOM的边界。如果你想从新开始,使用all: initial;来重置可继承的样式到它们的初始值,当它们穿过影子边界时。

<style>
  div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
  }
</style>

<div>
  <p>I'm outside the element (big/white)</p>
  <my-element>Light DOM content is also affected.</my-element>
  <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
  <style>
    :host {
      all: initial; /* 1st rule so subsequent properties are reset. */
      display: block;
      background: white;
    }
  </style>
  <p>my-element: all CSS properties are reset to their
     initial value using <code>all: initial</code>.</p>
  <slot></slot>
`;
</script>

/web/fundamentals/web-components/shadowdom_3793e854e7da886fc2e6ba08d8a00109949ca6f3c09bb10ef45dbcb84b1ebe8c.frame

查找页面使用的所有自定义元素

有时,找到页面上使用的自定义元素很有用。要做到这一点,你需要递归遍历页面上使用的所有元素的影子DOM。

const allCustomElements = [];

function isCustomElement(el) {
  const isAttr = el.getAttribute('is');
  // Check for <super-button> and <button is="super-button">.
  return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
  for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
      allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
      findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
  }
}

findAllCustomElements(document.querySelectorAll('*'));

<template>中创建元素

我们可以使用一个声明式的<template>来代替使用.innerHTML来填充影子根。模板是声明Web组件结构的理想占位符。

请参阅 "自定义元素:构建可重用的Web组件 "中的例子。

历史和浏览器支持

如果你在过去的几年里一直在关注网络组件,你就会知道Chrome 35+/Opera已经推出了旧版本的影子DOM一段时间了。Blink将在一段时间内继续并行支持这两个版本。v0规范提供了一个不同的方法来创建影子根(element.createShadowRoot而不是v1的element.attachShadow)。调用旧方法会继续创建一个具有v0语义的影子根,所以现有的v0代码不会被破坏。

如果你碰巧对旧的v0规范感兴趣,可以看看html5rocks的文章。1, 2, 3. 影子DOM v0和v1之间的差异也有很好的对比

浏览器支持

Shadow DOM v1已在Chrome 53(状态)、Opera 40、Safari 10和Firefox 63中发货。Edge已经开始开发

要功能检测影子DOM,请检查 attachShadow 的存在。

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

在浏览器广泛支持之前,shadydomshadycss polyfills给你v1的功能。Shady DOM模仿了Shadow DOM和shadycss polyfills CSS自定义属性的DOM范围和原生API提供的样式范围。

安装polyfills。

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

使用polyfills。

function loadScript(src) {
 return new Promise(function(resolve, reject) {
   const script = document.createElement('script');
   script.async = true;
   script.src = src;
   script.onload = resolve;
   script.onerror = reject;
   document.head.appendChild(script);
 });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
  loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
      // Polyfills loaded.
    });
} else {
  // Native shadow dom v1 support. Go to go!
}

关于如何垫片/范围你的风格的说明,请参阅github.com/webcomponen…

结束语

有史以来第一次,我们有了一个API基元,它可以进行适当的CSS作用域、DOM作用域,并具有真正的组成。结合其他Web组件API,如自定义元素,影子DOM提供了一种方法来编写真正的封装组件,而不需要黑客或使用旧的包袱,如<iframe>s。

不要误会我的意思。影子DOM当然是一个复杂的野兽!但它是一个值得一试的野兽。但它是一个值得学习的野兽。花一些时间来学习它。学习它并提出问题

进一步阅读

常见问题

今天可以使用Shadow DOM v1吗?

使用polyfill,可以。请看浏览器支持。

影子DOM提供什么安全功能?

影子DOM并不是一个安全功能,它是一个轻量级的工具,用于限定CSS和隐藏组件中的DOM树。它是一个轻量级的工具,用于限定CSS和隐藏组件中的DOM树。如果你想要一个真正的安全边界,使用<iframe>

一个web组件必须使用影子DOM吗?

不是的!你不需要创建web组件。你不需要创建使用影子DOM的web组件。然而,编写使用影子DOM的自定义元素意味着你可以利用CSS作用域、DOM封装和合成等功能。

开放式影子根和封闭式影子根的区别是什么?

请看封闭式影子根


www.deepl.com 翻译