Shadow DOM 综述

提醒:建议使用 Chrome 33 或者更高版本的 Chrome。

Web Components 系列主要由自定义元素(Custom Elements)HTML 引入(HTML Imports)影子 DOM(shadow DOM) 组成,而 Shadow DOM 无疑是当中的重中之重。本文对下面翻译的几篇文章进行综述,总结了 Shadow DOM 术语梳理和结构关系。

术语标注


  • 影子 DOM(shadow DOM):是一种依附于文档原有节点的子 DOM,具有封装性
  • 光明 DOM(light DOM):就是原生的 DOM,为了与影子 DOM 区别采用的名词
  • 影子树(shadow trees):特指存在于 shadow DOM 中的节点结构
  • 影子宿主(shadow host):特指 shadow DOM 所依附的影子宿主,是存在于原生 DOM 中的节点
  • 影子根(shadow root):指 shadow DOM 的根节点
  • 插入点(insertion point):指 shadow DOM 中的 <content> 标签
  • 贪心插入点(greedy insertion point):指使用通配符选择器进行匹配的插入点
  • 影子边界(shadow boundary):指对影子 DOM 与光明 DOM 进行的样式隔离,两者中的样式不会相互影响。
  • 分布节点(distributed nodes):指原本存在于光明 DOM 结构中,被 <content> 标签添加到影子 DOM 中的节点。

结构关系

影子 DOM 必须依附于一个文档中的原有 DOM 节点,也就是影子宿主,而影子 DOM 中的节点也可以成为另一个影子树的宿主。在 Web Components 的实际使用场景中,宿主一般是自定义元素,而在影子 DOM 中封装了其实现细节。

影子 DOM 树被影子边界所包裹,边界给其带来了封装性,这一封装性包括:

  1. DOM 的封装性:在不同的 DOM 树中无法选择另外 DOM 中的元素,只有获取对应的 DOM 树才能对其中的元素进行操作;
  2. 样式的封装性:在不同的 DOM 树中样式无法相互影响,只有通过一系列手段突破边界才能对对方的样式进行操作。其中通过影子 DOM 来对宿主 DOM 进行操作的方式多种多样:
    • 对宿主本身进行样式影响:通过 :host 选择器
    • 对宿主的祖先节点进行样式影响:通过 :host-context 选择器
    • 对分布节点进行影响:通过 ::content 选择器

    需要注意的是,单独使用 ::content 选择器没有意义,这表示对插入点进行的样式修饰,而插入点是不展现的;所以该选择器的出现必然跟随者子选择器,以便对分布节点进行样式影响。

    而光明 DOM 对于影子 DOM 的影响却要弱很多,且原则上来讲,影子 DOM 存在的意义就是为了对子 DOM 进行封装,而实现自定义元素对外部隐藏细节的效果。如果用户要求进行影响的话,这里也存在两个大杀器:一层穿透 ::shadow 伪类选择器与多重穿透 /deep/ 组合符。

  3. JS 的重定向与阻塞:影子边界对于 JS 的阻塞是最弱的,它仅仅阻塞了一部分事件, 同时将影子 DOM 中的事件重定向到宿主上。关于事件重定向,Eric Bidelman 的 Shadow DOM 301 讲的更为细致一点,事实上重定向仅仅是表示影子 DOM 中的事件冒泡无法通过影子边界,而在边界内部的事件监听还是有效果的。我们也可以通过 event.path 来查看调整后的事件路径,并找到事件在影子 DOM 中的真正源头。

注意:在影子 DOM 中,<link> 标签时被忽略的,因此不能用外链的方式引入 CSS 文件。在 Polymer 中我们可以这样做,是因为 Polymer 将 <link> 节点转化为了 <style> 节点所致。在原生的实现上要注意这一点。

1.Shadow DOM:简介

原文链接:Shadow DOM: Introduction

1.1什么是 Shadow DOM

如果说要用一个例子来解释 Shadow DOM,我认为 <video> 标签最为适合。我们看下下面这个例子:

See the Pen VvaKQY by iTattoo (@ZhouYanlang) on CodePen.

例子视频的 DOM 标签如下:

<video id="video" controls="" preload="none" poster="http://media.w3.org/2010/05/sintel/poster.png">
    <source id="mp4" src="http://media.w3.org/2010/05/sintel/trailer.mp4" type="video/mp4">
    <source id="webm" src="http://media.w3.org/2010/05/sintel/trailer.webm" type="video/webm">
    <source id="ogv" src="http://media.w3.org/2010/05/sintel/trailer.ogv" type="video/ogg">
</video>
<audio width="300" height="32" src="http://developer.mozilla.org/@api/deki/files/2926/=AudioTest_(1).ogg" autoplay="autoplay" controls="controls">
    Your browser does not support the HTML5 Audio.
</audio>

主要是有一个 <video> 标签包裹着 <source> 标签。但是你仔细想想,这里面其实大有文章。视频播放器本身有播放/暂停按钮、进度条、视频时间显示、音量控制以及播放时的一个全屏切换按钮。既然 DOM 源码这么干净,那实现这些组件的代码是从哪儿来的呢?

