这里所说的 Web Components指的是一套用于增强 DOM 行为的工具,包括shadow DOM、自定义元素和 HTML 模板。这一套浏览器 API 特别混乱。

  1. 并没有统一的“Web Components”规范:每个 Web 组件都在一个不同的规范中定义。
  2. 有些 Web 组件如shadow DOM 和自定义元素,已经出现了向后不兼容的版本问题。
  3. 浏览器实现极其不一致。

由于存在这些问题,因此使用 Web 组件通常需要引入一个 Web 组件库,比如 Polymer。这种库可以作为脚本,模拟浏览器中缺失的 Web 组件。

HTML 模板

在 Web 组件之前,一直缺少基于 HTML 解析构建 DOM 子树,然后在需要时再把这个子树渲染出来的机制。一种间接方案是使用 innerHTML 把标记字符串转换为 DOM 元素,但这种方式存在严重的安全隐患。另一种间接方案是使用dcument.createElement() 构建每个元素,然后逐个把它们添加到单根节点(不是添加到 DOM),但这样做特别麻烦,完全与标记无关。
相反,更好的方式是提前在页面中写出特殊标记,让浏览器自动将其解析为 DOM 子树,但跳过渲染。这正是 HTML 模板的核心思想,而 <template> 标签正是为这个目的而生的。

1. 使用 DocumentFragment

在浏览器中渲染时,上面例子中的文本不会被渲染到页面上。因为 <template> 的内容不属于活动文档,所以document.querySelector() 等 DOM 查询方法不会发现其中的 <p> 标签。这是因为 <p>存在于一个包含在 HTML 模板中的 DocumentFragment 节点内。
在浏览器中通过开发者工具检查网页内容时,可以看到 <template> 中的 DocumentFragment :

<template id="foo">
    #document-fragment
    <p>I'm inside a template!</p>
</template>

通过 <template> 元素的 content 属性可以取得这个 DocumentFragment 的引用:

console.log(document.querySelector('#foo').content); // #document-fragment

此时的 DocumentFragment 就像一个对应子树的最小化 document 对象。换句话说,DocumentFragment 上的 DOM匹配方法可以查询其子树中的节点:

const fragment = document.querySelector('#foo').content;
console.log(document.querySelector('p')); // null
console.log(fragment.querySelector('p')); // <p>...<p>

DocumentFragment 也是批量向 HTML 中添加元素的高效工具。比如,我们想以最快的方式给某个 HTML 元素添加多个子元素。如果连续调用 document.appendChild() ,则不仅费事,还会导致多次布局重排。而使用 DocumentFragment 可以一次性添加所有子节点,最多只会有一次布局重排:
// 开始状态:

<div id="foo"></div>

// 期待的结果:

<div id="foo">
    <p></p>
    <p></p>
    <p></p>
</div>
// 也可以使用 document.createDocumentFragment()
const fragment = new DocumentFragment();
const foo = document.querySelector('#foo');
// 为 DocumentFragment 添加子元素不会导致布局重排
fragment.appendChild(document.createElement('p'));
fragment.appendChild(document.createElement('p'));
fragment.appendChild(document.createElement('p'));
console.log(fragment.children.length); // 3
foo.appendChild(fragment);
console.log(fragment.children.length); // 0
console.log(document.body.innerHTML); // 输出结果如上期待结果

2. 使用 template 标签

注意,在前面的例子中, DocumentFragment 的所有子节点都高效地转移到了 foo 元素上,转移之后 DocumentFragment 变空了。同样的过程也可以使用 <template> 标签重现:

const fooElement = document.querySelector('#foo');
const barTemplate = document.querySelector('#bar');
const barFragment = barTemplate.content;
console.log(document.body.innerHTML);
// 原始状态
// <div id="foo">
// </div>
// <template id="bar">
//      <p></p>
//      <p></p>
//      <p></p>
// </template>

// 处理完后的结果跟用DocumentFragment一样
fooElement.appendChild(barFragment);
console.log(document.body.innerHTML);
// 期待的结果
// <div id="foo">
//      <p></p>
//      <p></p>
//      <p></p>
// </div>
// <tempate id="bar"></template>

