0%

深入理解Shadow DOM

什么是 Shadow DOM?

Shadow DOM 是浏览器提供的一套原生 API,允许开发者在元素内部创建私有的 DOM 树和样式表。它主要用于实现 Web Components,使组件具有真正的封装性,避免样式和脚本冲突。

Shadow DOM 的核心概念包括:

  • Shadow root:附着在宿主元素上的私有 DOM 根。
  • Shadow tree:shadow root 中的 DOM 结构,对宿主以外的文档不可见。
  • 光树(light DOM):宿主元素自身的内容。
  • 样式封装:shadow tree 中的 CSS 不会泄漏到外部,外部样式也默认不会穿透。

创建 Shadow DOM

使用 attachShadow 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<my-component></my-component>
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.title { color: blue; }
</style>
<div class="title">Hello Shadow</div>
`;
}
}
customElements.define('my-component', MyComponent);
</script>

mode 可以是 openclosed

  • open:可以通过 element.shadowRoot 访问。
  • closed:外部无法通过 API 访问,但并不是真正隐藏,只是通过标准接口不可见。

支持的浏览器

自 Chrome 及其衍生浏览器、Firefox、Safari、Edge 都支持 Shadow DOM,但在较旧版本或某些 WebView 中可能需要 polyfill。可以使用 if (Element.prototype.attachShadow) 进行检测。

样式封装细节

默认情况下,shadow tree 的样式只作用于自身。可以通过 CSS 组合选择器 ::slotted()::part() 实现与外部的有限交互。

1
2
3
4
5
6
7
8
9
10
11
12
13
<template id="tmpl">
<style>
::slotted(span) {
color: red;
}

::part(button) {
background: green;
}
</style>
<slot name="foo"></slot>
<button part="button">Click</button>
</template>

使用 slot 来投影 light DOM 内容:

1
2
3
<my-component>
<span slot="foo">插槽内容</span>
</my-component>

slot 元素支持 name 属性,多插槽可用于复杂布局。

事件传播与封装

事件在 shadow tree 内部触发时会遵循正常的 DOM 事件传播,但进入宿主的过程有特殊规则:

  • 事件会穿越 shadow boundary,但只会传播到宿主元素,而不会暴露内部节点。
  • 可以使用 composed 属性控制事件是否可穿越边界。

例子:

1
2
3
4
5
6
7
8
this.shadowRoot.addEventListener('click', e => {
console.log('shadow click');
});

this.dispatchEvent(new CustomEvent('my-event', {
bubbles: true,
composed: true,
}));

只有当 composed: true 时,事件才能在 light DOM 中监听到。

深度插槽与选择器

CSS 中可以使用 :host:host() 选择器来为宿主元素设置样式:

1
2
3
4
5
6
7
:host {
display: block;
}

:host([hidden]) {
display: none;
}

另外 :host-context() 允许基于宿主元素在外部环境的状态来应用样式。

代替 shadow DOM 原生封装,可通过 ::part 公开组件内部的特定部分,使外部样式可以作用于它们。

1
2
3
my-component::part(button) {
font-size: 20px;
}

栈纵深与封装破坏

从 light DOM 访问 shadow DOM 内部只能通过 querySelectorshadowRoot 上执行;跨多个 shadow boundary 需要显式遍历,例如:

1
2
3
const inner = document.querySelector('outer-component')
.shadowRoot.querySelector('inner-component')
.shadowRoot.querySelector('.foo');

注意,这种访问违反封装原则,应尽量避免。

动态创建与模板

使用 <template>cloneNode 可以方便地在组件内部创建内容:

1
2
3
4
5
6
<template id="card-tmpl">
<style>/*...*/</style>
<div class="card">
<slot></slot>
</div>
</template>

然后在组件构造函数中:

1
2
3
const tpl = document.getElementById('card-tmpl');
const shadow = this.attachShadow({ mode: 'open' });
shadow.appendChild(tpl.content.cloneNode(true));

生命周期与自定义元素

Shadow DOM 通常与 Custom Elements 一起使用。Custom Elements 提供了回调:

  • connectedCallback - 元素插入文档时触发。
  • disconnectedCallback - 元素从文档移除。
  • attributeChangedCallback - 属性变化。
  • adoptedCallback - 元素移动到新文档。

通过这些可以管理 shadow tree 的初始化和更新。

动态样式与 CSS Variables

CSS 变量在 shadow DOM 中可以跨边界作用:

1
2
3
4
5
6
7
:host {
--main-color: blue;
}

.slot-content {
color: var(--main-color);
}

父级定义的变量会传递给 shadow DOM,而在 shadow 中定义的变量不会传播到外部。

性能考虑

shadow DOM 的创建会带来一定的开销,尤其在大量元素时需要注意:

  • 避免在短时间内频繁 attach 和 detach shadow root。
  • 尽量复用 shadow DOM 实例,而不是销毁重建。

使用场景

  • 封装复杂组件:日期选择器、表格、下拉菜单等。
  • 第三方组件库:避免样式冲突。
  • 独立可复用的 UI 模块。

常见问题

  • 为什么样式不起作用? 检查是否忘记 ::slotted() 或使用了错误的选择器。
  • 事件无法捕获? 确认事件是否设置了 composed: true
  • 无法获取 shadowRoot? 如果使用 mode: 'closed',则无法从外部访问。

调试工具

Chrome DevTools 在 Elements 面板中支持显示 shadow tree,选择元素后可以查看其 shadow roots。开发者也可以在控制台使用 $0.shadowRoot

进阶技巧

  • 混合模式:有时为了兼容,可以在某些情况下不使用 shadow DOM,而仅使用 CSS Modules 或 BEM 命名空间。
  • polyfill:对旧浏览器或轻量级环境,可用 @webcomponents/shadydom 做降级处理。

总结

Shadow DOM 是构建现代 Web Components 的基石。通过正确使用它可以实现强大的封装、样式隔离和可重用性。掌握其 API、事件传播规则和样式穿透机制,是开发高质量组件库的基础。尽管它并非适用于所有场景,但在需要隔离复杂 UI 时,是非常值得投入的技术。

继续探索其他 Web Components 标准,如 Custom Elements 和 HTML Templates,可以进一步提升前端架构的模块化水平。