Shadow DOM 201

CSS and Styling

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.

这篇文章将继续讨论更多有关 Shadow DOM 的精彩内容。 文中内容完全基于 Shadow DOM 101 中讨论的概念,如果你在找入门文章,那么先去看看那一篇。

介绍

首先得承认,没有样式的标记谈不上漂亮。好在 Web 组件背后的那群聪明家伙们 早就预见了这一点。所以当我们在样式化 shadow 树中的内容时便拥有了众多选择。

在 Chrome 中,开启 about:flags 页面中的"Enable experimental Web Platform features"就可以体验本文介绍的所有内容。

样式封装

Shadow DOM 的一个核心特色叫做 shadow 边界(shadow boundary)。它有不少实用属性,其中最棒的一个是免费提供了样式封装。换句话说:

Shadow DOM 中定义的 CSS 样式只在 ShadowRoot 下生效。这意味着样式被封装了起来。

下面是一个示例。如果运行正常并且你的浏览器支持 Shadow DOM (它支持!),你会看到 "Shadow DOM"。

<div><h3>Light DOM</h3></div>
<script>
var root = document.querySelector('div').createShadowRoot();
root.innerHTML = '<style>h3{ color: red; }</style>' +
                 '<h3>Shadow DOM</h3>';
</script>

Light DOM

从这个示例中能观察到两个有趣的结果:

  • 页面中有 额外的 h3 元素,但被 h3 选择器所匹配并且样式为红色的只有在 ShadowRoot 的那个元素。再重申一遍,Shadow DOM 中的样式默认是有范围的。
  • 页面中定义的其他关于 h3 的样式并没有影响我们的内容。原因在于选择器无法越过 shadow 边界

这里包含的寓意是什么?我们将样式从外部世界中封装了起来。感谢 Shadow DOM!

样式化宿主元素(host element)

注意: Shadow DOM 规范使用 :host() 取代了旧的 :host

:host 允许你选择并样式化 shadow 树所寄宿的元素:

<button class="red">My Button</button>
<script>
var button = document.querySelector('button');
var root = button.createShadowRoot();
root.innerHTML = '<style>' +
    ':host { text-transform: uppercase; }' +
    '</style>' +
    '<content></content>';
</script>

在这里需要注意的是:父页面定义的样式规则的特异性要高于元素中定义的 :host 规则,但低于宿主元素上 style 特性定义的规则。 :host 仅在 ShadowRoot 的范围内生效,无法用它来影响 Shadow DOM 外的元素。

对用户状态的反馈

:host 的一个常见的使用场景是:你创建了一个自定义元素,希望对不同的用户状态(:hover,:focus,:active,等等)产生反馈。

<style>
:host {
  opacity: 0.4;
  transition: opacity 420ms ease-in-out;
}
:host:hover {
  opacity: 1;
}
:host:active {
  position: relative;
  top: 3px;
  left: 3px;
}
</style>

主题化一个元素

:host 的另一个使用场景是主题化。:host(<selector>) 接受一个选择器参数,当宿主元素或它的任意祖先元素和该选择器匹配,那么宿主元素就会匹配。

例子 - 大多数人在主题化时会为 <html><body> 应用一个类:

<body class="different">
  <x-foo></x-foo>
</body>

使用 :host(.different),仅当宿主元素是 .different 的后代元素时,<x-foo> 才会被样式化:

:host(.different) {
  color: red;
}

例子 - 只有当宿主元素本身拥有该类时才会匹配(例如 <x-foo class="different"></x-foo>):

:host(.different:host) {
  ...
}

在一个 shadow root 内支持多种宿主类型

:host 还有一种使用场景,那就是你创建了一个主题库,想在相同的 Shadow DOM 内为不同类型的宿主元素提供样式化。

:host(x-foo:host) {
  /* 当宿主是 <x-foo> 元素时生效。 */
}

:host(x-bar:host) {
  /* 当宿主是 <x-bar> 元素时生效。 */
}

:host(div) {  {
  /* 当宿主或宿主的祖先元素是 <div> 元素时生效。 */
}

^ 和 ^^ 选择器

^^ (Cat) 和 ^ (Hat) 选择器就好比是一把 CSS 权威的斩首剑(Vorpal sword)。 它们允许跨越 Shadow DOM 的边界并对 shadow 树内的元素样式化。

^ 连接符

^ 连接符等价于后代选择器(例如 div p {...}),只不过它能跨越一个 shadow 边界。这可以使你便捷的从 shadow 树中选择一个元素:

<style>
  #host ^ span {
    color: red;
  }
</style>

<div id="host">
  <span>Light DOM</span>
