目录
4721 字
24 分钟
Web Components 实战:生命周期、Shadow DOM 边界与声明式 Shadow DOM 的代价

一、先问:你要复用的是组件,还是运行环境#

假设你维护着一份公司内部的设计系统。需求很具体:同一套按钮、输入框、日期选择器,要同时出现在三个地方——一个 Astro 的静态营销页、一个还在用 jQuery 的后台、一个 React 管理端。

框架组件在这里很难复用。React 组件离开 React 运行时就不工作,Vue 单文件组件同样如此。Web Components 给出的答案是:把组件的边界交给浏览器,而不是交给框架。自定义元素的注册、Shadow DOM 的封装、属性的监听,都由浏览器实现。任何能操作 DOM 的环境都能用,包括服务端渲染出来的静态 HTML。

这个卖点是真的,但代价也是真的。它换来的不是「更轻的框架」,而是「你要自己补齐框架本来帮你做的事」:属性到状态的同步、SSR 与 hydration、样式注入、表单集成、测试环境。下面这几节讲的就是这些补齐工作里最容易出错的地方。

先给一个判断表,后面再逐条展开:

你的处境更合适的选择
单一框架、单一应用框架组件,省掉下面全部成本
多框架、多技术栈共享一套 UIWeb Components
只需要静态 HTML 里加一点交互原生元素 + 少量 JS,未必需要自定义元素
表单密集、强依赖表单校验框架组件,或接受 ElementInternals 的额外工作
大量依赖设计系统 token 深度定制可以,但要接受 ::part 的表达力上限

二、自定义元素的生命周期,比框架组件多三个陷阱#

框架组件的生命周期是「挂载—更新—卸载」,由框架调度。自定义元素的生命周期由 HTML 解析器、DOM 操作和注册时机共同决定,因此多了几个框架里不会遇到的顺序问题。

陷阱一:constructor 里读不到属性和子节点#

这是第一个会让人困惑的地方。自定义元素的 constructor 在两种时机被调用:

  1. 注册之后,解析器遇到对应标签,创建元素时;
  2. 元素已经在 DOM 里,之后才调用 customElements.define() 时,浏览器逐个「升级」它们。

第一种情况下,constructor 执行时起始标签才刚被解析,属性和子节点都还没就位。第二种情况下,属性和子节点都在,但规范同样不允许你在 constructor 里读取它们。规范的要求很直接:构造函数里不得检查属性、不得检查子节点、不得添加属性或子节点。

所以 constructor 只适合做两件事:初始化内部状态,以及创建 Shadow Root 和其中的静态结构。

