← 返回文章

JavaScript / CSS / Web Components

Shadow DOM 实战:构建真正可隔离的 Web Component

把一个组件复制到另一个项目,最容易出问题的往往不是 JavaScript,而是样式。宿主页面的一条 button { ... } 改掉组件按钮,组件内部的 .title 又意外影响外部同名元素。为了避免冲突,团队不断增加命名前缀,却仍无法保证第三方页面没有更强规则。

Shadow DOM 为 Web Components 提供 DOM 与样式封装。组件可以拥有自己的内部树、局部样式和插槽,同时通过明确的属性、事件与 CSS 自定义属性向外暴露能力。它不是“绝对隔离的 iframe”,但非常适合构建可复用的浏览器原生组件。

一、创建第一个 Shadow Root

先定义一个自定义元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class UserBadge extends HTMLElement {
constructor() {
super();

const shadow = this.attachShadow({ mode: 'open' });

shadow.innerHTML = `
<style>
:host {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--user-badge-color, #1f2328);
}

.avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
background: #e7eaf0;
}
</style>

<span class="avatar" aria-hidden="true"></span>
<strong><slot></slot></strong>
`;
}
}

customElements.define('user-badge', UserBadge);

页面中使用:

1
<user-badge>雯风未动</user-badge>

attachShadow({ mode: 'open' }) 创建 Shadow Root。内部 .avatar 不会被页面普通 .avatar 选择器匹配,内部 <style> 也不会直接影响外部文档。

二、宿主、Shadow Tree 和 Light DOM

理解三个概念就能理清大部分边界:

  • 宿主(host):页面里的 <user-badge> 元素。
  • Shadow TreeshadowRoot 内部的私有结构。
  • Light DOM:用户写在 <user-badge>...</user-badge> 之间的外部内容。

<slot> 把 Light DOM 内容投影到 Shadow Tree 的指定位置。具名插槽可以承载多个区域:

1
2
3
4
5
<profile-card>
<img slot="avatar" src="avatar.jpg" alt="庞康华" />
<span slot="name">庞康华</span>
<p>前端开发者,关注 Web 性能与工程化。</p>
</profile-card>
1
2
3
4
5
<article class="card">
<slot name="avatar"></slot>
<h2><slot name="name"></slot></h2>
<div class="summary"><slot></slot></div>
</article>

插槽不会把 Light DOM 节点真正移动进 Shadow Tree;它建立的是渲染上的分配关系。页面脚本仍可以通过宿主的普通子节点访问这些内容。

三、使用 :host 和 CSS 变量设计主题接口

Shadow 内部通过 :host 选择宿主:

1
2
3
4
5
6
7
8
9
10
11
12
13
:host {
display: block;
border: 1px solid var(--profile-card-border, #d0d5dd);
border-radius: var(--profile-card-radius, 16px);
}

:host([compact]) {
border-radius: 10px;
}

:host-context(.dark-theme) {
--profile-card-border: #424854;
}

外部页面可以设置组件公开的 CSS 自定义属性:

1
2
3
4
profile-card {
--profile-card-border: #bfd0ff;
--profile-card-radius: 20px;
}

CSS 变量能够跨过 Shadow 边界继承,是建立主题令牌的常用方式。变量名应带组件前缀并写进文档;如果直接让所有全局令牌渗透进来,组件仍会和宿主设计系统强耦合。

四、::slotted 能做什么,不能做什么

Shadow 样式可以选择直接分配给插槽的元素:

1
2
3
4
5
6
::slotted(img) {
width: 4rem;
height: 4rem;
object-fit: cover;
border-radius: 50%;
}

::slotted() 只能匹配被分配的顶层节点,不能继续深入选择其后代:

1
2
3
4
/* 不会像普通后代选择器那样深入工作 */
::slotted(.content p) {
margin: 0;
}

如果组件需要控制复杂 Light DOM 内部结构,应缩小插槽自由度、提供多个明确插槽,或把需要统一控制的结构放进 Shadow Tree,而不是依赖深层外部标记。

五、属性负责输入,事件负责输出

Web Component 的公共接口可以保持和原生元素相似:属性传入状态,事件通知变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ToggleSwitch extends HTMLElement {
static observedAttributes = ['checked'];

attributeChangedCallback() {
this.render();
}

connectedCallback() {
this.render();
}

render() {
const checked = this.hasAttribute('checked');
this.shadowRoot?.querySelector('button')
?.setAttribute('aria-pressed', String(checked));
}

emitChange(checked) {
this.dispatchEvent(new CustomEvent('toggle-change', {
detail: { checked },
bubbles: true,
composed: true,
}));
}
}

自定义事件想跨过 Shadow 边界让外部监听,需要根据场景设置 composed: true,通常也会设置 bubbles: true。不要把整个内部对象和可变 DOM 节点塞进 detail;传递稳定、最小的领域数据更容易维护。

浏览器会对跨边界事件进行 retargeting,外部看到的 event.target 可能是宿主元素而不是内部按钮。这是封装的一部分,外部代码不应依赖内部 DOM 结构。

六、open 与 closed 不是安全等级

mode: 'open' 允许通过 element.shadowRoot 访问内部树;closed 会让该属性返回 null。但 closed 不是保护密钥、支付信息或商业逻辑的安全边界。页面和组件运行在同一 JavaScript 环境,仍可能通过其他方式观察或修改行为。

大多数业务组件使用 open 更便于测试、调试和集成。选择 closed 应有明确封装理由,不能把它描述成“防止攻击”。

同样,Shadow DOM 也不是 iframe:网络权限、JavaScript 全局环境和 CSP 仍属于同一个页面。

七、无障碍不会自动完成

Shadow DOM 的语义仍来自内部真实元素。能用 <button> 就不要用 <div role="button">;图片仍需要合适的 alt;标题层级仍要和页面结构配合。

需要特别检查:

  • 宿主上的标签是否能正确命名内部控件;
  • 键盘焦点是否按预期进入和离开组件;
  • 状态变化是否通过原生属性或 ARIA 表达;
  • 错误信息与表单控件的关联是否跨边界有效;
  • 页面缩放和高对比模式下样式是否仍可读。

表单关联、自定义焦点管理等复杂场景需要额外 API 与测试。不要因为视觉隔离成功,就假设辅助技术也获得了完整信息。

八、性能与渲染策略

示例使用 innerHTML 便于讲解,但大型列表中每个实例都重新解析一份模板和 style 可能产生开销。可以复用 <template>

1
2
3
4
5
6
7
8
9
10
const template = document.createElement('template');
template.innerHTML = `...`;

class UserBadge extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.append(template.content.cloneNode(true));
}
}

更新时尽量修改具体文本和属性,不要每次状态变化都重建整个 Shadow Tree,否则会丢失焦点、选择状态和内部节点引用。

九、落地检查清单

  1. 组件真的需要样式和 DOM 隔离,还是普通语义组件已经足够?
  2. 属性、方法、事件和 CSS 变量是否形成了清晰公共接口?
  3. 插槽内容的结构自由度是否可控?
  4. 自定义事件是否需要 bubblescomposed
  5. 是否错误地把 closed Shadow Root 当作安全边界?
  6. 键盘、读屏、缩放和高对比模式是否实际测试?
  7. 大量实例是否复用模板并避免整树重建?

Shadow DOM 最有价值的地方不是把内部节点藏起来,而是迫使组件建立边界:内部结构自行负责,外部只能通过公开输入、事件和主题令牌交互。边界越清楚,组件跨项目复用时越不容易被偶然的全局样式击穿。

参考资料