</div>

<script>
  var host = document.querySelector('div');
  var root = host.createShadowRoot();
  root.innerHTML = "<span>Shadow DOM</span>" +
                   "<content></content>";
</script>
Light DOM

例子 (自定义元素) - <x-tabs> 在它的 Shadow DOM 中拥有一个子元素 <x-panel>。每个 panel 中都寄宿了各自的包含了 h2 标题的 Shadow 树,若想在主页面修改这些标题的样式,使用:

x-tabs ^ x-panel ^ h2 {
  ...
}

^^ 连接符

^^ 连接符与 ^ 类似,但功能更为强大。形如 A ^^ B 的选择器会忽略所有 shadow 边界从而匹配任意后代元素 B。简而言之, ^^ 能够跨越任意数量的 shadow 边界

^^ 连接符特别适用于自定义元素(通常会有多级 Shadow DOM)。比如说嵌套多个自定义元素(每个都拥有独自的 Shadow DOM)或使用 <shadow> 来继承一个元素从而创建新的元素。

例子 (自定义元素) - 选择 <x-tabs> 的所有后代 <x-panel>元素,忽略一切 Shadow 边界:

x-tabs ^^ x-panel {
  ...
}

使用 querySelector()

正如 .shadowRoot 使 shadow 树支持 DOM 遍历,连接符使 shadow 树支持选择器遍历。相比书写令人抓狂的嵌套链,你可以仅用一条语句:

// 无聊。
document.querySelector('x-tabs').shadowRoot
        .querySelector('x-panel').shadowRoot
        .querySelector('#foo');

// 有趣。
document.querySelector('x-tabs ^ x-panel ^ #foo');

样式化原生元素

修改原生 HTML 控件的样式是个挑战。多数人选择了放弃转而自己实现。现在我们有了 ^ 和 ^^,样式化 Shadow DOM 中的元素再也不是难题。比较明显的例子是 <video><input>

video ^ input[type="range"] {
  background: hotpink;
}
^ 和 ^^ 是否破坏了样式的封装?实际上,Shadow DOM 可以防止外部无意的修改样式,但它并非是防弹衣。应该允许开发者有意的 对 Shadow 树的内部设置样式……如果他们知道自己在做什么的话。拥有更多的控制对于灵活性,主题化,元素的重用性都是好事。

生成样式钩子

可定制是有用的。在某些情况下,你可能需要在 Shadow 的样式屏障上开几个孔,为其他人设置样式提前埋好钩子。

使用 ^ 和 ^^

There's a lot of power behind ^^ 的威力巨大。它为组件作者提供了一条途径:为指定的单个元素设置样式,或为多个元素修改主题。

例子 - 为所有拥有 .library-theme 类的元素设置样式,忽略所有 shadow 树:

body ^^ .library-theme {
  ...
}

使用 CSS 变量

在 Chrome 的 about:flags 页面中开启 "Enable experimental Web Platform features" 即可体验 CSS 变量。

创建主题化钩子的一个强大方法是使用 CSS 变量。本质上就是创建"样式占位符"来等待其他人填充。

想象一下,当一个自定义元素的作者在 Shadow DOM 中标记出变量的占位符。其中一个用于样式化内部按钮的字体,另一个用于样式化它的颜色:

button {
  color: var(--button-text-color, pink); /* default color will be pink */
  font-family: var(--button-font);
}

然后,元素的使用者按照自己的喜好定义了这些值。很可能是为了和他自己页面中非常酷的漫画字体主题(Comic Sans theme)相搭配:

#host {
  --button-text-color: green;
  --button-font: "Comic Sans MS", "Comic Sans", cursive;
}

由于 CSS 变量的这种继承方式,使得这些工作完成的很出色!完整的代码如下:

<style>
  #host {
    --button-text-color: green;
    --button-font: "Comic Sans MS", "Comic Sans", cursive;
  }
</style>
<div id="host">Host node</div>
<script>
var root = document.querySelector('#host').createShadowRoot();
root.innerHTML = '<style>' +
    'button {' +
      'color: var(--button-text-color, pink);' +
      'font-family: var(--button-font);' +
    '}' +
    '</style>' +
    '<content></content>';
</script>
我已经不止一次的在文中提到自定义元素。目前你只需要了解,Shadow DOM为它们的结构基础提供样式化和 DOM 封装即可。这里介绍的都是样式化自定义元素的概念。

重置样式

诸如 font,color,还有 line-height 这些可继承样式仍然会影响 Shadow DOM 中的元素。然而出于最大灵活性的考虑,Shadow DOM 为我们提供了 resetStyleInheritance 属性来控制在 shadow 边界能发生什么。当你创建一个新的组件时,可以把它当做从零开始的一个方法。

