Shadow DOM 封装与样式
基于 WHATWG HTML/DOM 现行标准与各浏览器 Baseline 状态 · 核于 2026-07
速查
- 四个术语:shadow host(挂影子树的宿主节点)、shadow tree(影子树)、shadow boundary(影子边界)、shadow root(影子树根节点,
ShadowRoot实例)。 - 创建:命令式
el.attachShadow({ mode: "open" });声明式<template shadowrootmode="open">(解析时自动转为影子根,详见下一页)。 - 谁能挂:自治自定义元素 + 一组白名单内置元素(
article/aside/blockquote/body/div/footer/h1-h6/header/main/nav/p/section/span);每元素至多一棵影子树,重复attachShadow()抛错(声明式影子根是唯一例外:被清空后返回)。 mode: "open" | "closed":open 时外部可经el.shadowRoot进入;closed 时el.shadowRoot返回null。closed 不是安全机制(可被attachShadow补丁等手段绕过),内置元素级的"真隔离"做不到,常规组件用 open 即可。delegatesFocus: true:点击/聚焦宿主的不可聚焦区域时,焦点自动委托给影子树内第一个可聚焦元素,且宿主获得:focus/:focus-visible匹配——包装输入类组件的焦点体验标配。- 其余选项:
slotAssignment: "named" | "manual"(插槽分发模式,见下一页)、clonable(cloneNode()时是否连影子树一起克隆)、serializable(允许getHTML()序列化)、customElementRegistry(绑定局部注册表,见上一页)。 - JS 边界:页面的
document.querySelector()穿不进影子树;进入唯一入口是el.shadowRoot(open 时);影子树内部用this.shadowRoot.querySelector()自查。 getRootNode():影子树内节点的node.getRootNode()返回所在 shadow root(而非 document)——判断"我在哪棵树里"的标准方法。- CSS 双向封装:页面样式选择器不进影子树,影子树内
<style>不外泄;但可继承属性(color/font-family等)与 CSS 自定义属性(变量) 会沿 DOM 穿透边界继承——这是留给主题定制的正门。 - 样式注入两手段:影子树内放
<style>(声明式、简单直接);Constructable Stylesheets——new CSSStyleSheet()+sheet.replaceSync(css)+shadow.adoptedStyleSheets = [sheet](同一 sheet 多实例共享、只解析一次、改一处全生效)。 :host三件套::host选宿主自身;:host(.foo)宿主匹配选择器时才命中;:host-context(.dark)宿主的祖先匹配时命中(暗色主题适配惯用,注意 Firefox 长期未实现)。::slotted(selector):影子树内样式化被分发的 light DOM 节点,只能选中插槽的直接顶层节点,选不到其后代;且只能作用于元素(文本节点不行)。::part()+exportparts:影子树内元素标part="label",外部用my-el::part(label)样式化——官方外部样式接口;嵌套组件用exportparts把内层 part 转发出来。- 事件 retargeting:影子树内冒泡出来的事件,在外部看
event.target被重定向为宿主元素(封装不泄内部结构);影子树内部监听时 target 不变。 composed:事件能否穿越影子边界由composed标志决定——大多数 UI 原生事件(click 等)为true;new CustomEvent()默认composed: false,组件对外发事件必须显式{ bubbles: true, composed: true },忘了就是"外面永远听不到"的经典坑。composedPath():返回事件穿越的完整路径(含影子树内节点,closed 树的内部节点除外);path[0]可拿到真实起点。dir/lang继承:影子树继承宿主的这两个属性语境,无需手动同步。
一、概念:一棵"挂在元素上的私有 DOM 树"
Shadow DOM 允许把一棵隐藏的 DOM 树挂到常规节点上独立渲染。浏览器自己早就在用这套机制——<video> 的控制条、<input type="range"> 的滑轨,都是内置元素的影子树(在 DevTools 打开 "Show user agent shadow DOM" 可见)。Web Components 把同样的能力开放给了开发者。
常规 DOM 影子树
───────── ─────────
<body>
└─ <user-card> ←─ shadow host
╲ (shadow boundary)
└─ #shadow-root ←─ shadow root
├─ <style>…</style>
├─ <div class="name">…</div>
└─ <slot>…</slot>四个术语必须先立住:shadow host(宿主)、shadow tree(影子树)、shadow boundary(边界)、shadow root(影子根,ShadowRoot 实例)。封装的全部故事都发生在"边界"两侧。
二、attachShadow():选项全解
const shadow = el.attachShadow({
mode: "open", // 必填:"open" | "closed"
delegatesFocus: false, // 焦点委托
slotAssignment: "named", // 插槽分发:"named"(默认)| "manual"
clonable: false, // cloneNode() 时是否连影子树一起克隆
serializable: false, // 是否允许 getHTML() 序列化这棵影子树
customElementRegistry: undefined, // 绑定局部注册表(Scoped Registries)
});mode: "open":外部可以el.shadowRoot拿到影子根,DevTools、测试工具、集成方都友好——常规组件的默认选择。mode: "closed":el.shadowRoot返回null。注意这不是安全机制——创建者自己持有的引用、对attachShadow打补丁等手段都能绕过,它表达的只是"请勿依赖内部结构"的强约定。内置元素那种真正无法触碰的隔离,作者代码做不到。delegatesFocus: true:宿主的不可聚焦区域被点击/调用focus()时,焦点自动落到影子树内第一个可聚焦元素上,同时宿主整体获得:focus样式匹配——自定义输入框、搜索框这类"包壳组件"的焦点体验必开项。slotAssignment:"named"按slot/name属性自动分发(默认);"manual"改为用HTMLSlotElement.assign()手动指派,详见下一页。clonable: true:cloneNode(true)时影子树随之克隆(声明式影子根默认可克隆)。serializable: true:允许el.getHTML({ serializableShadowRoots: true })把影子树序列化输出——SSR/快照场景配套。customElementRegistry:把这棵影子树的自定义元素解析绑定到局部注册表,见自定义元素页。
限制:并非任何元素都能挂影子树——自治自定义元素和一组白名单内置元素(article、aside、blockquote、body、div、footer、h1-h6、header、main、nav、p、section、span)可以;<a>、<img>、<button> 等不行(它们自己有内部结构或语义)。每个元素至多一棵影子树,对已有命令式影子根的元素再调 attachShadow() 抛 NotSupportedError(唯一例外:元素带声明式影子根时,attachShadow() 不抛错,而是清空该影子根并返回——这是 SSR 水合的关键行为,见下一页)。
三、对 JavaScript 的封装边界
页面脚本的常规 DOM 查询止步于影子边界:
// 页面里有 <user-card>,其影子树内含 <span>
document.querySelectorAll("span"); // 找不到影子树里的 span
// open 模式的唯一入口
const card = document.querySelector("user-card");
card.shadowRoot.querySelectorAll("span"); // 能找到
// closed 模式
card.shadowRoot; // null,外部无门反过来,影子树内部的代码用 this.shadowRoot.querySelector() 查自己的树,互不干扰。判断"某节点位于哪棵树"用 getRootNode():
innerSpan.getRootNode(); // 返回所在的 ShadowRoot(而非 document)
innerSpan.getRootNode({ composed: true }); // 穿透影子边界,返回最外层 documentNode.isConnected 对影子树内节点同样生效(宿主连着文档即为 true)。
四、对 CSS 的封装:边界内外与两条穿透通道
双向隔离是默认行为:
/* 页面全局样式:选择器进不了影子树 */
span { color: blue; } /* 影子树内的 span 不受影响 */影子树内的 <style> 同样只作用于本树。但有两条设计好的穿透通道,这是主题化的正门:
- 可继承属性照常继承:
color、font-family、line-height等可继承属性会从宿主继承进影子树——所以"组件字体跟随页面"不需要做任何事; - CSS 自定义属性(变量)穿透边界:外部定义的
--brand-color在影子树内var(--brand-color)可读——组件对外暴露"样式 API"的标准方式。
/* 页面侧:定调 */
user-card { --card-accent: #d33; }/* 影子树内:消费变量,并给默认值兜底 */
.name { color: var(--card-accent, #333); }给影子树注入样式的两种手段
手段一:影子树内直接放 <style>(通常随 <template> 一起克隆)——声明式、直观,适合样式少、无需跨实例共享的组件。
手段二:Constructable Stylesheets(可构造样式表)——程序化创建样式表对象,多棵影子树共享同一实例:
// 创建 + 填充(replaceSync 同步版 / replace 返回 Promise)
const sheet = new CSSStyleSheet();
sheet.replaceSync("span { color: red; border: 2px dotted black; }");
// 采纳:adoptedStyleSheets 是数组,可与其他 sheet 并存
shadow.adoptedStyleSheets = [sheet];优势:浏览器只解析一次,成百上千个组件实例共享;运行时改 sheet 一处,所有采纳者同步生效。组件库的通用做法是"基础样式走共享 constructed sheet,实例级差异走 CSS 变量"。注意声明式 Shadow DOM 的 HTML 里写不了 constructed sheet(它是纯 JS 对象),SSR 场景需在水合时补挂。
五、样式钩子::host 系、::slotted()、::part()
影子树内部的样式表可用三组专属选择器:
/* :host —— 选中宿主元素自身(从影子树内部"向外"给宿主设样式) */
:host {
display: inline-block; /* 自定义元素默认 display:inline,几乎总要改 */
}
/* :host(selector) —— 宿主匹配 selector 时才命中:按宿主的类/属性变体 */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* :host-context(selector) —— 宿主的祖先匹配时命中:典型是主题适配 */
:host-context(.theme-dark) {
background: #222;
}
/* ::slotted(selector) —— 样式化被分发进来的 light DOM 节点 */
::slotted(span) {
color: #666;
}三个高频注意点:
:host的优先级低于页面里直接写在宿主标签上的规则(外部可覆盖内部的宿主默认样式,符合"使用者说了算"的直觉);::slotted()只能选中分发内容的顶层节点——::slotted(li)无效如果<li>是被分发<ul>的后代;也只能选元素,纯文本节点样式化不了(可继承属性除外);:host-context()在 Firefox 长期未实现,跨浏览器主题适配更稳的路线是 CSS 变量或宿主上的属性/类。
影子树外部想样式化内部结构,官方接口是 part / ::part():
<!-- 影子树内:把允许外部定制的节点标上 part 名 -->
<template shadowrootmode="open">
<label part="label">用户名</label>
<input part="field" />
</template>/* 页面侧:显式样式化被导出的 part */
my-field::part(label) { font-weight: bold; }
my-field::part(field) { border-radius: 6px; }嵌套组件的内层 part 默认不透传,外层需用 exportparts 显式转发(可改名):
<!-- 外层组件影子树内:把内层组件的 part 导出到更外层 -->
<inner-field exportparts="label, field:inner-field"></inner-field>设计约定:CSS 变量管"值"(颜色/尺寸),::part() 管"块"(某个内部元素的整体样式),二者构成组件的完整对外样式 API;::part() 之后还可接 :hover、:state() 等伪类。
六、事件:retargeting 与 composed
Retargeting(重定向):影子树内元素触发的事件冒泡出边界后,外部监听器看到的 event.target 被替换为宿主元素——外界不该知道(也不必知道)组件内部按钮长什么样。影子树内部的监听器看到的 target 仍是真实内部节点。
composed 标志决定事件是否有资格穿越边界:
- 大多数原生 UI 事件(
click、input、keydown等)composed: true,正常穿出; - 少数如
slotchange、多数非 UI 事件不穿出; new CustomEvent()默认composed: false且bubbles: false——自定义组件对外派发事件的完整样板必须两者都开:
// 组件内部:对外发事件的标准姿势
this.dispatchEvent(
new CustomEvent("change", {
bubbles: true, // 允许冒泡
composed: true, // 允许穿越影子边界 —— 忘了它,外面永远听不到
detail: { value: this._value },
}),
);需要"看穿"重定向时用 event.composedPath():返回事件的完整传播路径数组,composedPath()[0] 是真实起点(closed 影子树的内部节点会被隐去)。event.composed 属性可查询事件本身的穿越资格。
七、易错点
- 自定义元素默认
display: inline:影子树里写好的宽高布局"不生效",九成是忘了:host { display: block }。 - closed 当安全机制:closed 只挡
el.shadowRoot这一条路,防不了有心人——它是 API 约定,不是沙箱。 - CustomEvent 忘开
composed:组件事件"外面监听不到"的第一嫌疑。 ::slotted()选后代:只能选顶层被分发节点,::slotted(.item span)这类写法无效。- 全局样式失效的"惊讶":normalize.css、工具类框架(Tailwind 等)都进不了影子树——组件样式必须自带,或通过 constructed sheet 显式共享。
- 可继承属性"漏进来"的反向惊讶:
color/font从页面继承进影子树是规范行为,要完全重置可在:host上all: initial。 - 重复
attachShadow()抛错:NotSupportedError;只有声明式影子根允许被attachShadow()"接管"(清空并返回)。
封装边界立好了,下一页解决"结构从哪来、外部内容怎么进来、以及不靠 JS 怎么直出这棵树":template、slot 与声明式 Shadow DOM。