1.2“影子”

事实上,各大浏览器的厂商已经编写了播放器的组件代码使之能够正常运作。

  • 如果你打开 Chrome 的开发者工具,
  • 点击右上角的“Settings”按钮,
  • 勾选“Show user agent shadow DOM”,
  • 你就可以看到视频播放器 DOM 结构的细节。

看到标灰的 #shadow-root 了吗?这里就是所有视频播放器控制组件的所在之处。浏览器之所以将其置灰,是为了表明这部分是在 shadow DOM 里,对于页面的其他部分来说它是不可用的。这里的不可用意味着你写的 CSS 选择器和 JavaScript 代码都不会影响到这部分内容。实际上,让 <video> 标签的 UI 能够运行的标签和样式都被浏览器封装了。 那么,什么是 Shadow DOM 呢? 简而言之,Shadow DOM 是一个 HTML 的新规范,其允许开发者封装自己的 HTML 标签、CSS 样式和 JavaScript 代码。Shadow DOM 以及我们以后将会讨论的一些技术,使得开发人员可以创建诸如 <video> 这样自定义的一级标签。总的来说,这些新标签和相关的 API 被称为 Web Components

1.3Shadow DOM 为何这么重要

如果你已经做过一段时间的网站开发,你可能听过 Bootstrap。Bootstrap 是一组 UI 组件的集合,使用它需要将一系列 CSS 样式、JS 脚本以及规定的 HTML 模式糅合在一起。以下这个例子展示了使用 Bootstrap navabar 所需要编写的 HTML 结构:

<nav class="navbar navbar-default navbar-fixed-top" role="navigation">  
  <div class="navbar-header">
    <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-ex6-collapse">
      <span class="sr-only">Toggle navigation</span>
      <span class="icon-bar"></span>
      <span class="icon-bar"></span>
      <span class="icon-bar"></span>
    </button>
    <a class="navbar-brand" href="#">Brand</a>
  </div>
  <div class="collapse navbar-collapse navbar-ex6-collapse">
    <ul class="nav navbar-nav">
      <li class="active"><a href="#">Home</a></li>
      <li><a href="#">About</a></li>
      <li><a href="#">Contact</a></li>
    </ul>
  </div>
</nav>  

由于无法封装 Boostrap 的组件,因此你自己的 HTML 必须要和它文档上的保持一致。很多时候这就 意味着你要从 Boostrap 的说明文档里把这一坨代码复制过来然后粘贴到你的项目中。尽管用这种方法也可以快速的构建页面,但这也意味着大部分的页面标签都不是你自己写的,你也没有 深入了了解这些页面结构。这可能会导致各种潜在的小 bug,你也无法快速直观的分析自己的工作——因为你代码里都是一坨又一坨的模板。 再考虑一下这个情况:Boostrap 的 CSS 样式表有大约 6600 行那么多,这就意味着任何时候你都有可能一个不小心覆盖了一个至关重要的选择器,导致页面一部分样式崩坏。

1.4Shadow DOM 的价值所在

让我们想象一下,如果在上一个例子中, Boostrap 的 navbar 的用法像 <video> 标签一样,它可能看起来是这样:

<bootstrap-navbar fixed-top>  
  <a href="#">Home</a>
  <a href="#">About</a>
  <a href="#">Contact</a>
</bootstrap-navbar> 

这样是不是看起来简洁多了~ 当然你可以在 HTML 里定义自己的标签,但是浏览器会把不认识的标签全当做 <div> 处理,同时你这么写也得不到任何特殊的带有封装特性的好处。更重要的是,你无法把 navbar 的实现细节隐藏起来,实现的代码必须存在于页面某处。如果你真的想实现一些私有化,你必须使用臃肿无比又难以处理的 <iframe>。Shadow DOM 的引入改变了这一切。

2.Shadow DOM:基础

 原文链接:Shadow DOM: The Basics

2.1环境支持

为了能尝试下面的实例,建议使用 Chrome 33 或者更高版本的 Chrome。

2.2例子1

让我们看一个非常简单的 HTML 文档。

<body>  
  <div class="my-widget">
    <h1>我的组件的标题</h1>
    <p>一些组件的内容</p>
  </div>
  <div class="my-other-widget">
    <h1>我的另一个组件的标题</h1>
    <p>一些另一个组件的内容</p>
  </div>
</body>

当 HTML 转化成 DOM,每个元素都会变成一个节点(node)。而一组互相嵌套的一组节点则被称为节点树(node tree)。 Shadow DOM 的独特之处在于它允许我们创建自己的节点树,这种节点树被称为影子树(shadow trees)。影子树对其中的内容进行了封装,有选择性的进行渲染。这就意味着我们可以插入文本、重新安排内容、添加样式等等。举个栗子:

<div class="widget">Hello, world!</div>  
<script>  
    var host = document.querySelector('.widget');
    var root = host.createShadowRoot();
    root.textContent = '我在你的 div 里!';
</script>