如果想要复制模板,可以使用 importNode() 方法克隆 DocumentFragment :

const fooElement = document.querySelector('#foo');
const barTemplate = document.querySelector('#bar');
const barFragment = barTemplate.content;
console.log(document.body.innerHTML);
// 原始状态
// <div id="foo">
// </div>
// <template id="bar">
//      <p></p>
//      <p></p>
//      <p></p>
// </template>

fooElement.appendChild(document.importNode(barFragment, true));
console.log(document.body.innerHTML);
// 期待的结果
// <div id="foo">
//      <p></p>
//      <p></p>
//      <p></p>
// </div>
// <template id="bar">
//      <p></p>
//      <p></p>
//      <p></p>
// </template>

3. 模板脚本

脚本执行可以推迟到将 DocumentFragment 的内容实际添加到 DOM 树。下面的例子演示了这个过程:

// 页面 HTML:

// <div id="foo"></div>
// <template id="bar">
//  <script>console.log('Template script executed');</script>
// </template>

const fooElement = document.querySelector('#foo');
const barTemplate = document.querySelector('#bar');
const barFragment = barTemplate.content;
console.log('About to add template');
fooElement.appendChild(barFragment);
console.log('Added template');

// 顺序执行
// About to add template
// Template script executed
// Added template

如果新添加的元素需要进行某些初始化,这种延迟执行是有用的。

shadow DOM

概念上讲,shadow DOM Web 组件相当直观,通过它可以将一个完整的 DOM 树作为节点添加到父 DOM 树。这样可以实现 DOM封装,意味着 CSS 样式和 CSS 选择符可以限制在影子 DOM子树而不是整个顶级 DOM 树中。
shadow DOM 与 HTML 模板很相似,因为它们都是类似 document 的结构,并允许与顶级 DOM 有一定程度的分离。不过,shadow DOM 与 HTML 模板还是有区别的,主要表现在shadow DOM的内容会实际渲染到页面上,而 HTML 模板的内容不会。

1. 理解shadow DOM

假设有以下 HTML 标记,其中包含多个类似的 DOM 子树:

<div>
    <p>Make me red!</p>
</div>
<div>
    <p>Make me blue!</p>
</div>
<div>
    <p>Make me green!</p>
</div>

从其中的文本节点可以推断出,这 3 个 DOM子树会分别渲染为不同的颜色。常规情况下,为了给每个子树应用唯一的样式,又不使用 style 属性,就需要给每个子树添加一个唯一的类名,然后通过相应的选择符为它们添加样式:

<div class="red-text">
    <p>Make me red!</p>
</div>
<div class="green-text">
    <p>Make me green!</p>
</div>
<div class="blue-text">
    <p>Make me blue!</p>
</div>
<style>
    .red-text {
    color: red;
    }
    .green-text {
    color: green;
    }
    .blue-text {
    color: blue;
    }
</style>

当然,这个方案也不是十分理想,因为这跟在全局命名空间中定义变量没有太大区别。尽管知道这些样式与其他地方无关,所有 CSS 样式还会应用到整个 DOM。为此,就要保持 CSS 选择符足够特别,以防这些样式渗透到其他地方。但这也仅是一个折中的办法而已。理想情况下,应该能够把 CSS 限制在使用它们的 DOM上:这正是shadow DOM 最初的使用场景。

2. 创建shadow DOM

考虑到安全及避免shadow DOM 冲突,并非所有元素都可以包含shadow DOM。尝试给无效元素或者已
经有了shadow DOM的元素添加shadow DOM 会导致抛出错误。
以下是可以容纳shadow DOM 的元素。
任何以有效名称创建的自定义元素(参见 HTML 规范中相关的定义)

<article>、<aside>、<blockquote>、<body>、<div>、<footer>、<h1>、<h2>、<h3>、<h4>、<h5>、<h6>、<header>、<main>、<nav>、<p>、<section>、<span>

