Web Components 入门:跨框架复用组件的一种思路

要不要用 Web Components,我的判断标准很单一:这个组件是不是要跨框架、跨页面复用。如果答案是"就服务当前这个 Vue 后台",那基本没必要碰它,框架组件更省心;只有当同一个组件要同时进 Vue2 老项目、React 新项目、还有几张静态运营页时,Web Components 才真正值回它的那些成本。

这个判断不是拍脑袋来的。我们这边同时维护着好几拨技术栈的页面,有阵子要给它们共享一个客服浮窗,最早是给 Vue 项目和老 jQuery 页面各维护一套,光同步一句文案改动就要改两处、发两次版。后来统一成一个 Web Component,对外只暴露一个标签和几个属性,接入文档从两页缩到几行。这种"一次构建、到处嵌入"的体验,是它最打动我的地方,但这份好处后面挂着一串工程细节,得先踩明白。

先认清它是一组标准,不是库

Web Components 不是某个单独的库,而是一组浏览器标准,主要三块:Custom Elements 定义自定义 HTML 标签,Shadow DOM 封装内部结构和样式,HTML Template 定义可复用模板。有了它们就能写出这样的标签:

1<user-card name="张三"></user-card>

浏览器识别这个自定义元素,执行对应逻辑。这里第一个硬规则是命名必须带短横线,比如 user-cardapp-player,目的是避免和未来的原生标签冲突。

我起步时踩过一个低级坑:图省事命名成 usercardcustomElements.define 直接抛 Failed to execute 'define' on 'CustomElementRegistry',盯着代码看半天才反应过来是命名规则。还有一次更隐蔽——组件脚本是异步加载的,HTML 里标签先出现、脚本后注册,浏览器对未识别的自定义元素会当成普通 HTMLUnknownElement 渲染,等脚本注册后才"升级"成真正的组件。如果你在升级前就去读它的属性或调方法,会拿到一片空白。后来我习惯用 customElements.whenDefined('user-card').then(...) 等组件就绪,比盲目 setTimeout 靠谱。

从最小的自定义元素说起

一个很小的自定义元素长这样:

1class UserCard extends HTMLElement {
2  connectedCallback() {
3    const name = this.getAttribute("name") || "游客";
4
5    this.innerHTML = `
6      <div class="user-card">
7        <strong>${name}</strong>
8        <span>欢迎回来</span>
9      </div>
10    `;
11  }
12}
13
14customElements.define("user-card", UserCard);

注册后就能在 HTML 里用 <user-card name="李四"></user-card>。这是最基础的工作方式,但它还没处理属性变化——后续动态改 name

1document.querySelector('user-card').setAttribute('name', '王五');

组件不会自动重渲染,除非显式监听属性。这里还藏着一个容易忽略的限制:HTML attribute 进来都是字符串<user-card visible="false"> 里的 visible 不会自动变成布尔 false,它就是个字符串。复杂数据也不适合直接塞 attribute,得通过 property 或方法传:

1const card = document.querySelector('user-card');
2card.user = { id: 1, name: '张三' };

所以对外 API 要提前设计清楚:哪些走 attribute、哪些走 property、哪些通过事件输出。这层没划清楚,接入方会很难用。

生命周期与属性变化

Custom Elements 给了一组生命周期方法:connectedCallback(插入页面)、disconnectedCallback(移出页面)、attributeChangedCallback(监听属性变化)、adoptedCallback(被移到新文档)。要监听属性,得声明 observedAttributes

1class UserCard extends HTMLElement {
2  static get observedAttributes() {
3    return ['name'];
4  }
5
6  connectedCallback() {
7    this.render();
8  }
9
10  attributeChangedCallback() {
11    this.render();
12  }
13
14  render() {
15    const name = this.getAttribute('name') || '游客';
16
17    this.innerHTML = `
18      <div class="user-card">
19        <strong>${name}</strong>
20        <span>欢迎回来</span>
21      </div>
22    `;
23  }
24}

组件内部绑了全局事件或定时器,要在 disconnectedCallback 里清理:

1disconnectedCallback() {
2  window.removeEventListener('resize', this.handleResize);
3}

这跟框架组件的卸载清理很像,只是这里得你自己管。还有种特殊情况是元素被移动位置——列表排序、弹窗挂载、微前端容器迁移都可能触发断开再重连。如果 connectedCallback 里无脑重复初始化,就会重复监听、重复请求、重复渲染。我的做法是给初始化加状态位,或者把可重复执行和只执行一次的逻辑分开。

有个坑值得单独提:attributeChangedCallback 会在元素插入 DOM 之前就触发。元素带着初始属性写在 HTML 里时,回调可能比 connectedCallback 更早跑,这时 Shadow DOM 还没建好,直接操作内部节点就报错。所以我在 render 里加一道判断,只在 isConnected 为真时才真渲染:

1attributeChangedCallback(name, oldValue, newValue) {
2  if (oldValue === newValue) return; // 值没变就别白渲染
3  if (this.isConnected) this.render();
4}