通过上面的代码,我们已经通过一个影子树替换掉了我们 .widget div 的文本内容。要创建一个影子树,我们首先要指定一个节点担任影子宿主(shadow host)。在这个例子里,我们将 .widget 当做我们的影子宿主。然后我们给影子宿主添加一个称作影子根(shadow root)的新节点。影子根作为影子树的第一个节点,其他的节点都是它的子节点。 如果你使用 Chrome 开发者工具检查这个元素,你会看到 #shadow-root 是怎么被标灰显示的吗?这就是我们刚才创建的影子根。结果表明,影子宿主里面的内容没有被渲染,反而影子根里面的内容被渲染了出来。 这就是当实例运行时,我们看到 Im inside yr div! 而不是 Hello, world! 的原因。

2.3例子2

在影子DOM里创建新的DOM元素h1,p

<body>
  <div class="widget">Hello, world!</div>
  <script>
    var host = document.querySelector('.widget');
    var root = host.createShadowRoot();

    var header = document.createElement('h1');
    header.textContent = '一只野生的影子标题出现了!';

    var paragraph = document.createElement('p');
    paragraph.textContent = '一些影子文本也探头探脑滴冒了出来…';

    root.appendChild(header);
    root.appendChild(paragraph);
  </script>
</body>

 我沿用了之前的例子并给它添加了两个新元素,之所以这样做是为了说明 Shadow DOM 的操作与普通的DOM 的操作区别真的不大。你仍然可以使用 appendChildinsertBefore 来将子节点添加到父节点上,和之前一样,影子宿主 里面的内容 Hello world 并没有被渲染,取而代之展现的的是 影子根。 你可能会说:“这个我知道,但我如果想要渲染影子宿主里的内容,那该怎么玩?” 既然你诚心诚意的发问了,我就大发慈悲的告诉你——这绝对是 Shadow DOM 的一个杀手锏!请耐心读下去,我会向你展示它的神奇之处。

2.4temlpate 标签

预定义一个模板,浏览器不会渲染。这是影子DOM更加模块化了

<div id="nameTag2">
    <div class="red">Bob</div>
    <div class="yellow">B.Love</div>
    <div class="blue">bob@</div>
</div>
<template id="nameTagTemplate2">
    <div style="background: purple; padding: 1em;">
        <div style="color: red;">
            <content select=".red"></content>
        </div>
        <div style="color: yellow;">
            <content select=".yellow"></content>
        </div>
        <div style="color: blue;">
            <content select=".blue"></content>
        </div>
    </div>
</template>
<script>
//  document.querySelector('#nameTag2').textContent = 'Shellie';
    var shadow = document.querySelector('#nameTag2').createShadowRoot();
    var template = document.querySelector('#nameTagTemplate2');
    var clone = document.importNode(template.content, true);
    shadow.appendChild(clone);
</script>

2.5content 标签

在之前的两个例子里,我们用影子根里面的内容完全替换掉了影子宿主里面的内容。但这种奇技淫巧在实际开发中没什么用。真正有用的是我们可以从影子宿主中获取内容,并使用影子根中的结构将这些内容呈现。像这种将内容与实现分离的方式让我们可以更加灵活的处理页面的呈现。

想要引用影子宿主里面的内容,我们首先需要采用一个新的标签—— <content> 标签。这儿又是一个栗子:

<body>
  <div class="pokemon">
    胖丁
  </div>

  <template class="pokemon-template">
    <h1>一只野生的 <content></content> 出现了!</h1>
  </template>

  <script>
    var host = document.querySelector('.pokemon');
    var root = host.createShadowRoot();
    var template = document.querySelector('.pokemon-template');
    root.appendChild(document.importNode(template.content, true));
  </script>
</body>

译者注:pokemon 就是口袋妖怪的意思。 使用 <content> 标签,我们创建了一个插入点(insertion point),其将 .pokemon div 中的文本投射(projects) 出来,使之得以在我们的影子节点 <h1> 中展示。插入点十分强大,它允许我们在不改变源代码的情况下改变渲染顺序,这也意味着我们可以对要呈现的内容进行选择。 你可能已经注意到我们已经使用了一个模板标签而不是整个用 JavaScript 来构建 shadow DOM。我发现使用 <template> 标签令 shadow DOM 的使用过程更加简单。 让我们看一个使用进阶的例子来证明如何使用多个插入点。

2.6select 属性

<div class="bio">
    <span class="first-name">Rob</span>
    <span class="last-name">Dodson</span>
    <span class="city">San Francisco</span>
    <span class="state">California</span>
    <p>HTML/CSS/JavaScript</p>
</div>
<template class="bio-template">
    <dl>
        <dt>Last Name</dt>
        <dd><content select=".last-name"></content></dd>
        <dt>First Name</dt>
        <dd><content select=".first-name"></content></dd>
        <dt>City</dt>
        <dd><content select=".city"></content></dd>
        <dt>State</dt>
        <dd><content select=".state"></content></dd>
    </dl>
    <p><content select=""></content></p>
</template>
<script>
    var host = document.querySelector('.bio');
    var root = host.createShadowRoot();
    var template = document.querySelector('.bio-template');
    root.appendChild(template.content);
