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-card、app-player,目的是避免和未来的原生标签冲突。
我起步时踩过一个低级坑:图省事命名成 usercard,customElements.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.stringify 再 parse,又丑又易错。接入方本来就用 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 后台用的弹窗,这笔账就完全算不过来。