oldValue === newValue 也不多余——有些代码用 setAttribute('name', name) 来"刷新"组件,哪怕值没变回调也会触发,不挡一下就是无意义重渲染。另外 observedAttributes 是静态 getter,里面属性名写错不会报错,只是回调安静地不触发。我有次把 data-id 写成 dataId 调了一下午,自定义元素属性名全是小写短横线,和 JS 的驼峰是两套写法。

Shadow DOM:样式隔离的收益与成本

普通组件样式很容易互相影响——页面有个 .title,组件内部也有个 .title,选择器写得不小心就互相覆盖。Shadow DOM 把组件内部 DOM 和样式封起来,减少污染:

1const shadow = this.attachShadow({ mode: "open" });
2shadow.innerHTML = `
3  <style>
4    .title {
5      color: #1677ff;
6    }
7  </style>
8  <div class="title">组件标题</div>
9`;

这段样式只影响组件内部,外部脚本也没法随意依赖内部结构。但它有成本:全局 CSS 穿不进去,调样式要多一层结构;表单、弹窗、主题系统都得额外设计样式入口。常见做法是用 CSS 自定义属性暴露主题能力:

1shadow.innerHTML = `
2  <style>
3    .title {
4      color: var(--user-card-title-color, #1677ff);
5    }
6  </style>
7  <div class="title">组件标题</div>
8`;

使用方这样覆盖:

1<user-card style="--user-card-title-color: #dc2626"></user-card>

既保留封装,又给外部留有限的定制点。实例很多时样式还能复用,现代浏览器有 adoptedStyleSheets 这类能力,把样式表对象共享给多个 Shadow Root,避免每个实例都塞一份 <style>。不过它有兼容性和构建方式的考虑,业务里不一定一上来就用,先把主题入口和封装范围设计清楚更重要。

CSS 自定义属性是少数能天然穿透 Shadow DOM 的东西,因为它本身就继承。除了它还有 ::part(),可以把内部某个节点标记成"可被外部定制的部件":

1shadow.innerHTML = `
2  <button part="confirm-btn">确定</button>
3`;

外部就能改它的样式,不用知道内部类名:

1user-card::part(confirm-btn) {
2  background: #16a34a;
3}

我喜欢这套组合:变量管颜色、间距这类细粒度 token,::part() 留几个关键节点的整体样式入口。既不破坏封装,又不至于让接入方为改个按钮颜色逼你暴露内部结构。还有个真实的坑:Shadow DOM 里字体不会自动继承外面的 @font-face,但 font-family 属性本身是继承的,于是经常出现"字体声明在外面、组件里却 fallback 成默认字体"的诡异现象。办法是把 @font-face 写在 document 级别,组件内部只引用 font-family。同理,全局 reset、Tailwind 这类原子化 CSS 也进不去 Shadow DOM,组件内部基础样式得自己再写一份,这是用 Shadow DOM 要付的固定成本。

事件通信:composed 决定能不能被外面听到

对外通信通常派发自定义事件:

1this.dispatchEvent(
2  new CustomEvent('select', {
3    detail: {
4      id: this.getAttribute('user-id'),
5    },
6    bubbles: true,
7    composed: true,
8  })
9);

使用方监听:

1document.querySelector('user-card').addEventListener('select', (event) => {
2  console.log(event.detail.id);
3});

bubbles 表示事件能冒泡,composed 表示能穿过 Shadow DOM 边界。composed 这个我吃过亏:做一个嵌在 Shadow DOM 里的下拉组件,派发事件忘了加 composed,事件在 shadow 那层就被拦住出不去,外面怎么都监听不到,控制台又没任何报错,排查特别折磨。规律记住就行——只要事件要被 Shadow DOM 外部的代码听到,composed 就必须为 true

事件命名也要谨慎。select 简单,但复杂页面里容易和别的组件语义混在一起。对外组件可以带命名空间,比如 user-card-select,看着啰嗦,但日志、埋点和跨团队接入时更好定位来源。属性和事件之间还有个取舍点:attribute 只能传字符串,传对象数组就得 JSON.stringifyparse,又丑又易错。接入方本来就用 JS 拿到元素引用的话,我更倾向用 property 传复杂数据,attribute 留给写在 HTML 里的简单标量:

1class UserCard extends HTMLElement {
2  set user(value) {
3    this._user = value;
4    this.render();
5  }
6  get user() {
7    return this._user;
8  }
9}
10
11// 使用方
12const el = document.querySelector('user-card');
13el.user = { id: 1, name: '张三' }; // 直接传对象,不用序列化

这也是为什么 Vue、React 集成 Web Components 时要区分"绑到 attribute 还是 property"——Vue 3 的 .prop 修饰符、React 对 DOM property 的特殊处理,本质都在解决这类数据该怎么原样传过这一层。

插槽让内容可组合

组件要接收外部内容,用 slot

1shadow.innerHTML = `
2  <article class="card">
3    <h3><slot name="title">默认标题</slot></h3>
4    <div><slot></slot></div>
5  </article>
6`;