</script>

在这个例子中我们创建了一个非常简单的简历组件。因为每个定义的字段都需要特定的内容,我们必须告诉 <content> 标签有选择性的插入内容。为了做到这一点,我们使用 select 属性。select 属性使用 CSS 选择器来选取想要展示的内容。

举例来说,<content select=".last-name"> 会在影子宿主里寻找任何样式名称为 .last-name 的元素。如果找到一个匹配的元素,其就会将这个元素渲染到 shadow DOM 中对应的 <content> 标签中去。

2.7改变顺序

通过插入点,我们不必修改 content 内容的结构而改变渲染的顺序。请记住,内容存在于影子宿主中,而呈现的方式存在于影子根也就是 shadow DOM 中。这有一个不错的示例,它展示了如何将姓氏一栏和名字一栏的渲染顺序进行交换。

<template class="bio-template">
    <dl>
        <dt>First Name</dt>
        <dd><content select=".first-name"></content></dd>
        <dt>Last Name</dt>
        <dd><content select=".last-name"></content></dd>
        <dt>City</dt>
        <dd><content select=".city"></content></dd>
        <dt>State</dt>
        <dd><content select=".state"></content></dd>
    </dl>
    <p><content select=""></content></p>
</template>

通过对我们的 template 模板进行简单的修改,我们就在不更改影子宿主内容的前提下对展示的效果进行了替换。为了更好的理解上面的内容,请看一下 Chrome 开发者工具的检查元素。.first-name 节点依然是影子宿主的第一个子节点,但是我们让它显示在 .last-name 节点之后了。我们通过改变插入点的顺序来完成了这一切,仔细回味一下你就会发现这个功能的强大之处。

2.8贪心插入点(Greedy Insertion Points)

你可能已经注意到了,在 .bio-template 模板的最后,我们有一个 content 标签,他的 select 属性值为空。

<p><content select=""></content></p>  

这种被称作通配符选择器(wildcard selection),其可以抓取影子宿主中所剩余的全部内容。以下三种选择器是完全相等的:

<content></content>  
<conent select=""></conent>  
<content select="*"></content>  

我们来实验一把,将通配符选择器移到 template 模板的顶部:

<template class="bio-template">  
  <p><content select=""></content></p>
  <dl>
    <dt>Last Name</dt>
    <dd><content select=".last-name"></content></dd>
    <dt>First Name</dt>
    <dd><content select=".first-name"></content></dd>
    <dt>City</dt>
    <dd><content select=".city"></content></dd>
    <dt>State</dt>
    <dd><content select=".state"></content></dd>
  </dl>
</template>  

你会注意到所有的内容都被挪到 <p> 标签中去了,这完全的改变了我们组件的展示。这是因为这个选择器是贪心的,而且元素只能被选择一次。我们一旦把贪心选择器放在了模板的顶部,他就会将所有内容都抓取,不给其他 select 选择器留一点内容。 Dominic Cooney(@connsta)在他的博文 Shadow DOM 101 中对贪心选择器有很好的描述。文中他将选择器的原理比作舞会的邀请函:

<content> 元素是一封将文档(document)内容邀请去 Shadow DOM 渲染舞会的请柬。这些邀请按序发出;谁能收到请柬取决于请柬发往的地址(也就是 select 属性)。对于内容元素来说,一旦收到请柬就会欣然接受并立即动身——谁会不接受这样一份盛大舞会请柬呢?如果接下来又有一份请柬发送到这一地址,额,不好意思,现在家里已经没人能去了。

掌握选择器和插入点的用法也是挺麻烦的,所以 Eric Bidelman(@ebidel)写了一个插入点可视化工具来帮助阐明这一概念。

3.Shadow DOM:样式

原文链接:Shadow DOM: Styles

如果你想直奔主题,我列出我的这一篇博文 Shadow DOM CSS 小抄以供参考。 今天我们将学习在影子边界(shadow boundary)上使用 CSS 样式,以及如何给影子宿主(shadow hosts)添加样式。 在今天开始之前,我想要感谢 Eric Bidelman 的这篇介绍 Shadow DOM 样式添加的宏文(可以戳中文译版)。本文的大部分都是我对他这篇博文内容的实践。如果有机会的话你一定要去读一下 HTLM5 Rocks 关于 Web Components 的全部文章

3.1样式封装

机智的读者可能会注意到在文章开始的简介处我使用了一个新的术语——影子边界。影子边界表示分离常规 DOM (与影子 DOM 相对立的“光明” DOM)与 shadow DOM 的壁障。影子边界的主要好处就是防止主 DOM 中的样式泄露到 shadow DOM 中。这就意味着即使你在主文档中有一个针对全部 <h3> 标签的样式选择器,这个样式也不会不经你的允许便影响到 shadow DOM 的元素。

<body>  
  <style>
    button {
      font-size: 18px;
      font-family: '华文行楷';
    }
  </style>
  <button>我是一个普通的按钮</button>
  <div></div>
  <script>
    var host = document.querySelector('div');
    var root = host.createShadowRoot();
    root.innerHTML = '<style>button { font-size: 24px; color: blue; } </style>' +
                     '<button>我是一个影子按钮</button>'
  </script>