resetStyleInheritance

  • false - 默认。可继承 CSS 属性仍然能够继承。
  • true - 在边界处将可继承属性重置为 initial

以下这个例子表明了修改 resetStyleInheritance 是如何影响到 shadow 树的:

<div>
  <h3>Light DOM</h3>
</div>

<script>
var root = document.querySelector('div').createShadowRoot();
root.resetStyleInheritance = false;
root.innerHTML = '<style>h3{ color: red; }</style>' +
                 '<h3>Shadow DOM</h3>' +
                 '<content select="h3"></content>';
</script>

Light DOM

DevTools inherited properties

理解 .resetStyleInheritance 倒有些棘手,主要是因为它只影响那些能继承的 CSS 属性。它的含义是:当你在页面和 ShadowRoot 之间的边界处查找用于继承的属性时,不要继承宿主上的属性值,而应该使用 initial 值来代替(按照 CSS 规范)。

如果你拿不准哪些属性在 CSS 中可以继承,查看这个列表 或在开发者工具的元素面板中切换 "Show inherited" 复选框。

样式化分布式节点

.resetStyleInheritance 严格的影响那些定义在 Shadow DOM 的节点的样式化行为。

分布式节点则是另一码事。<content> 元素允许你从 Light DOM 中选择节点并将它们渲染到 Shadow DOM 中预先定义的位置。从逻辑上讲,它们并不在 Shadow DOM 中;它们是宿主元素的子元素,在"渲染时期"混入对应的位置。

自然的,分布式节点从它们所在的文档(宿主元素的所在文档)获得样式。不过有个例外,那就是它们还可能从混入的地方(在 Shadow DOM 中)获得额外样式。

::content 伪元素

注意: Shadow DOM 规范使用 ::content 替换了旧的 ::distributed()

如果分布式节点是宿主元素的子元素,那么我们如何在 Shadow DOM 内部来指定它们呢?答案就是使用 CSS ::content 伪元素。这是穿过插入点(insertion point)来指定节点的方式。比如说:

::content > h3 用来样式化任意穿过插入点的 h3 标签。

让我们来看一个简单的例子:

<div>
  <h3>Light DOM</h3>
  <section>
    <div>I'm not underlined</div>
    <p>I'm underlined in Shadow DOM!</p>
  </section>
</div>

<script>
var div = document.querySelector('div');
var root = div.createShadowRoot();
root.innerHTML = '\
    <style>\
      h3 { color: red; }\
      content[select="h3"]::content > h3 {\
        color: green;\
      }\
      ::content section p {\
        text-decoration: underline;\
      }\
    </style>\
    <h3>Shadow DOM</h3>\
    <content select="h3"></content>\
    <content select="section"></content>';
</script>

Light DOM

I'm not underlined

I'm underlined in Shadow DOM!

你会在下方看到 "Shadow DOM" 和 "Light DOM"。要注意的是 "Light DOM" 仍然会保留从该页面中获得的样式(例如 margins)。

在插入点重置样式

当创建一个 ShadowRoot 后,你便可以选择重置继承的样式。 <content><shadow> 插入点同样可以选择。当使用这些元素时,要么在 JS 中设置 .resetStyleInheritance, 或是在元素上设置 reset-style-inheritance boolean 特性。

  • 对于一个 ShadowRoot 或 <shadow> 插入点:reset-style-inheritance 意味着可继承的 CSS 属性在宿主元素处被设置为 initial,此时这些属性还没有对 shadow 中的内容生效。该位置称为上边界(upper boundary)

  • 对于 <content> 插入点:reset-style-inheritance 意味着在宿主的子元素分发到插入点之前,将可继承的 CSS 属性设置为 initial该位置称为下边界(lower boundary)

牢记:在 host 所在文档中定义的样式依然会对它们指定的节点生效,即使这些节点被分发"进" Shadow DOM 中。 进入一个插入点并不会改变那些已经生效的属性。

总结

作为自定义元素的作者,我们拥有众多控制内容外观和感觉的选项。Shadow DOM 为这个美丽新世界奠定了基础。

Shadow DOM 为我们提供了范围受限的样式封装,还有控制外部世界对内部样式尽可能多(或尽可能少)影响的手段。 通过定义自定义伪元素或包含 CSS 变量的占位符,作者能够提供对第三方友好的样式化钩子使得他们能够对内容进一步的定制。 总而言之,web 作者对于内容的展现拥有绝对的控制权。

感谢 Dominic CooneyDimitri Glazkov 对该教程内容的审阅。

Comments

0