Web Components 实战:从自定义元素到跨框架复用
本文目录
Web Components 是浏览器原生的组件模型:不依赖任何框架,React、Vue、原生页面都能直接使用同一个组件。它的价值场景很明确——跨框架复用与长期存活的设计系统、需要嵌到别人页面里的第三方组件。这篇文章按使用顺序讲:三件套、生命周期、attribute 与 property、slot 分发、Shadow DOM 样式、事件,最后是各框架中的接入方式与选型判断。
先说清楚边界:Web Components 不是"更好的 Vue 组件",它没有响应式系统、没有模板指令、没有状态管理。它是"把组件变成一个 HTML 标签"的标准,适合做组件层,不适合做应用层。
三件套各自解决什么问题
| 能力 | 解决什么 | 对应 API |
|---|---|---|
| Custom Elements | 定义行为挂在新标签上 | customElements.define() |
| Shadow DOM | 样式与 DOM 结构隔离 | element.attachShadow() |
| HTML Templates | 声明 inert 的模板片段 | <template> / <slot> |
// 最小可用的自定义元素:名字必须含连字符,避免与内置标签冲突
class MyCounter extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello from custom element';
}
}
customElements.define('my-counter', MyCounter);
<!-- 之后在任何页面、任何框架里都这样用 -->
<my-counter></my-counter>
customElements.define 的两个硬规则:元素名必须包含连字符且全小写;同一标签名不能重复定义(define 重复调用会直接抛错)。动态 import 懒加载组件时要注意第二点——先 customElements.whenDefined('my-counter') 查询,别盲目 define。
生命周期回调:比框架钩子更贴近 DOM
| 回调 | 触发时机 | 典型用途 | 易错点 |
|---|---|---|---|
constructor | 元素创建/升级 | 初始化状态、attachShadow | 规范禁止在这里读子节点/属性,也不要在这里加 attribute |
connectedCallback | 插入文档 | 渲染、绑监听 | 元素移动时会再次触发,逻辑要可重入 |
disconnectedCallback | 移出文档 | 清理监听、定时器 | 忘记清理是内存泄漏主源 |
attributeChangedCallback | observedAttributes 列出的属性变化 | 同步内部状态 | 只对白名单属性生效 |
adoptedCallback | 移入新 document | 少见(iframe/弹窗迁移) | 大多数项目用不到,别硬凑 |
class StarRating extends HTMLElement {
static observedAttributes = ['value'];
// constructor 只做状态初始化,规范禁止读取子节点与属性
constructor() {
super();
this._value = 0;
this._cleanup = null;
}
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: inline-flex; gap: 4px; cursor: pointer; }
.star { color: #c8c8c8; }
.star.on { color: #f5a623; }
</style>
<span class="star">★</span><span class="star">★</span>
<span class="star">★</span><span class="star">★</span>
<span class="star">★</span>`;
}
this._render();
// disconnectedCallback 一定能配对清理
const onClick = (e) => {
const stars = [...this.shadowRoot.querySelectorAll('.star')];
const next = stars.indexOf(e.target) + 1;
this.value = next || 1;
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this._value },
bubbles: true, // 冒泡到父级
composed: true, // 穿过 shadow 边界
}));
};
this.shadowRoot.addEventListener('click', onClick);
this._cleanup = () => this.shadowRoot.removeEventListener('click', onClick);
}
disconnectedCallback() {
this._cleanup?.();
this._cleanup = null;
}
attributeChangedCallback(name, _old, next) {
if (name !== 'value') return;
this._value = Number(next) || 0;
this._render();
}
// property 入口:外部 JS 直接读写 .value
get value() { return this._value; }
set value(v) {
this._value = v;
this.setAttribute('value', String(v)); // 反射回 attribute,CSS 选择器可见
}
_render() {
if (!this.shadowRoot) return;
const stars = [...this.shadowRoot.querySelectorAll('.star')];
stars.forEach((s, i) => s.classList.toggle('on', i < this._value));
}
}
customElements.define('star-rating', StarRating);
一个升级时序细节:元素在 HTML 里先出现、脚本后加载时,attributeChangedCallback 会先于 connectedCallback 触发(升级瞬间属性已就位)。所以 attributeChangedCallback 里不要假设 DOM 已渲染——上面代码里 _render 的守卫就是防这个。
attribute 与 property:两条传值通道
| 维度 | attribute | property |
|---|---|---|
| 本质 | HTML 序列化里的字符串 | 元素实例上的 JS 值 |
| 声明方式 | 标签上写死,或 setAttribute | el.value = …,可传对象/函数 |
| 大小写 | 名字不区分大小写(会被转小写) | 区分大小写 |
| 观察变化 | observedAttributes + 回调 | 手写 getter/setter 拦截 |
| 序列化 | 能被 outerHTML 带走 | 不能 |
设计原则:外部配置用 attribute(声明式、可序列化),复杂值与运行时交互用 property。两者之间的同步要自己写,且要选定"事实源"——上面的例子以 property 为事实源、attribute 只做反射展示。布尔属性(如 disabled)有专门陷阱:它的语义是"存在即真",判断要用 hasAttribute('disabled'),不要读 getAttribute('disabled') 的值(空字符串也是真值)。
slot 分发:把内容投影进组件
<user-card user-id="42">
<span slot="title">高级会员</span>
<p>这里是默认插槽内容,进无名 slot</p>
</user-card>
// 组件内部:具名插槽对位,未命名的落进默认 slot
class UserCard extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; border: 1px solid #ddd; padding: 8px; }
::slotted([slot='title']) { font-weight: 600; }
slot { color: #666; }
</style>
<div part="title"><slot name="title">默认标题</slot></div>
<div><slot>无内容时的回退文案</slot></div>`;
}
}
customElements.define('user-card', UserCard);
三个要点:slot 的回退内容只在没分发内容时显示;::slotted() 只能从组件内侧给分发进来的元素加有限样式(继承类、简单选择器),主要样式仍由外层文档控制,这正好是"内容归使用者管"的分界;slot 分发变化可以监听:
const slot = this.shadowRoot.querySelector('slot');
slot.addEventListener('slotchange', () => {
const nodes = slot.assignedNodes(); // 拿到实际分发进来的节点
this._renderCount(nodes.length);
});
Shadow DOM:样式隔离与受控穿透
Shadow DOM 的默认行为是"关起门来":外层样式进不来(继承属性如字体颜色除外),内部选择器出不去。需要交换样式时有三种受控通道:
| 通道 | 方向 | 用途 |
|---|---|---|
| CSS 自定义属性 | 外 → 内 | 主题变量::host 之外定义 --brand-color,内部用 var(--brand-color) |
::part / ::part + exportparts | 外 → 内 | 暴露内部节点给外部定向样式化 |
::slotted | 内 → 外 | 给分发内容加有限样式 |
/* 使用方:只通过变量与 ::part 定制,不用知道内部结构 */
star-rating {
--star-size: 24px;
}
star-rating::part(title) {
color: #333;
}
<!-- 组件内部需配合 exportparts,把内部 part 再导出 -->
<div part="title" exportparts="title">…</div>
两件事要写进团队认知:继承是穿透的——font、color 这类可继承属性天然流进 shadow 树,所以"组件默认不吃外部样式"是误解,reset 类全局样式确实影响不到内部结构,但文字类样式会漏进去,必要时在 :host 里显式归零;mode: 'closed' 不是安全边界——closed 只是让外部拿不到 shadowRoot 引用,藏不住任何有决心的人,安全上的事交给 iframe。
样式隔离之外还有一份 SSR 红利:声明式 Shadow DOM(<template shadowrootmode="open">)允许服务端把 shadow 结构直接序列化进 HTML,首屏不依赖 JS 就有完整渲染,这对慢网络与爬虫都有价值,主流内核已陆续支持,用前按目标环境核实版本。
事件冒泡与 composed:跨边界的事件语义
Shadow DOM 内部触发的事件,冒泡到边界时 event.target 会被重定向为宿主元素本身——外部框架看到的 target 永远是 <star-rating>,而不是内部的某个 .star。要让自定义事件穿透边界,构造时必须带 composed: true:
this.dispatchEvent(new CustomEvent('change', {
detail: { value: this._value },
bubbles: true, // 沿 DOM 树向上
composed: true, // 穿过 shadow root 边界
}));
注意两个参数的分工:bubbles 管向上传播,composed 管跨边界,只开 bubbles 的事件会停在 shadow root 上,外面的监听器收不到。排查"组件事件收不到"时先检查这两个字段。需要看完整路径时用 event.composedPath(),它返回跨边界展开后的节点链。
在 React、Vue 与 Astro 中使用
| 维度 | React 18 及以前 | React 19 | Vue 3 | Astro |
|---|---|---|---|---|
| property 传值 | 不支持,非字符串 prop 被转成字符串 attribute | 支持:非字符串 prop 按 property 设置 | 支持:元素存在同名 property 时按 property 赋值 | 原生场景无此问题,直接 JS 操作 |
| 自定义事件 | ref 上手动 addEventListener | 有官方支持,映射规则以 react.dev 为准 | v-on 以原生监听器挂上 | 脚本里手动监听 |
| 组件开发 | — | — | defineCustomElement 反向输出 Vue 组件 | 直接写原生元素或嵌任意框架组件 |
React:18 与 19 的分水岭
// React 18 及以前:对象 props 会变成 "[object Object]" 字符串,事件必须手动绑
function App() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
const onChange = (e) => setRating(e.detail.value);
el.addEventListener('change', onChange);
return () => el.removeEventListener('change', onChange);
}, []);
return <star-rating ref={ref} value="3"></star-rating>;
}
// React 19 起:非字符串 prop 直接按 property 设置
function App() {
return <star-rating value={3} max-value={5}></star-rating>;
}
React 19 之前的限制是历史包袱最重的部分:所有 prop 一律字符串化,导致对象、数组、函数全传不过去,很多团队为此写了 wrapper 组件。React 19 起按"字符串走 attribute、其余走 property"处理,具体的事件监听映射规则建议以当前 react.dev 文档为准——这类细则在补丁版本间有过微调。
Vue:defineCustomElement 双向出口
// Vue 3.2+:同一套 .vue 组件可以编译成 Web Component 输出
import { defineCustomElement } from 'vue';
import RatingCard from './RatingCard.ce.vue';
customElements.define('rating-card', defineCustomElement(RatingCard));
这是 Vue 生态用 Web Components 的主流姿势:内部开发仍是熟悉的单文件组件(.ce.vue 会被单独配置成 element 构建),对外输出标准自定义元素。反过来在 Vue 模板里使用别人的自定义元素也顺畅——v-on 会注册为原生监听器,属性赋值有"同名 property 则走 property"的启发式。
Astro:无框架优先
---
// src/components/StarCounter.astro —— 默认输出零 JS 的静态 HTML
const { initial = 0 } = Astro.props;
---
<star-counter data-initial={initial}>
<!-- 无 JS 时仍然可见的回退内容 -->
<span slot="label">收藏数</span>
</star-counter>
<script>
class StarCounter extends HTMLElement {
connectedCallback() {
const n = Number(this.dataset.initial || 0);
this.querySelector('[slot="label"]').append(`:${n}`);
}
}
customElements.define('star-counter', StarCounter);
</script>
Astro 默认不 hydrate、不打包框架运行时,自定义元素用原生脚本挂载即可,data attribute 传初值、渐进增强保回退内容,这套组合和 Web Components 的理念天然一致。需要复杂状态时再按需引入任意框架做 island。
何时选 Web Components
| 选 | 不选 |
|---|---|
| 设计系统要同时服务 React/Vue/多技术栈团队 | 全公司只有一种框架,直接用该框架组件更顺 |
| 组件要嵌进客户/第三方页面(评论框、播放器、支付控件) | 组件与应用强耦合,依赖应用的路由与全局状态 |
| 组件生命周期按"年"计,要跨框架版本存活 | 快速迭代的一次性业务组件 |
| 声明式、HTML 优先的组件(标签即 API) | 需要复杂模板逻辑(条件循环渲染由框架 DSL 承担更高效) |
与微前端的关系一句话:Web Components 解决"组件级"跨框架,微前端解决"应用级"拆分,前者常作为后者的共享组件载体(见微前端实践),但两者不是一回事。
注意事项与边界
constructor里禁止读子节点/属性、禁止加 attribute,是规范约束不是风格建议,违反会导致部分浏览器升级失败。- 属性名会被小写化:
setAttribute('maxValue', …)实际存成maxvalue,内部属性名一律小写带连字符(max-value)。 - attribute 与 property 的同步要自己实现,且必须选定唯一事实源,否则两边改一边会出现"显示对了、状态错了"的幽灵 bug。
- JS 加载前自定义元素是未知元素:给
<my-el>写的 CSS 依然生效,可利用:not(:defined)做加载占位样式;关键内容别只依赖 JS 才能渲染出来。 - closed 模式不提供安全性,只提供一点门槛;真要隔离第三方内容用 iframe。
- 需要参与表单时用 form-associated 能力:
static formAssociated = true+attachInternals(),通过ElementInternals上报 value 与校验状态,而不是自己监听 form。 - 每个自定义元素都是公共 API:标签名、attribute、事件名、part 命名一旦发布就进入兼容承诺,改动要像改 npm 包版本一样谨慎。
总结
Web Components 的三件套各司其职:Custom Elements 定义标签与生命周期(构造期约束、可重入的连接/断开、白名单属性观察),Shadow DOM 提供隔离与三种受控样式通道(自定义属性、::part、::slotted),Templates/Slots 承担声明与内容投影。使用层的两个关键机制是传值(attribute 序列化通道 + property 运行时通道,单一事实源)与事件(bubbles 与 composed 各管一个维度,target 跨边界会被重定向)。框架接入看版本:React 19 修复了长期痛点,Vue 有 defineCustomElement 双向出口,Astro 与其理念天然契合。选型时盯住"跨框架复用与长期存活"这两个真实诉求,应用内的普通组件用框架原生方案依然是更高效的选择。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。