</body>

我们整了两个按钮,一个是普通的 DOM,另一个是 shadow DOM。注意页面顶部的 <style> 标签指示所有的 button 都要用行楷以及 18px 的字号。 由于影子边界的存在,第二个按钮忽略掉这个样式标签并使用自己的样式。由于我们没有重写 font-family 属性,所以它使用了浏览器默认的字体来实现。 要记住影子边界也保护主文档不受 shadow DOM 样式的侵袭。你可能注意到影子按钮有一个蓝色的 color 属性,但是原文档中的按钮还是保持了它默认的显示样式。 这种作用域化(scoping)的特性实在是非常的“额妹子嘤”。我们折腾了这么些年样式表,它的选择范围似乎越来越大。你越来越难以向一个项目中添加新的样式,因为你担心不小心把页面中的那一块搞崩掉。Shadow DOM 提供给我们的样式边界意味着我们终于可以开始用一种更加局部的、特定组件化的方式来考虑和编写我们的 CSS。

3.2宿主样式(:host)

我经常把影子宿主想象成一栋建筑物的外表。这栋建筑物的内部有组件的全部运行方式,外面有一个好的门面。 许多情况下你可能会想给这门面调整一下样式,这就轮到 :host 选择器出场了。

<body>  
  <style>
    .widget {
      text-align: center;
    }
  </style>

  <div class="widget">
    <p>Hello World!</p>
  </div>
  <script>
    var host = document.querySelector('.widget');
    var root = host.createShadowRoot();
    root.innerHTML = '<style>' +
                     ':host {' +
                     '  border: 2px dashed red;' +
                     '  text-align: left;' +
                     '  font-size: 28px;' +
                     '} ' +
                     '</style>' +
                     '<content></content>';
  </script>
</body>  

尽管给我们的组件添加一个红色边框看似没啥,但这里面可是发生了很多有趣的事情。首先,应用于 :host 的样式是继承自 shadow DOM 里的元素的。所以我们的 <p> 标签里的字体大小有 28px。 同时注意到,页面上的样式可以设置 :host 中的 text-align 的文本对齐方式为居中。:host 的选择器的优先级被设定为低于页面选择器的优先级,所以如果有需要的话它可以轻松的被页面重写样式。在这个例子里页面样式 .widget 击败了影子样式 :host

3.3宿主样式中的类型选择器

由于 :host伪类选择器(Pseudo Selector),我们可以将其应用于多个标签上来改变我们组件的外观。我们举一个其他的栗子来证明这一点。

<body>
  <p>我的段落</p>
  <div>我的 Div</div>
  <button>我的按钮</button>

  <!-- Our template -->
  <template class="shadow-template">
    <style>
    :host(p) {
      color: blue;
    }

    :host(div) {
      color: green;
    }

    :host(button) {
      color: red;
    }

    :host(*) {
      font-size: 24px;
    }
    </style>
    <content select=""></content>
  </template>

  <script>
    // 给每一个元素创建一个影子根
    var root1 = document.querySelector('p').createShadowRoot();
    var root2 = document.querySelector('div').createShadowRoot();
    var root3 = document.querySelector('button').createShadowRoot();

    // 对于每一个影子根使用同一个模板
    var template = document.querySelector('.shadow-template');

    // 把每一个模板嵌入影子根中,注意一下不同的 :host 样式对显示效果的影响
    root1.appendChild(document.importNode(template.content, true));
    root2.appendChild(document.importNode(template.content, true));
    root3.appendChild(document.importNode(template.content, true));
  </script>
</body>

由于模板标签在使用上要更简单一些,因此我在这个例子中改用模板标签来操作 Shadow DOM。 在上面的例子中可以看到,我们可以利用 :host 选择器来改变我们组件的某一个特定标签样式。我们还可以根据类名、ID、属性等等来进行匹配选择——任何有效的 CSS 选择器都可以正常工作。

比方说,如果你想写一个自适应的组件,你可以在 :host 中写各种诸如 .widget-fixed.widget-flex.widget-fluid 的样式,或者在表单元素的 :host 中写 .valid.error 样式。 通过使用 * 选择器,我们可以创造应用于全部 :host 元素的默认的样式,正如在这个例子中我们设置所有组件的 font-size 为 24px。通过这一方式你可以构建组件的基本外观,然后在通过不同方式的选择器给你的组件增光添彩。 那我们怎么基于宿主元素的父元素对它构建不同的主题呢?嚯嚯,我们有一个专门实现主题化的选择器!

3.4主题化

