作者:Russell Beswick
原文地址:Smashing Magazine
翻译:掘金安东尼
在构建现代 Web 应用时,我们越来越倾向于组件化,而 Web Components 正是原生支持这一理念的标准。你也许已经听说过它的三大核心能力:自定义元素(Custom Elements)、HTML 模板(HTML Templates)和影子 DOM(Shadow DOM)。
在这篇文章中,我们将聚焦其中最神秘却也最强大的部分 ------ Shadow DOM,深入剖析它的动机、原理、使用方式以及最佳实践。
什么是 Web Components?
我们常见的 Web Components 示例通常只关注自定义元素(Custom Elements),比如 <my-component>
这样的标签。但实际上,Web Components 是一组可以搭配使用、也可以独立使用的浏览器原生 API,包括:
- 自定义元素(Custom Elements)
- HTML 模板(HTML Templates)
- 影子 DOM(Shadow DOM)
这意味着:你可以只用自定义元素而不必引入 Shadow DOM,但一旦你需要封装样式、避免冲突、提高组件的复用性,Shadow DOM 就会变得不可或缺。
为什么需要 Shadow DOM?
现代 Web 应用由各种来自不同库、框架甚至第三方的组件构成,DOM 元素之间极容易产生冲突。CSS 污染、全局选择器、类名冲突、脚本注入......都是典型的老问题。
比如你可能看到这样的"div soup":
xml
<div id="my-custom-app-framework-landingpage-header" class="my-custom-app-framework-foo">
<div><div><div><div><div><div>etc...</div></div></div></div></div></div>
</div>
Shadow DOM 的设计初衷就是要解决这些问题。它可以把 HTML、CSS 甚至 JavaScript 封装在组件内部,创建一个完全隔离的子树,就像 <video>
或 <details>
元素那样,避免外部影响。

哪些元素可以使用 Shadow DOM?
虽然 Shadow DOM 通常和自定义元素搭配使用,但它其实也可以附着在很多原生 HTML 元素上,比如:
css
<aside> <blockquote> <body> <div> <footer> <h1>--<h6> <header> <main> <nav> <p> <section> <span>
但有些元素像 <input>
和 <select>
,虽然内置了 shadow root,却不允许通过脚本访问,需要在 DevTools 中打开"显示用户代理 Shadow DOM"选项才能查看。


如何创建 Shadow DOM?
我们可以通过两种方式创建 Shadow Root:
1. 命令式创建
ini
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>Hello from the Shadow DOM!</p>';
document.body.appendChild(host);
如果使用 { mode: 'open' }
,你可以通过 host.shadowRoot
访问其内部内容;但如果设为 closed
,这个属性会返回 null
,外部无法访问。这种模式更适合处理敏感信息,比如银行账户组件。
✅ 推荐默认使用
closed
模式,除非你明确需要调试或数据暴露。
2. 声明式创建
无需 JavaScript,也可以使用 HTML 模板声明 Shadow Root:
xml
<my-widget>
<template shadowrootmode="closed">
<p>Declarative Shadow DOM content</p>
</template>
</my-widget>
对于一些纯静态组件,这是更安全、更兼容 SEO 的方式。如果你注册了这个自定义元素,还可以通过 ElementInternals
访问影子根:
scala
class MyWidget extends HTMLElement {
#internals = this.attachInternals();
#shadowRoot = this.#internals.shadowRoot;
connectedCallback() {
const p = this.#shadowRoot.querySelector('p');
console.log(p.textContent);
}
}
customElements.define('my-widget', MyWidget);
Shadow DOM 的高级配置项
除了 mode
,attachShadow
还支持以下三个选项:
1. clonable: true
在传统 DOM 中使用 cloneNode(true)
克隆一个附有 Shadow DOM 的元素时,默认不会拷贝影子内容。但现在你可以这样做:
xml
<template shadowrootmode="closed" shadowrootclonable>
<p>This is a test</p>
</template>
这样克隆出来的元素会包含 Shadow DOM 内容,非常适合组件复用。
2. serializable: true
启用后可以用 getHTML()
获取当前 Shadow DOM 的字符串表示,用于缓存或复制到另一个元素上。不过一定要小心,避免意外泄露敏感数据。
ini
const html = element.getHTML({ serializableShadowRoots: true });
你还可以使用 setHTMLUnsafe()
动态注入:
arduino
this.#shadow.setHTMLUnsafe(`
<template shadowrootmode="open" shadowrootserializable>
<p>Deep Shadow DOM Content</p>
</template>
`);
3. delegatesFocus: true
这个选项让宿主元素像 <label>
一样自动把焦点传递给内部的 <input>
:
ini
<template shadowrootmode="closed" shadowrootdelegatesfocus>
<input type="text" />
</template>
适合表单组件,但需要注意:默认情况下,Shadow DOM 内的字段不会自动参与表单提交或验证,需要搭配 ElementInternals
使用。
使用 <slot>
进行内容投递
Shadow DOM 并不是完全封闭的。你可以使用 <slot>
定义插槽,实现内容的注入与分发:
xml
<my-widget>
<template shadowrootmode="closed">
<h2><slot name="title"><span>Fallback Title</span></slot></h2>
<slot name="description"><p>A placeholder description.</p></slot>
<ol><slot></slot></ol>
</template>
<span slot="title">A Slotted Title</span>
<p slot="description">Example description</p>
<li>Foo</li><li>Bar</li><li>Baz</li>
</my-widget>
每个 <slot>
都可以监听 slotchange
事件,并通过 assignedNodes()
获取内容:
ini
slot.addEventListener('slotchange', () => {
const nodes = slot.assignedNodes();
});
这些插入的 DOM 节点仍属于宿主元素的"轻量级 DOM",可以直接用 document.querySelector
访问。
总结:从神秘到掌控
现在你应该已经理解:
- Shadow DOM 是什么?
- 什么时候应该用?
- 怎么用才安全高效?
它不仅是样式封装的利器,也是组件安全的基础设施。未来我们还会深入探讨 Shadow DOM 中的样式作用域、变量穿透、CSS scoping 等高级技巧。