class CopyButton extends HTMLElement {
static observedAttributes = ["value"];
#value = "";
#status = null;
#abort = null;
constructor() {
super();
// 创建 shadow root、写入静态结构是允许的
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<button type="button" part="button">
<slot name="label">复制</slot>
</button>
<span part="status" role="status"></span>
`;
this.#status = root.querySelector('[part="status"]');
}
}

注意 attachShadow 只能调用一次,第二次会抛 NotSupportedError。这一点会在第五节的声明式 Shadow DOM 里变成一个真实的兼容性问题。

陷阱二:connectedCallback 可能执行很多次#

框架里「挂载」通常只有一次。自定义元素不是:节点被移动到另一个父节点时,会先触发 disconnectedCallback,再触发一次 connectedCallback。列表排序、拖拽、把元素从一个容器挪到另一个容器,都会让它重新连接。

意味着 connectedCallback 必须幂等。不要在里面对全局状态做「加一」这类操作,也不要在里面无条件地添加事件监听。

connectedCallback() {
// 已经连接过就直接返回;节点被移动时会再次进入这里
if (this.#abort) return;
this.#abort = new AbortController();
document.addEventListener("copy", this.#onCopy, {
signal: this.#abort.signal,
});
}
disconnectedCallback() {
this.#abort?.abort();
this.#abort = null;
}

AbortController 的 signal 很适合这个场景:卸载时一次性移除所有监听,不用逐个记住引用。

另外两个相关的细节:

  • disconnectedCallback 不保证在页面关闭时触发。 不要把它当成「保存状态」的时机,用户直接关标签页时它可能根本不执行。持久化应该放在明确的用户动作里。
  • 解析期间 connectedCallback 早于子节点解析完成。 元素在起始标签处就被插入文档,此时 this.children 往往是空的。需要等待子节点时,用 queueMicrotask 之后的时机,或者监听 slot 的 slotchange。

陷阱三:注册顺序决定回调顺序#

如果元素先出现在 DOM 里、之后才注册,升级过程是固定顺序的:

  1. 调用 constructor;
  2. 对元素上已有的每个 observedAttributes 中的属性,逐个调用 attributeChangedCallback,此时 oldValue 是 null;
  3. 调用 connectedCallback。

而如果是先注册、解析器再创建元素,顺序变成:constructor → 逐个 attributeChangedCallback(属性解析到时触发)→ connectedCallback。两种情况里 attributeChangedCallback 都早于 connectedCallback,所以「在 connected 时读取属性」是安全的,但「在 connected 时假设属性已经全部到齐」则不一定——如果属性是通过 JS 在之后设置的,回调会随后到来。

还有一个默认样式问题#

自定义元素没有浏览器默认样式,它的 display 是 inline。很多「组件没显示出来」的问题,最后都是忘了写 display:

copy-button {
display: inline-block;
}

或者写在组件内部:

:host {
display: inline-block;
}

后者只在 Shadow Root 存在时生效。如果脚本加载失败,元素就退回成一个没有样式的 inline 盒子。这个问题在第五节会有一个更好的解法。

为了避免脚本加载期间的闪烁,可以配合 :defined:

@media (scripting: enabled) {
copy-button:not(:defined) {
visibility: hidden;
}
}

把「隐藏未定义元素」限制在脚本可用的环境里,这样脚本被禁用时内容仍然是可见的,而不是永远藏起来。

三、attribute 与 property:两份状态,一条同步规则#

Web Components 里有两套并行的状态:HTML 属性(attribute)永远是字符串,JS 属性(property)可以是任意类型。框架会自动帮你做这层转换,自定义元素要自己写。

常见的约定是单向同步:attribute 是声明式的输入,property 是运行时的值;attribute 变化时更新 property,property 变化时按需反射回 attribute。

class ProgressRing extends HTMLElement {
static observedAttributes = ["value"];
#value = 0;
get value() {
return this.#value;
}
set value(next) {
const clamped = Math.min(100, Math.max(0, Number(next) || 0));
if (clamped === this.#value) return;
this.#value = clamped;
// 反射回 attribute;下一轮 attributeChangedCallback 会因为值相等而提前返回
if (this.getAttribute("value") !== String(clamped)) {
this.setAttribute("value", String(clamped));
}
this.#render();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === "value" && oldValue !== newValue) {
this.value = newValue;
}
}
#render() {
this.style.setProperty("--progress", `${this.#value}%`);
}
}

这段代码里有两个容易忽略的点。

第一,attributeChangedCallback 只在值真正变化时触发,oldValue === newValue 时不会调用。同时,它只对 observedAttributes 里列出的属性生效,其他属性变了不会有任何通知。

第二,setter 里先比较再写 attribute,是为了打断循环:set value → setAttribute → attributeChangedCallback → set value。第二次进入 setter 时 clamped === this.#value 成立,直接返回。如果没有这个比较,setAttribute 每次都会触发回调,虽然浏览器对相同值的 setAttribute 仍会触发 attributeChangedCallback(值相同则 oldValue === newValue,回调仍会调用但可提前返回),但显式判断能让意图更清楚,也避免不必要的渲染。

布尔属性要按「存在」判断#

HTML 的布尔属性语义是「存在即真」,disabled="false" 依然是禁用状态。所以不要写 this.getAttribute("disabled") === "true":

get disabled() {
return this.hasAttribute("disabled");
}
set disabled(next) {
this.toggleAttribute("disabled", Boolean(next));
}

命名映射要手动维护#

attribute 习惯用 kebab-case(max-length),property 习惯用 camelCase(maxLength)。两者不会自动对应,需要自己写映射:

static observedAttributes = ["max-length"];
attributeChangedCallback(name, _old, next) {
if (name === "max-length") this.maxLength = next;
}

这也是 Lit 这类库存在的主要理由之一:它们把属性声明、类型转换和反射规则收敛成了一份声明式配置。是否值得引入,取决于你有多少个组件要维护。

四、Shadow DOM 的边界:哪些东西能穿过去#

Shadow DOM 的封装经常被描述成「样式隔离」,但它其实是一组规则,而不是一堵墙。搞清楚哪些能穿透,比记住「隔离」两个字有用得多。

穿不过去的#

