目录
一、先问:你要复用的是组件,还是运行环境
假设你维护着一份公司内部的设计系统。需求很具体:同一套按钮、输入框、日期选择器,要同时出现在三个地方——一个 Astro 的静态营销页、一个还在用 jQuery 的后台、一个 React 管理端。
框架组件在这里很难复用。React 组件离开 React 运行时就不工作,Vue 单文件组件同样如此。Web Components 给出的答案是:把组件的边界交给浏览器,而不是交给框架。自定义元素的注册、Shadow DOM 的封装、属性的监听,都由浏览器实现。任何能操作 DOM 的环境都能用,包括服务端渲染出来的静态 HTML。
这个卖点是真的,但代价也是真的。它换来的不是「更轻的框架」,而是「你要自己补齐框架本来帮你做的事」:属性到状态的同步、SSR 与 hydration、样式注入、表单集成、测试环境。下面这几节讲的就是这些补齐工作里最容易出错的地方。
先给一个判断表,后面再逐条展开:
| 你的处境 | 更合适的选择 |
|---|---|
| 单一框架、单一应用 | 框架组件,省掉下面全部成本 |
| 多框架、多技术栈共享一套 UI | Web Components |
| 只需要静态 HTML 里加一点交互 | 原生元素 + 少量 JS,未必需要自定义元素 |
| 表单密集、强依赖表单校验 | 框架组件,或接受 ElementInternals 的额外工作 |
| 大量依赖设计系统 token 深度定制 | 可以,但要接受 ::part 的表达力上限 |
二、自定义元素的生命周期,比框架组件多三个陷阱
框架组件的生命周期是「挂载—更新—卸载」,由框架调度。自定义元素的生命周期由 HTML 解析器、DOM 操作和注册时机共同决定,因此多了几个框架里不会遇到的顺序问题。
陷阱一:constructor 里读不到属性和子节点
这是第一个会让人困惑的地方。自定义元素的 constructor 在两种时机被调用:
- 注册之后,解析器遇到对应标签,创建元素时;
- 元素已经在 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 里、之后才注册,升级过程是固定顺序的:
- 调用
constructor; - 对元素上已有的每个
observedAttributes中的属性,逐个调用attributeChangedCallback,此时oldValue是null; - 调用
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 年,主流浏览器都已经可用,但如果你的用户群里还有旧版浏览器,需要准备降级方案。
两种常见做法:
- 保留客户端渲染路径。 也就是上面那段
this.shadowRoot ?? this.attachShadow(...),加上一个在解析后把普通<template>转换成 shadow root 的 polyfill。 - 不依赖 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 真正难的地方,从来不是「怎么写一个自定义元素」——那部分半小时就能学会。难的是承认它把一个组件库该有的基础设施都推给了你:状态同步、渲染时机、样式契约、表单语义。想清楚这些成本由谁承担,再决定要不要用它。