<body>
  <div class="serious">
    <p class="serious-widget">
      大家好我十分的严肃
    </p>
  </div>

  <div class="playful">
    <p class="playful-widget">
      漂亮的小云彩效果……
    </p>
  </div>

  <template class="widget-template">
    <style>
    :host-context(.serious) {
      width: 250px;
      height: 50px;
      padding: 50px;
      font-family: '微软雅黑';
      font-weight: bold;
      font-size: 24px;
      color: black;
      background: tomato;
    }

    :host-context(.playful) {
      width: 250px;
      height: 50px;
      padding: 50px;
      font-family: '华文行楷';
      font-size: 24px;
      color: white;
      background: deepskyblue;
    }
    </style>
    <content></content>
  </template>
  <script>
    var root1 = document.querySelector('.serious-widget').createShadowRoot();
    var root2 = document.querySelector('.playful-widget').createShadowRoot();
    var template = document.querySelector('.widget-template');
    root1.appendChild(document.importNode(template.content, true));
    root2.appendChild(document.importNode(template.content, true));
  </script>
</body>

使用 :host-context() 的语法我们可以基于内容元素修改我们组件的外观。这实在太简洁啦!我确信你曾用过子类选择器,例如 .parent > .child 这样的,但你是否曾梦想有例如 .parent < .child 的这样一个父类选择器?现在你的梦想成真啦,当然这仅限于使用 shadow DOM。我在想有一天我们是不是能看到这个语法也能在 CSS 中出现呢?

3.5宿主样式状态

:host 标签最好用的地方之一就是设置状态的样式,例如 :hover:active。例如,我们想在按钮被用户鼠标覆盖的时候上加一个绿色的边框,好办!

<body>  
  <button>我的按钮</button>

  <template class="button-template">
    <style>
      :host {
        font-size: 18px;
        cursor: pointer;
      }
      :host(:hover) {
        border: 2px solid green;
      }
    </style>
    <content></content>
  </template>
  <script>
    var host = document.querySelector('button');
    var root = host.createShadowRoot();
    var template = document.querySelector('.button-template');
    root.appendChild(template.content.cloneNode(true));
  </script>
</body>  

这没啥特别的,就是希望你多想一些场景。你还想到了其他你可以使用的场景么?


译者注:由于新概念有点多,列出一些参考博文和自己的见解,以飨读者。 模板标签,HTML5Rocks的文章中译本,结合这篇以及文中给出的作者自己博文的链接就能大概了解模板标签的特性。这个标签目前在非 IE 的浏览器下都得到支持,主要有四个特性:

  • 惰性:在使用前不会被渲染;
  • 无副作用:在使用前,模板内部的各种脚本不会运行、图像不会加载等;
  • 内容不可见:模板的内容不存在于文档中,使用选择器无法获取;
  • 可被放置于任意位置:即使是 HTML 解析器不允许出现的位置,例如作为 <select> 的子元素。

本文中利用模板为 shadow DOM 填充内容,省去了通过 JS 加载的麻烦。注意文中使用 .content 属性来导出模板的内容,并通过 importNode 方式对模板进行了深拷贝。

:host-context 的作用,这个选择器的实际用法是用来选择影子宿主的祖先元素的,最大的用处大概就是主题的设置。比方我的组件有各种状态,但是很多情况用户实际操作的可能是组件外部的父元素,这个选择器的目的就是通过更改父元素的样式状态就能完成组件状态的变化。

4.Shadow DOM:样式(续)

原文传送门:Shadow DOM: Styles (cont.)

4.1分布节点

通过阅读各种博客,我认识到了一点:在使用 shadow DOM 的时候应该确保内容和表现的分离。换句话说,如果你的一个按钮上想展示一些文本,那本这些文本应该来自页面而不是埋在 shadow DOM 的模板里。来自页面并通过 <content> 标签添加到 shadow DOM 的内容被称为分布节点。 在最开始我困惑于如何给分布节点添加样式的时候,我这样写 CSS 的:

<div class="some-shadow-host">  
  <button>Hello World!</button>
</div>

<template>  
  <style>
    :host {
      ...
    }

    button {
      font-size: 18px;
    }
  </style>
  <content></content>
</template> 

试想如果 button 是来自影子宿主的话,一旦它被 <content> 标签选中,就应该能被我的样式渲染。然而事实却并非如此,实际上,分布节点的样式渲染需要用到 :content 伪类选择器。这样做是十分有意义的,因为我们可能会想让影子模板中的按钮与出现在 <content> 标签中的按钮拥有不同的样式。 让我们看一个实例:

<body>
  <div class="widget">
    <button>分布节点碉堡啦!</button>
  </div>

  <template class="widget-template">
    <style>
      ::content > button {
        font-size: 18px;
        color: white;
        background: tomato;
        border-radius: 10px;
        border: none;
        padding: 10px;
      }
    </style>
    <content select=""></content>
  </template>

  <script>
    var host = document.querySelector('.widget');
    var root = host.createShadowRoot();
    var template = document.querySelector('.widget-template');
    root.appendChild(document.importNode(template.content, true));
  </script>
</body>

在这里我们将按钮从 .widget 影子宿主中取出并放置到 <content> 标签中。使用 ::content 伪类选择器,我们使用 >button 定位到子元素并设置了华丽丽的样式。

4.2 ::shadow