  • 普通选择器。 外部的 .card button 选不到 shadow 内部的 <button>,document.querySelector 也一样。
  • id 的作用域。 shadow 内部的 id 和外部的 id 互不冲突,label[for] 也指不过去。
  • 事件目标。 从外部监听时,event.target 会被重定向为宿主元素。

最后一条值得展开。点击组件内部的按钮,外部监听器看到的 event.target 是 <copy-button>,不是内部的 <button>:

document.querySelector("copy-button").addEventListener("click", (event) => {
console.log(event.target); // <copy-button>
console.log(event.composedPath()[0]); // 内部真正的 <button>
});

这是有意的设计:内部结构属于组件的实现细节,不应该泄漏给外部。需要知道真实目标时,用 composedPath()。另外,只有 composed: true 的事件才能穿出 shadow 边界,click、focus 这类 UI 事件默认是 composed 的,自定义事件则需要自己声明。

能穿过去的#

  • 继承的 CSS 属性。 color、font-family、line-height 这些会继承进 shadow 内部。
  • CSS 自定义属性。 --brand-color 可以穿透边界,这是设计系统向组件传 token 的主要通道。
  • ::part()。 组件用 part="button" 暴露内部节点,外部用 ::part(button) 定制。
  • ::slotted()。 只能选中 slot 的直接子节点,不能再往下深入。

这里的常见误解是:放进 slot 的元素「属于组件」。实际上它们仍然在 light DOM 里,样式由外部文档决定。::slotted() 只能在组件内部写,而且只能触及直接子节点,所以它更像一个有限的钩子,而不是完整的样式入口。

/* 组件内部:暴露 part,并用自定义属性作为主题入口 */
:host {
display: inline-flex;
gap: 0.5rem;
--copy-accent: #2563eb;
}
button {
color: var(--copy-accent);
border: 1px solid currentColor;
}
::slotted(svg) {
width: 1em;
height: 1em;
}
/* 外部:可以改 part 的样式,也可以覆盖自定义属性 */
copy-button::part(button) {
border-radius: 6px;
}
copy-button {
--copy-accent: #7c3aed;
}

如果组件内部还有嵌套的 shadow 树,需要 exportparts 把内层的 part 再导出一次,否则外部只能看到最外层的 part。

焦点与可访问性#

如果组件内部包了一个输入框,点击宿主元素时焦点不会自动进去。attachShadow({ mode: "open", delegatesFocus: true }) 可以让宿主获得焦点时把焦点转交给内部第一个可聚焦元素。

但 delegatesFocus 只解决焦点转移,不解决语义。一个自制的下拉框不会因为长得像 <select> 就拥有它的键盘行为。ARIA 角色、aria-expanded、方向键处理仍然要自己实现,这部分工作量经常被低估。

五、声明式 Shadow DOM:SSR 的正确答案,也是 hydration 的新坑#

到目前为止,所有组件都要等 JS 执行、调用 attachShadow、再渲染内容。这条路径在静态页面上有两个问题:首屏会出现「先空后满」的闪烁;在 JS 被禁用或加载失败时,组件里什么都没有。

声明式 Shadow DOM(Declarative Shadow DOM,DSD)让 HTML 解析器直接创建 shadow root,不需要等脚本:

<copy-button value="hello">
<template shadowrootmode="open">
<style>
:host {
display: inline-flex;
gap: 0.5rem;
}
button {
color: var(--copy-accent, #2563eb);
}
</style>
<button type="button" part="button">
<slot name="label">复制</slot>
</button>
<span part="status" role="status"></span>
</template>
<span slot="label">复制链接</span>
</copy-button>

解析器读到这个 <template shadowrootmode="open"> 时,会把它作为宿主元素的 shadow root 挂上去,template 本身不会成为页面节点。页面上看到的样式和结构在 HTML 到达时就已经成立。

几个必须注意的约束:

  • 属性名是 shadowrootmode,不是早期草案里的 shadowroot。
  • <template> 必须是宿主元素的直接子节点,放在第一个子节点的位置最稳妥。
  • 嵌套的 DSD 需要在外层 template 内再写一层,解析器会逐层处理。

客户端不能再 attachShadow#

DSD 之后,connectedCallback 执行时 this.shadowRoot 已经存在。如果构造函数里还是无条件 attachShadow,就会抛 NotSupportedError。正确的写法是复用已经存在的 root:

class CopyButton extends HTMLElement {
constructor() {
super();
// DSD 已经创建过 shadow root;没有时才自己建
const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
this.#hydrate(root);
}
#hydrate(root) {
const button = root.querySelector("button");
const status = root.querySelector('[part="status"]');
button?.addEventListener("click", () => this.#copy(status));
}
}

注意这里的事件绑定发生在构造函数里。升级时子节点可能还没解析完,但 DSD 的 shadow 内容在解析器创建 shadow root 时就已经写入了,所以 querySelector 能拿到。对于没有 DSD 的普通路径,constructor 里只有你自己写入的结构,同样拿得到。真正拿不到的只有 light DOM 里的子节点。

浏览器支持与降级#

DSD 的支持情况是:Chrome 90+、Safari 16.4+、Firefox 123+。到 2026 年,主流浏览器都已经可用,但如果你的用户群里还有旧版浏览器,需要准备降级方案。

两种常见做法:

  1. 保留客户端渲染路径。 也就是上面那段 this.shadowRoot ?? this.attachShadow(...),加上一个在解析后把普通 <template> 转换成 shadow root 的 polyfill。
  2. 不依赖 shadow 的静态降级。 用 @media (scripting: none) 或 :not(:defined) 给未升级状态一套可用的普通样式,保证最坏情况下内容仍然可读。

抓取与序列化#

Shadow DOM 里的文本不在文档的普通 DOM 树里。搜索引擎主流实现会执行脚本并展开 shadow 内容,但不要假设所有抓取器、所有摘要工具都能看到它。对 SEO 关键的正文,不要放进组件的 shadow 里。

同样,host.innerHTML 不会包含 shadow root 的内容,做快照、做 HTML 对比、做端到端断言时都要意识到这一点。

六、表单关联:自定义输入框必须走 ElementInternals#

如果组件只是一个按钮或展示块,到这里就够了。但只要它要参与表单提交,就必须处理一个隐藏的边界:外层 <form> 看不到 shadow 内部的 <input>。

具体表现是:

  • new FormData(form) 里没有这个字段;
  • form.checkValidity() 不会校验它;
  • form.reset() 不会重置它;
  • 外部 <label for="..."> 指不到它。

在 shadow 里放一个原生 <input> 并不能解决这些问题,因为表单关联是按 DOM 树查找的。正确做法是让宿主元素自己成为表单控件:

class RatingInput extends HTMLElement {
static formAssociated = true;
static observedAttributes = ["value", "required"];
#internals = null;
#value = 0;
constructor() {
super();
this.#internals = this.attachInternals();
const root = this.attachShadow({ mode: "open", delegatesFocus: true });
root.innerHTML = `
<style>
:host { display: inline-flex; gap: 2px; }
button { font-size: 1.5rem; line-height: 1; cursor: pointer; }
</style>
${[1, 2, 3, 4, 5]
.map(
(n) =>
`<button type="button" data-value="${n}" aria-label="${n} 星">★</button>`,
)
.join("")}
`;
root.addEventListener("click", (event) => {
const button = event.target.closest("button[data-value]");
if (!button) return;
this.value = Number(button.dataset.value);
});
}
get value() {
return String(this.#value);
}
set value(next) {
const n = Math.min(5, Math.max(0, Number(next) || 0));
this.#value = n;
// 交给表单的值;null 表示「不提交这个字段」
this.#internals.setFormValue(n === 0 ? null : String(n));
const missing = n === 0 && this.hasAttribute("required");
this.#internals.setValidity(
missing ? { valueMissing: true } : {},
missing ? "请至少选择一颗星" : "",
);
if (this.getAttribute("value") !== String(n)) {
this.setAttribute("value", String(n));
}
}
attributeChangedCallback(name, _old, next) {
if (name === "value") this.value = next;
}
formResetCallback() {
this.value = this.getAttribute("value") ?? 0;
}
}
customElements.define("rating-input", RatingInput);

几个关键 API:

  • static formAssociated = true 告诉浏览器这个元素要参与表单。
  • this.attachInternals() 返回 ElementInternals,一个元素只能调用一次。
  • setFormValue() 决定提交什么值;传 null 表示不提交。
  • setValidity() 决定校验状态和错误文案。
  • formResetCallback()、formDisabledCallback()、formStateRestoreCallback() 分别对应重置、禁用和浏览器恢复表单状态。

还有一点容易忽略:宿主元素上的 name 属性决定字段名,disabled 也要自己处理——禁用状态不会自动传给组件内部,需要实现 formDisabledCallback 并同步到内部按钮。

无障碍不能省#

表单控件是交互密度最高的组件,键盘操作和屏幕阅读器支持必须逐项验证:Tab 能进入、方向键能调整、aria-valuenow 这类状态会更新、错误文案和控件有 aria-describedby 关联。ElementInternals 也提供了设置 ARIA 角色的入口,但角色只是起点,行为要自己实现。

七、什么时候不该用 Web Components#

前面几节刻意把成本摊开了。结论是:Web Components 不是「更好的组件方案」,而是一种跨运行时的组件协议。它的收益来自「多处复用」,成本来自「自己补齐框架的能力」。如果收益不成立,成本就是纯粹的负担。

以下情况,用框架组件更省事:

  • 只有一个应用、一个框架。 你付出属性同步、SSR、测试的全部成本,换来一个并不需要的跨框架能力。
  • 组件依赖框架的响应式系统。 复杂的派生状态、细粒度更新、时间旅行调试,自定义元素都要从头实现。
  • 重度依赖设计系统定制。 ::part 只能暴露你预先声明的节点,遇到「改内部第三层 padding」这类需求会很别扭。自定义属性可以缓解,但表达力有限。
  • 表单密集且校验复杂。 每个控件都要实现 ElementInternals 那一套,组件越多,重复代码越多。
  • 团队没有浏览器层调试经验。 Shadow DOM 的样式问题、事件重定向问题,排查路径和普通 DOM 不一样。

反过来,如果满足下面几条,Web Components 的收益会很明确:

  • 同一份 UI 要跨两个以上框架或非框架页面使用;
  • 组件以「黑盒」形式发布,内部结构不需要外部定制;
  • 你有能力维护一套 SSR 和测试基础设施;
  • 组件边界清晰,跨边界通信主要靠属性和事件。

八、一份最小检查清单#

如果你决定动手,下面这些点在写完第一个组件后逐条对照一遍:

  • constructor 里没有读取 attributes 或 children;
  • connectedCallback 可以重复执行,全局监听用 AbortController 清理;
  • 所有需要监听的 attribute 都写进了 observedAttributes;
  • 布尔属性用 hasAttribute / toggleAttribute 判断;
  • 属性到 property 的同步不会形成死循环;
  • 宿主元素设置了明确的 display;
  • 需要外露的样式节点标了 part,主题入口用自定义属性;
  • 自定义事件按需设置了 bubbles 和 composed;
  • 有 DSD 版本时,构造函数复用 this.shadowRoot 而不是再次 attachShadow;
  • 需要参与表单时实现了 formAssociated 与 ElementInternals;
  • 未升级状态有可用的降级样式;
  • 在真实浏览器里测过键盘操作和屏幕阅读器路径。

Web Components 真正难的地方,从来不是「怎么写一个自定义元素」——那部分半小时就能学会。难的是承认它把一个组件库该有的基础设施都推给了你:状态同步、渲染时机、样式契约、表单语义。想清楚这些成本由谁承担,再决定要不要用它。

Web Components 实战:生命周期、Shadow DOM 边界与声明式 Shadow DOM 的代价
https://www.hehonglei.cn/posts/web-components-custom-elements-shadow-dom-guide/
作者
Honglei He
发布于
2026-11-02
许可协议
CC BY-NC-SA 4.0