shadow DOM 是通过 attachShadow() 方法创建并添加给有效 HTML 元素的。容纳shadow DOM 的元素被称为shadow host。shadow DOM 的根节点被称为shadow root。
attachShadow() 方法需要一个 shadowRootInit 对象,返回shadow DOM的实例。 shadowRootInit对象必须包含一个 mode 属性,值为 "open" 或 "closed" 。对 "open" shadow DOM的引用可以通过 shadowRoot属性在 HTML 元素上获得,而对 "closed" shadow DOM 的引用无法这样获取。

下面的代码演示了不同 mode 的区别:

document.body.innerHTML = `
    <div id="foo"></div>
    <div id="bar"></div>`;
    
const foo = document.querySelector('#foo');
const bar = document.querySelector('#bar');
const openShadowDOM = foo.attachShadow({ mode: 'open' });
const closedShadowDOM = bar.attachShadow({ mode: 'closed' });
console.log(openShadowDOM); // #shadow-root (open)
console.log(closedShadowDOM); // #shadow-root (closed)
console.log(foo.shadowRoot); // #shadow-root (open)
console.log(bar.shadowRoot); // null

一般来说,需要创建closed shadow DOM 的场景很少。虽然这可以限制通过shadow host访问shadow DOM,但恶意代码有很多方法绕过这个限制,恢复对shadow DOM 的访问。简言之,不能为了安全而创建closed shadow DOM。

  1. 使用shadow DOM

把shadow DOM 添加到元素之后,可以像使用常规 DOM 一样使用shadow DOM。来看下面的例子,这里
重新创建了前面红/绿/蓝子树的示例:

for (let color of ['red', 'green', 'blue']) {
    const div = document.createElement('div');
    const shadowDOM = div.attachShadow({ mode: 'open' });
    document.body.appendChild(div);
    shadowDOM.innerHTML = `
        <p>Make me ${color}</p>
        <style>
            p {
                color: ${color};
            }
        </style>
    `;
}

虽然这里使用相同的选择符应用了 3 种不同的颜色,但每个选择符只会把样式应用到它们所在的shadow DOM 上。为此,3 个 <p> 元素会出现 3 种不同的颜色。
可以这样验证这些元素分别位于它们自己的shadow DOM 中:

for (let color of ['red', 'green', 'blue']) {
    const div = document.createElement('div');
    const shadowDOM = div.attachShadow({ mode: 'open' });
    document.body.appendChild(div);
    shadowDOM.innerHTML = `
        <p>Make me ${color}</p>
        <style>
            p {
                color: ${color};
            }
        </style>
    `;
}
function countP(node) {
    console.log(node.querySelectorAll('p').length);
}
countP(document); // 0
for (let element of document.querySelectorAll('div')) {
    countP(element.shadowRoot);
}
// 1
// 1
// 1

在浏览器开发者工具中可以更清楚地看到shadow DOM。例如,前面的例子在浏览器检查窗口中会显示成这样:

<body>
    <div>
        #shadow-root (open)
        <p>Make me red!</p>
        <style>
            p {
                color: red;
            }
        </style>
    </div>
    <div>
        #shadow-root (open)
        <p>Make me green!</p>
        <style>
            p {
                color: green;
            }
        </style>
    </div>
    <div>
        #shadow-root (open)
        <p>Make me blue!</p>
        <style>
            p {
                color: blue;
            }
        </style>
    </div>
</body>

shadow DOM 并无限制。HTML 元素可以在 DOM 树间无限制移动:

document.body.innerHTML = `
    <div></div>
    <p id="foo">Move me</p>
`;
const divElement = document.querySelector('div');
const pElement = document.querySelector('p');
const shadowDOM = divElement.attachShadow({ mode: 'open' });
// 从父 DOM 中移除元素
divElement.parentElement.removeChild(pElement);
// 把元素添加到影子 DOM
shadowDOM.appendChild(pElement);
// 检查元素是否移动到了影子 DOM 中
console.log(shadowDOM.innerHTML); // <p id="foo">Move me</p>

4. 合成与shadow DOM 槽位

shadow DOM是为自定义 Web组件设计的,为此需要支持嵌套 DOM片段。从概念上讲,可以这么说:位于shadow host中的HTML需要一种机制以渲染到shadow DOM中去,但这些HTML又不必属于shadow DOM树。
默认情况下,嵌套内容会隐藏。来看下面的例子,其中的文本在 1000 毫秒后会被隐藏:

document.body.innerHTML = `
    <div>
        <p>Foo</p>
    </div>
`;
setTimeout(() => document.querySelector('div').attachShadow({ mode: 'open' }), 1000);

shadow DOM 一添加到元素中,浏览器就会赋予它最高优先级,优先渲染它的内容而不是原来的文本。
在这个例子中,由于shadow DOM是空的,因此 <div> 会在 1000 毫秒后变成空的。
为了显示文本内容,需要使用 <slot> 标签指示浏览器在哪里放置原来的 HTML。下面的代码修改
了前面的例子,让shadow host中的文本出现在了shadow DOM中:

document.body.innerHTML = `
    <div id="foo">
        <p>Foo</p>
    </div>
`;
document.querySelector('div')
        .attachShadow({ mode: 'open' })
        .innerHTML = `<div id="bar">
                        <slot></slot>
                    <div>`

现在,投射进去的内容就像自己存在于shadow DOM 中一样。检查页面会发现原来的内容实际上替代了 <slot> :

<body>
    <div id="foo">
        #shadow-root (open)
        <div id="bar">
            <p>Foo</p>
        </div>
    </div>
</body>

注意,虽然在页面检查窗口中看到内容在shadow DOM中,但这实际上只是DOM内容的投射(projection)。实际的元素仍然处于外部 DOM中:

document.body.innerHTML = `
    <div id="foo">
        <p>Foo</p>
    </div>
`;
document.querySelector('div')
        .attachShadow({ mode: 'open' })
        .innerHTML = `<div id="bar">
                        <slot></slot>
                    </div>`
console.log(document.querySelector('p').parentElement);
// <div id="foo"></div>

下面是使用槽位(slot)改写的前面红/绿/蓝子树的例子:

for (let color of ['red', 'green', 'blue']) {
    const divElement = document.createElement('div');
    divElement.innerText = `Make me ${color}`;
    document.body.appendChild(divElement)
    divElement.attachShadow({ mode: 'open' })
              .innerHTML = `
                    <p><slot></slot></p>
                    <style>
                        p {
                            color: ${color};
                        }
                    </style>
                `;
}

除了默认槽位,还可以使用命名槽位(named slot)实现多个投射。这是通过匹配的 slot/name 属性对实现的。带有 slot="foo" 属性的元素会被投射到带有 name="foo" 的 <slot> 上。下面的例子演示了如何改变shadow host子元素的渲染顺序:

document.body.innerHTML = `
    <div>
        <p slot="foo">Foo</p>
        <p slot="bar">Bar</p>
    </div>
`;
document.querySelector('div')
        .attachShadow({ mode: 'open' })
        .innerHTML = `
            <slot name="bar"></slot>
            <slot name="foo"></slot>
        `;
// Renders:
// Bar
// Foo

5. 事件重定向

如果shadow DOM中发生了浏览器事件(如 click ),那么浏览器需要一种方式以让父 DOM处理事件。不过,实现也必须考虑shadow DOM 的边界。为此,事件会逃出shadow DOM并经过事件重定向(event retarget)在外部被处理。逃出后,事件就好像是由shadow host本身而非真正的包装元素触发的一样。下面的代码演
示了这个过程:

// 创建一个元素作为shadow host
document.body.innerHTML = `
    <div onclick="console.log('Handled outside:', event.target)"></div>
`;
// 添加影子 DOM 并向其中插入 HTML
document.querySelector('div')
        .attachShadow({ mode: 'open' })
        .innerHTML = `
            <button onclick="console.log('Handled inside:', event.target)">Foo</button>
        `;
// 点击按钮时:
// Handled inside: <button onclick="..."></button>
// Handled outside: <div onclick="..."></div>

注意,事件重定向只会发生在shadow DOM 中实际存在的元素上。使用 <slot> 标签从外部投射进来的元素不会发生事件重定向,因为从技术上讲,这些元素仍然存在于shadow DOM 外部。


万年打野易大师
1.5k 声望1.1k 粉丝