迄今为止我们已经列举了各种基于 shadow DOM 封装的优点,但有时你可能会想让使用者打破影子边界的壁垒,让他们能够给你的组件添加一些样式。 我们假设你在表单里创建了一个标志。在你的模板里你给输入框定义了文字的大小,但是你希望用户可以改变文字大小以更好的适应自己的网站。使用 ::shadow 伪类选择器我们可以赋予用户重写我们默认定义的自由,如果用户这样做的话,他就可以打破影子边界的壁垒。

<body>
  <style>
    .sign-up::shadow #username,
    .sign-up::shadow #password {
      font-size: 18px;
      border: 1px solid red;
    }

    .sign-up::shadow #btn {
      font-size: 18px;
    }
  </style>
  <div class="sign-up"></div>

  <template class="sign-up-template">
    <style>
      #username,
      #password {
        font-size: 10px;
      }
    </style>
    <div>
      <input type="text" id="username" placeholder="用户名">
    </div>
    <div>
      <input type="password" id="password" placeholder="密码">
    </div>
    <button id="btn">注册</button>
  </template>

  <script>
    var host = document.querySelector('.sign-up');
    var root = host.createShadowRoot();
    var template = document.querySelector('.sign-up-template');
    root.appendChild(document.importNode(template.content, true));
  </script>
</body>

4.3 /deep/

使用 ::shadow 选择器的一个缺陷是他只能穿透一层影子边界。如果你在一个影子树中嵌套了多个影子树,那么使用 /deep/ 组合符【注】更为简便。

<head>  
  <style>
    #foo /deep/ button {
      color: red;
    }
  </style>
</head>  
<body>  
  <div id="foo"></div>

  <template>
    <div id="bar"></div>
  </template>

  <script>
    var host1 = document.querySelector('#foo');
    var root1 = host1.createShadowRoot();
    var template = document.querySelector('template');
    root1.appendChild(document.importNode(template.content, true));

    var host2 = root1.querySelector('#bar');
    var root2 = host2.createShadowRoot();
    root2.innerHTML = '<button>点我点我</button>';
  </script>
</body>  

注:组合符(Combinator)是 CSS 里的一个概念,用于表示两个选择器之间的关系。现有的组合符号有后代选择器(space)、子选择器(>)、相邻兄弟选择器(+)和兄弟选择器(~)。

4.4template中间可以放多个模板

举个栗子

<div id="nameTag">
    Bob
</div>
<template id="nameTagTemplate">
    <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>
    <style>
        .outer1 {
            border: 2px solid pink;
            border-radius: 1em;
            background: url(images/50792d34ed60c.jpg) 100% 100%;
            font-size: 20pt;
            width: 12em;
            height: 7em;
            text-align: center;
            font-family: sans-serif;
            font-weight: bold;
        }

        .name1 {
            font-size: 45pt;
            font-weight: normal;
            margin-top: 0.8em;
            padding-top: 0.2em;
        }
    </style>
    <div class="outer1">
        <div class="name1">
            <content></content>
        </div>
        Shadow Dom
    </div>
</template>
<script>
    var shadow = document.querySelector('#nameTag').createShadowRoot();
    var template = document.querySelector('#nameTagTemplate');
    var clone = document.importNode(template.content, true);
    shadow.appendChild(clone);
</script>

5.Shadow DOM:JavaScript

原文传送门:Shadow DOM: JavaScript

我们目前已经对模板HTML引入和 Shadow DOM(简介基础样式样式续) 有了一定了解。所有这些技术的终极目标就是自定义元素(custom elements),但是我们尚未企 及这一终点。在你迈向这一终点之前,我还希望你能理解使用 JavaScript 操作 Shadow DOM 的基本方式。所以在本文中我将要阐述一些需要注意的问题——特别是关于事件(events)是如何工作的。当你掌握了这些知识,你就能更好的创建你的自定 义元素啦。

让我们开整吧!

5.1JavaScript 作用域

还记得之前我花了大把时间解释 Shadow DOM 的 CSS 是怎么封装并被保护不遭受原始文档的侵袭的, 这种方式是不是很碉堡?你可能会猜 JavaScript 也是用这种碉堡的方式工作的,我一开始也这么想的,但是事实上完全不是我们想的那样。除了我们稍后会讨论的一些例外情况,Shadow DOM 里的 JavaScript 的使用方式和之前其实区别不大。这就意味着往年你所学习的那些 JavaScript 最佳实践仍然可以在 Shadow DOM 里使用。

这里就是一个使用 JS 的例子:

<body>  
  <div id="host"></div>
  <template>
    <h1>Hello World!</h1>
    <script>
    var foo = 'bar';
    </script>
  </template>
  <script>
    var host = document.querySelector('#host');
    var root = host.createShadowRoot();
    var template = document.querySelector('template');
    root.appendChild(document.importNode(template.content, true));
    console.log('window.foo = ' + window.foo);
  </script>
</body>   

尽管我们使用了模板标签且 <script> 代码块写在 Shadow DOM,变量 foo 仍然挂载到了 window 上。这里没有什么特殊的魔法将变量移出全局作用域。相反我们需要依赖 IIFE立即执行函数表达式,使用函数作用域避免全局变量污染,JS 里著名的 trick,译者注),这位可靠的朋友确保一切都得到保障。