使用时:

1<info-card>
2  <span slot="title">账户信息</span>
3  <p>这里是卡片内容。</p>
4</info-card>

插槽适合做布局壳、卡片、弹窗、面板这类需要外部填充内容的组件。

什么情况该用,什么情况别用

回到最初的判断。Web Components 适合这几类定位清晰的场景:跨 Vue、React、原生页面复用的组件;公司内部多技术栈共用组件;小组件、小挂件、嵌入式功能;不希望强依赖某个框架的 UI 能力。像前面那个客服浮窗,或者一个要给多个框架项目共享的播放器、支付按钮、埋点组件,它就比为每个框架各写一套有吸引力。对外提供嵌入式组件时优势更明显,第三方只要引个脚本、写个标签:

1<script src="https://example.com/widget.js"></script>
2<support-widget app-id="demo"></support-widget>

对接入方的框架要求极低。但对外分发有几个工程细节要提前想清楚:样式隔离要做足,否则 widget 进了别人页面会被对方全局 CSS 影响、也可能反过来污染对方,Shadow DOM 在这里几乎是必选项;还要防重复注册,同一脚本被引两次,第二次 define 会直接抛错:

1if (!customElements.get('support-widget')) {
2  customElements.define('support-widget', SupportWidget);
3}

表单控件是另一档成本

判断要不要用它做表单组件,得单独掂量,因为这是完全不同的一档成本。普通自定义元素默认不像 <input> 那样参与表单提交,也不天然支持校验状态。现代浏览器逐步补齐了 form-associated custom elements 和 ElementInternals,能让自定义元素更像原生表单控件:

1class AppInput extends HTMLElement {
2  static formAssociated = true;
3
4  constructor() {
5    super();
6    this.internals = this.attachInternals();
7  }
8
9  set value(value) {
10    this.internals.setFormValue(value);
11  }
12}

这方向很有价值,但意味着组件要处理更多细节:value、disabled、required、校验提示、表单 reset。只做展示组件很轻松,一旦做表单控件就不能只把 DOM 包起来。落地前还要确认目标浏览器范围,别把它当成和普通 input 一样没有兼容成本。

在框架里用,没想象中顺滑

既然主打跨框架,就得讲讲在框架里真正用起来的体验。Vue 这边相对省心,vue.config.js 里配一下 compilerOptions.isCustomElement(Vue 3)或 ignoredElements(Vue 2),告诉编译器某些标签不是 Vue 组件、别去解析,就能用了。

React 18 这个阶段最别扭。它对自定义元素有两个老问题:很多属性会按 HTML attribute 处理,复杂数据要自己写 property;自定义元素派发的 CustomEvent 需要手动用原生事件监听兜住。所以在 React 里用 Web Component,常常得退回命令式,自己拿 ref 去赋 property、加监听:

1function Wrapper({ user, onSelect }) {
2  const ref = useRef(null);
3
4  useEffect(() => {
5    const el = ref.current;
6    el.user = user;              // 用 property 传对象
7    el.addEventListener('select', onSelect); // 手动监听原生事件
8    return () => el.removeEventListener('select', onSelect);
9  }, [user, onSelect]);
10
11  return <user-card ref={ref}></user-card>;
12}

写两次就嫌啰嗦,实践里通常包一层 React 适配组件,把这些脏活封进去,对外只暴露正常 props。这也提醒一件事:Web Components 解决的是"运行时跨框架运行","开发体验上的无缝集成"还得各框架自己补,别指望拖进去就跟原生组件一样丝滑。

它不一定替代框架组件

Web Components 是浏览器标准,但不代表所有场景都比框架组件好。Vue 和 React 在状态管理、模板语法、工程生态、开发体验上仍有优势,很多业务项目里直接用框架组件更高效。更合理的理解是:它适合做跨框架组件,而不是替代所有 Vue、React 组件。

落地前还有几处成本要评估:服务端渲染和 hydration 支持是否满足要求、表单组件要不要和框架表单库深度集成、主题系统怎么穿透 Shadow DOM、事件命名和属性类型怎么规范、构建产物要不要兼容旧浏览器。如果只是一个单一 React 后台,把所有按钮、表格、表单都改成 Web Components,收益多半不如成本高。

这两年也能看到 Declarative Shadow DOM 被更多讨论,它让服务端输出 Shadow DOM 成为可能,对 SSR 友好些。但这不等于 SSR 问题全消失了,跨框架组件真落地时还得看目标浏览器、构建产物、样式加载、hydration 方式和组件升级时机。

所以判断落到一句话上:当组件要跨项目、跨框架、跨页面分发时,Web Components 值得用;当它只服务当前框架应用时,框架组件通常更简单高效。回到最初那个客服浮窗——它值得用 Web Components,不是因为这项技术更先进,而是因为它同时要塞进 Vue 项目、老 jQuery 页面和静态运营页,少维护一套代码的收益盖得过样式隔离和事件通信这些成本;换成一个只在自家 Vue 后台用的弹窗,这笔账就完全算不过来。