引入基于 Slot 的 Shadow DOM API

我们很高兴地宣布,我们四月份提议的新基于 slot 的 Shadow DOM API 的基本支持现已在 WebKit 的 nightly 构建版本中提供,自 r190680 之后。Shadow DOM 是 Web Components 的一部分,Web Components 是一组最初由 Google 提出的规范,旨在在 Web 上创建可重用的小部件和组件。Shadow DOM 特别通过允许在元素上创建名为“shadow tree”的并行树来为 DOM 树提供轻量级封装,该树替换元素的渲染而不修改底层 DOM 树。由于 shadow tree 不是其所附加的“宿主”元素的普通子元素,因此组件的用户不会意外地“戳入”其中。样式规则也作用域化,这意味着在 shadow tree 外部定义的 CSS 规则不适用于 shadow tree 内部的元素,而在 shadow tree 内部定义的规则不适用于其外部的元素。

样式隔离

使用 Shadow DOM 的一个主要好处是样式隔离。为了了解其工作原理,假设我们想创建一个自定义进度条。我们可以使用两个嵌套的 div 来显示进度条,以及另一个带有文本的 div 来显示百分比,如下所示:

<style>
.progress { position: relative; border: solid 1px #000; padding: 1px; width: 100px; height: 1rem; }
.progress > .bar { background: #9cf; height: 100%; }
.progress > .label { position: absolute; top: 0; left: 0; width: 100%;
    text-align: center; font-size: 0.8rem; line-height: 1.1rem; }
</style>
<template id="progress-bar-template">
    <div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
        <div class="bar"></div>
        <div class="label">0%</div>
    </div>
</template>
<script>
function createProgressBar() {
    var fragment = document.getElementById('progress-bar-template').content.cloneNode(true);
    var progressBar = fragment.querySelector('div');
    progressBar.updateProgress = function (newPercentage) {
        this.setAttribute('aria-valuenow', newPercentage);
        this.querySelector('.label').textContent = newPercentage + '%';
        this.querySelector('.bar').style.width = newPercentage + '%';
    }
    return progressBar;
}
</script>

请注意 template 元素的使用,它允许作者包含一个 HTML 片段,该片段可以通过克隆内容在以后实例化。这是我们在 WebKit 中实现的第一个 Web Components 功能,后来合并到了 HTML5 规范中。template 元素可以出现在文档中的任何位置(例如 tabletr 元素之间),并且 template 元素内部的内容是惰性的,不运行脚本或加载图像和其他类型的子资源。然后,此自定义进度条的用户可以实例化并更新进度,如下所示:

var progressBar = createProgressBar();
container.appendChild(progressBar);
...
progressBar.updateProgress(10);

此进度条实现的问题在于其两个内部 div 对用户是可自由访问的,并且其样式规则未作用域于进度条。例如,为进度条定义的样式规则将应用于进度条外部具有类名 progress 的内容:

<section class="project">
    <p class="progress">Pending an approval</p>
</section>

同样,为其他元素定义的样式规则可能会覆盖进度条中的规则:

<style>
.label { font-weight: bold; }
</style>

虽然我们可以通过使用自定义元素名称(例如 custom-progressbar)来作用域化规则,然后通过 all: initial 初始化所有其他属性来解决这些问题,但 Shadow DOM 提供了一个更优雅的解决方案。这里的想法是在外部 div 引入一个封装层,以便进度条的用户看不到其内部(例如为标签和进度条创建的 div),并且为进度条定义的样式不会干扰页面的其余部分,反之亦然。为此,我们首先通过调用 attachShadow({mode: 'closed'}) 在进度条上创建 ShadowRoot,然后在其下附加其实现所需的各种节点。假设我们仍然使用 div 来“宿主”此 shadow root,那么我们可以创建一个新的 div 并附加一个 shadow root,如下所示:

<template id="progress-bar-template">
    <style>
        .progress { position: relative; border: solid 1px #000; padding: 1px; width: 100px; height: 1rem; }
        .progress > .bar { background: #9cf; height: 100%; }
        .progress > .label { position: absolute; top: 0; left: 0; width: 100%;
            text-align: center; font-size: 0.8rem; line-height: 1.1rem; }
    </style>
    <div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
        <div class="bar"></div>
        <div class="label">0%</div>
    </div>
</template>
<script>
function createProgressBar() {
    var progressBar = document.createElement('div');
    var shadowRoot = progressBar.attachShadow({mode: 'closed'});
    shadowRoot.appendChild(document.getElementById('progress-bar-template').content.cloneNode(true));
    progressBar.updateProgress = function (newPercentage) {
        shadowRoot.querySelector('.progress').setAttribute('aria-valuenow', newPercentage);
        shadowRoot.querySelector('.label').textContent = newPercentage + '%';
        shadowRoot.querySelector('.bar').style.width = newPercentage + '%';
    }
    return progressBar;
}
</script>

请注意,样式元素位于模板元素内部,并与 div 一起克隆到 shadow root 中。这允许在 shadow root 内部定义的样式规则被作用域化。在 shadow root 外部定义的样式规则也不适用于 shadow root 内部的元素。提示:在调试代码时,您可能会发现使用 shadow DOM 的 open 模式很有帮助,这样您就可以通过宿主元素的 shadowRoot 属性访问新创建的 shadow root。例如 {mode: DEBUG ? 'open' : 'closed'}

使用 Slot 进行组合

此时,您可能想知道为什么必须在 DOM 中而不是 CSS 中完成此操作。样式是一种表现概念,那么为什么我们要向 DOM 添加新元素呢?实际上,CSS Scoping Module Level 1 的第一个公共工作草案定义了 @scope 规则,它正是实现了这一点。那么为什么我们需要添加另一种机制来隔离样式呢?一个动机是允许组件实现中使用的元素对节点遍历 API(例如 querySelectorAllgetElementsByTagName)隐藏。因为 shadow root 内部的节点默认情况下不会被这些 API 找到,所以使用 Shadow DOM 的组件的用户无需担心每个组件是如何实现的。每个组件都呈现为一个不透明的元素,其实现细节封装在其 Shadow DOM 中。请注意,Shadow DOM 不提供类似 iframe 的跨源安全边界。如果需要,脚本可以轻松绕过 Shadow DOM 边界。我们需要基于 DOM 的解决方案的另一个原因是组合。假设我们有一个联系人列表:

<ul id="contacts">
    <li>
        Commit Queue
        (<a href="mailto:commit-queue@webkit.org">commit-queue@webkit.org</a>)<br>
        One Infinite Loop, Cupertino, CA 95014
    </li>
    <li>
        Niwa, Ryosuke
        (<a href="mailto:rniwa@webkit.org">rniwa@webkit.org</a>)<br>
        Two Infinite Loop, Cupertino, CA 95014
    </li>
</ul>

并且我们希望在启用脚本时为列表中每个联系人信息添加一个漂亮的 UI:

我们可以使用命名 slot 将文本渲染到我们 Shadow DOM 中的其他位置,而无需修改 DOM,而不是将所有这些文本复制到我们自己的 Shadow DOM 中,如下所示:

<template id="contact-template">
    <style>
        :host { border: solid 1px #ccc; border-radius: 0.5rem; padding: 0.5rem; margin: 0.5rem; }
        b { display: inline-block; width: 5rem; }
    </style>
    <b>Name</b>: <slot name="fullName"><slot name="firstName"></slot> <slot name="lastName"></slot></slot><br>
    <b>Email</b>: <slot name="email">Unknown</slot><br>
    <b>Address</b>: <slot name="address">Unknown</slot>
</template>
<script>
window.addEventListener('DOMContentLoaded', function () {
    var contacts = document.getElementById('contacts').children;
    var template = document.getElementById('contact-template').content;
    for (var i = 0; i < contacts.length; i++)
        contacts[i].attachShadow({mode: 'closed'}).appendChild(template.cloneNode(true));
});
</script>

从概念上讲,slot 是 Shadow DOM 中的“孔”,将由其宿主元素的子元素填充。每个元素都可以通过 slot 属性分配到特定名称的 slot 中,如下所示:

<ul id="contacts">
    <li>
        <span slot="fullName">Commit Queue</span>
        (<a slot="email" href="mailto:commit-queue@webkit.org">commit-queue@webkit.org</a>)<br>
        <span slot="address">One Infinite Loop, Cupertino, CA 95014</span>
    </li>
</ul>

在这里,我们将一个 shadow root 附加到 li,并且每个带有 slot 属性的 span 都被分配到 shadow DOM 中同名的 slot。让我们仔细看看 shadow DOM 模板:

<b>Name</b>:
<slot name="fullName">
    <slot name="firstName"></slot>
    <slot name="lastName"></slot>
</slot><br>
<b>Email</b>: <slot name="email">Unknown</slot><br>
<b>Address</b>: <slot name="address">Unknown</slot>

在这个模板中,我们有名为 fullName 的 slot,其中包含另外两个名为 firstNamelastName 的 slot,以及另外两个名为 emailaddress 的 slot。fullName slot 利用了回退内容,并且只有在没有节点分配给 fullName slot 时才显示 firstNamelastName。即使在此示例中,每个 slot 都精确分配了一个节点,但多个具有相同 slot 属性值的元素可以分配给单个 slot,并且它们将按照它们作为宿主元素的子元素出现的顺序显示。您还可以使用一个未命名的默认 slot,该 slot 将由所有未指定 slot 属性的宿主子元素填充。当 Web 浏览器渲染此内容时,li 元素的内容被 Shadow DOM 替换,并且其中的 slot 被其分配的节点替换,就像渲染以下 DOM 一样:

<ul id="contacts">
    <li>
        <!--shadow-root-start-->
            <b>Name</b>:
            <slot name="fullName">
                <!--slot-content-start-->
                    <span slot="fullName">Commit Queue</span>
                <!--slot-content-end-->
            </slot><br>
            <b>Email</b>:
            <slot name="email">
                <!--slot-content-start-->
                    <a slot="email" href="mailto:commit-queue@webkit.org">commit-queue@webkit.org</a>
                <!--slot-content-end-->
            </slot><br>
            <b>Address</b>:
            <slot name="address">
                <!--slot-content-start-->
                    <span slot="address">One Infinite Loop, Cupertino, CA 95014</span>
                <!--slot-content-end-->
            </slot>
        <!--shadow-root-end-->
    </li>
</ul>

如您所见,基于 slot 的组合是一个强大的工具,它允许小部件无需克隆或修改 DOM 即可拉入页面内容。通过它,小部件可以响应对其子节点的更改,而无需 MutationObservers 或通过脚本进行明确通知。本质上,组合将 DOM 转换为组件之间的通信媒介。

宿主元素样式化

前面的示例中还有一个神秘的伪类 :host 需要注意:

<template id="contact-template">
    <style>
        :host { border: solid 1px #ccc; border-radius: 0.5rem; padding: 0.5rem; margin: 0.5rem; }
        b { display: inline-block; width: 5rem; }
    </style>
...
</template>

这个伪类,顾名思义,匹配此规则所在的 Shadow DOM 的宿主元素。默认情况下,在 Shadow DOM 外部定义的作者样式规则优先于在 Shadow DOM 中定义的规则。这允许组件定义其“默认样式”,并让组件的用户根据需要进行覆盖。此外,组件可以使用 !important 来强制某些样式,例如宽度和 display 类型,没有这些样式它就无法正常工作。在 Shadow DOM 内部定义的任何 !important 规则都优先于在 Shadow DOM 外部定义的常规和 !important 规则。

未来工作

Web Components 仍有大量工作要做。对于样式,我们希望允许对分配到 Shadow DOM 内部 slot 的节点进行样式设置。人们还希望组件能响应文档主题,并向其用户暴露可样式化的部分,就像 CSS 伪元素一样。从长远来看,我们希望看到一个命令式 DOM API 来操纵 slot 分配,正如我们之前提议的那样。为了补充 Shadow DOM,我们还对自定义元素感兴趣。自定义元素 API 允许作者将 JavaScript 类与 HTML 文档中的特定元素名称关联起来,这是习惯性地附加 Shadow DOM 和其他自定义行为的好方法。不幸的是,关于何时以及如何创建自定义元素,存在一些相互冲突的提案。为了帮助引导 W3C 中的讨论,我们计划在 WebKit 中对其进行原型设计。对于 Web Components 的打包和交付,我们一直在研究致力于 ES6 模块。像 Mozilla 一样,我们相信模块将彻底改变作者构建页面的方式。我们最终还希望设计一个 API,以便在 Shadow DOM 和自定义元素之上创建一个具有类似 iframe 安全边界的完全隔离的 Web 组件。总而言之,我们非常高兴能将 Web Components 的一项主要功能引入 WebKit,我们将继续向您更新更多即将推出的功能。如果您有任何疑问,请随时联系@WebKitJon Davis