<template>  
  <h1>Hello World!</h1>
  <script>
  (function () {
    var foo = 'bar';
  }());
  </script>
</template>  

这样才像那么回事儿么!

5.2事件重定向(Event Retargeting)

Shadow DOM 里的 JS 与传统的 JS 一个真正不同的点在于事件调度(event dispatching)。要记住的一点是:原来绑定在 shadow DOM 节点中的事件被重定向了,所以他们看起来像绑定在影子宿主上一样

我知道没有个例子的话你们都不知道我在说啥……

<body>  
  <input id="normal-text" type="text" value="I'm normal text">

  <div id="host"></div>

  <template>
    <input id="shadow-text" type="text" value="I'm shadow text">
  </template>

  <script>
    var host = document.querySelector('#host');
    var root = host.createShadowRoot();
    var template = document.querySelector('template');
    root.appendChild(document.importNode(template.content, true));

    document.addEventListener('click', function(e) {
      console.log(e.target.id + ' clicked!');
    });
  </script>
</body> 

可以戳上面这个例子的 JS Bin 链接

分别点击上面两个文本输入框并查看控制台的输出。当你点击“normal text”的输入框时控制台输出这个输入框的 id,然而当你点击“shadow text”的输入框时控制台却输出了宿主元素(就是 #host)的 id 。这是因为影子节点上的事件必须重定向,否则这将破坏封装性。如果时间帮顶继续指向 #shadow-text 那么任何人都可以在我们的 Shadow DOM 里乱搞而破坏我们内部的结构。

5.3分布式节点

分布节点——也就是投射到 Shadow DOM 中的影子宿主中的内容。你可能会认为既然这些节点出现在 Shadow DOM 中,那么他们上面的时间应该也被重定向了。但事实并非如此。

这有另外一个例子来证明以上的观点。

<body>  
  <input id="normal-text" type="text" value="I'm normal text">

  <div id="host">
    <input id="distributed-text" type="text" value="I'm distributed text">
  </div>

  <template>
    <div>
      <input id="shadow-text" type="text" value="I'm shadow text">
    </div>
    <div>
      <content></content>
    </div>
  </template>

  <script>
    var host = document.querySelector('#host');
    var root = host.createShadowRoot();
    var template = document.querySelector('template');
    root.appendChild(document.importNode(template.content, true));

    document.addEventListener('click', function(e) {
      console.log(e.target.id + ' clicked!');
    });
  </script>
</body> 

可以戳上面这个例子的 JS Bin 链接

和刚才一样,当你点击每一个输入框的时候你会看到绑定了事件的元素的 id。在“dustributed text”上点击你会发现他的事件绑定元素是未被重定向的。这是因为分布节点来自原有 DOM 结构,而用户是可以操作原有 DOM 结构的。对它进行事件重定向没啥必要,而且事实上你也不想让它重定向。如果一个使用者给你提供一个按钮来对 Shadow DOM 设置样式,有时候他们可能会希望监听它的 click 事件。

5.4被阻塞的事件(Blocked Events)

有些情况下事件绑定不进行重定向而直接被干掉。以下时间会被阻塞到根节点且不会被原有 DOM 结构监听到:

  • abort
  • error
  • select
  • change
  • load
  • reset
  • reset
  • resize
  • scroll
  • selectstart

举个例子说明一下:

<body>  
  <input id="normal-text" type="text" value="I'm normal text">

  <div id="host">
    <input id="distributed-text" type="text" value="I'm distributed text">
  </div>

  <template>
    <div>
      <content></content>
    </div>
    <div>
      <input id="shadow-text" type="text" value="I'm shadow text">
    </div>
  </template>
  <script>
    var host = document.querySelector('#host');
    var root = host.createShadowRoot();
    var template = document.querySelector('template');
    root.appendChild(document.importNode(template.content, true));

    document.addEventListener('select', function(e) {
      console.log(e.target.id + ' text selected!');
    });
  </script>
</body>  

可以戳上面这个例子的 JS Bin 链接

这里我对 select 事件进行了监听,当你点击并拖动鼠标选中文本的时候就会触发这一事件。如果你尝试在“normal text(原有 DOM 结构)”的输入框中选中文本,控制台会输出 normal-text text selected!。 而你对作为分布节点的“distributed text”输入框进行操作也会得到相似的结果。但是如果你尝试选中作为影子节点的“shadow text”输入框,控制台什么都不会输出。选择的时间在影子根节点中被干掉了因此不能冒泡到文档中,而我们的事件监听重定向至文档,因此无法监听到这一事 件。如果你想在你的 Shadow DOM 中使用以上事件,请记住他们会被扼杀在根节点。

注:webcomponents.js 在页面组件层面主要实现了三个内容:自定义元素、HTML 引入以及 Shadow DOM,对于模板和 HTML 引入的内容稍后会进行翻译。本系列的主要目的为了解 Shadow DOM 的原理并探究作为 polyfill 的 webcomponents 是如何在非 native 支持的浏览器上实现这些特性的