
聊到前端组件化大多数人第一反应是 Vue、React 或者 Angular。但有一类方案一直游离在框架话语体系之外它就是 Web Components——一套由浏览器原生提供的组件化开发标准。如果你正在为多框架共存、组件跨项目复用、或者不想被某个框架生态绑死而头疼那原生组件化开发这条路值得你认真看一眼。我之前在一家业务线很多的团队里做过前端架构React 项目、Vue 项目、甚至老 jQuery 页面同时存在。每次想统一一套 UI 组件库都会被框架差异卡住React 组件没法直接扔进 Vue 项目Vue 组件在原生页面里又跑不起来。后来我们把核心业务组件全部改成了 Web Components情况才真正好转。这篇文章就把 Web Components 的核心原理、实战写法、以及踩坑经验一次性讲透。1. 先说说 Web Components 到底是什么1.1 从一次组件化改造的痛点说起2022 年我们接到一个内部系统重构需求涉及 4 个前端项目一个 Vue 3 项目、一个 React 18 项目、一个 jQuery 老系统还有一个 AngularJS 时代的遗留系统。需求是要统一这些项目里的用户信息选择器、日期范围选择器、附件上传组件。最初方案是各项目自己实现工作量直接乘以 4。后来尝试用微前端但微前端解决的是应用间集成不是组件级复用粒度不对。这时候我们意识到真正需要的是一个“不依赖任何框架运行时的组件层”。组件本身是一个独立模块无论宿主页面是 Vue、React 还是原生 JavaScript只要它符合浏览器标准就能被识别和渲染。这个“标准”就是 Web Components。从本质上讲Web Components 不是某个框架提供的组件体系而是浏览器原生能力。它由 Custom Elements、Shadow DOM、HTML Templates 和 ES Modules 这几个标准共同组成。浏览器直接解析和渲染不需要额外引入运行时库这在使用体验上很像“组件是浏览器的一部分功能”。1.2 四项标准技术各管哪一块这四个标准技术经常被混在一起说但它们的职责其实是分离的Custom Elements自定义元素允许你注册一个新的 HTML 标签比如user-picker、date-range并规定这个标签的创建、挂载、更新和销毁时对应执行哪些逻辑。Shadow DOM影子 DOM给组件提供一套独立的内部 DOM 树和样式作用域。外部页面的 CSS 不会意外影响组件内部组件内部样式也不会泄漏出去。HTML TemplatesHTML 模板用template标签定义一段可复用的 HTML 结构它不会被渲染但可以在组件实例化时被克隆使用。ES ModulesES 模块作为组件的分发载体让组件可以通过import方式按需加载。打个比方Custom Elements 是“组件的外壳和生命周期”Shadow DOM 是“组件的隔离房间”HTML Templates 是“房间的装修图纸”ES Modules 是“运载装修材料的货车”。四个配合起来才能完成一个完整的组件闭环。1.3 它和 Vue/React 组件有什么本质区别很多人一听到“组件化”就下意识以为 Web Components 是 Vue/React 的平替这个理解有偏差。Vue/React 组件是框架层面通过虚拟 DOM、响应式系统、组件实例等机制实现的抽象它们必须依赖框架运行时才能工作。而 Web Components 是浏览器层面的原生对象模型它直接把 DOM、样式、生命周期暴露给浏览器。用一句话概括Vue 组件是“运行在框架里的组件”Web Components 是“运行在浏览器里的组件”。前者的依赖是框架后者的依赖是浏览器本身。这带来两个直接后果一方面Web Components 可以在任何框架中使用不受框架版本约束另一方面Web Components 没有内置的响应式数据系统数据变化后的 UI 更新需要你自己写逻辑。这种“自由”和“原始”并存的状态决定了它的适用范围和使用方式。2. 原生组件化开发的核心思路与设计取舍2.1 为什么浏览器原生方案值得重新审视大部分前端开发者对 Web Components 的观望都卡在“它没有响应式系统、写起来啰嗦”这个印象上。说实话这个印象不算错但要看场景。在单页应用内部Vue/React 的响应式系统和组件开发体验确实无可替代。但当你面对的是“跨项目复用”“多技术栈共存”“第三方生态集成”这类问题时框架组件的优势会被框架绑定问题抵消。Web Components 的价值恰恰体现在这里它是浏览器和页面之间的一份公共契约任何框架都认识这份契约。我们的实际经历很有说服力。把用户选择器做成 Web Component 之后Vue 项目里直接在模板里写user-pickerReact 项目里通过createElement或者 JSX 方式引用jQuery 项目里直接document.createElement(user-picker)都能正常工作。测试、维护、版本升级只要做一次所有项目同步受益。还有一个被低估的优点Web Components 没有运行时版本兼容问题。Vue 2 项目升级 Vue 3 时组件 API 可能不兼容但 Web Components 基于浏览器标准只要浏览器支持就一直可以运行。这里要强调一下它最适合的是“组件层面的公共基建”而不适合替代框架完成复杂的业务编排。2.2 设计组件时的封装边界怎么定交接过组件库的人都知道“封装边界”是组件化最难的决策。Web Components 因为 Shadow DOM 的隔离特性封装边界更加敏感。如果边界划太细组件数量爆炸页面碎片化严重划太粗组件内部逻辑复杂可复用性又下降。我常用的划分标准是三条业务无关性优先。通用组件尽量不携带业务逻辑数据由外部传入事件由外部监听。比如日期选择器只负责日期选择和显示至于选中日期后触发什么请求交给业务方。单组件单职责。一个组件只做一件事。用户选择器不负责权限校验权限校验由业务方在事件回调中处理。状态托管的粒度。组件内部状态尽量少。能通过属性传入的状态就通过属性传能通过事件交给外部的逻辑就通过事件回调。内部状态越多组件的黑盒程度越高排查问题越难。在 Shadow DOM 内部如果你把组件样式全部封装起来外部无法调整组件内部元素样式这时就必须在设计上预留 CSS 自定义属性作为定制入口。比如组件里的主色、圆角、间距都通过--picker-primary-color这样的变量暴露给外部。这不是可选优化而是组件能否在真实业务里被有效使用的关键。2.3 轻量化不等于功能少能力边界在哪Web Components 没有自带路由、状态管理、SSR 方案这些能力确实不如框架生态丰富。但轻量不代表简陋它的能力边界值得重新审视。可以通过属性Attributes和属性反射Property Reflection实现数据输入可以通过自定义事件Custom Events实现数据输出和交互通知可以通过observedAttributes和attributeChangedCallback实现响应式更新可以通过connectedCallback/disconnectedCallback处理生命周期可以通过 Shadow DOM 做样式隔离可以通过 CSS 自定义属性做主题定制可以通过:host选择器控制组件宿主元素自身的样式。这些能力覆盖了日常组件 80% 的需求。对于复杂状态管理类似大型表单联动的深层依赖确实不如 Vue/React 顺手但可以借助全局事件总线或者外部状态容器来补充不至于完全束手无策。3. 实操从零手写一个可复用的 Web Component3.1 环境准备与项目骨架先说结论写 Web Components 不一定需要构建工具现代浏览器原生支持 ES Modules你可以直接用script typemodule引入组件文件。但为了开发体验和产物优化我建议用 Vite 作为打包开发服务器。一个最小化的项目骨架npm create vitelatest my-web-component -- --template vanilla cd my-web-component npm install npm run dev在src目录下新建components/user-avatar.js这个文件用来定义user-avatar组件。组件文件推荐命名为“标签名 内容”的组合便于定位和维护。3.2 自定义元素、Shadow DOM 和模板的配合写法一个完整的 Web Component 通常由三部分构成类定义、模板结构、注册逻辑。下面写一个最简单的用户头像组件用来演示整体写法。// src/components/user-avatar.js const template document.createElement(template); template.innerHTML style .avatar { display: inline-block; width: 48px; height: 48px; border-radius: 50%; background-color: #e5e7eb; overflow: hidden; text-align: center; line-height: 48px; font-size: 20px; color: #6b7280; user-select: none; } img { width: 100%; height: 100%; object-fit: cover; } /style div classavatar/div ; class UserAvatar extends HTMLElement { constructor() { super(); this.attachShadow({ mode: open }); this.shadowRoot.appendChild(template.content.cloneNode(true)); this.avatarEl this.shadowRoot.querySelector(.avatar); } static get observedAttributes() { return [name, src, size]; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue newValue) return; if (name name) this.updateName(newValue); if (name src) this.updateImage(newValue); if (name size) this.updateSize(newValue); } connectedCallback() { this.updateName(this.getAttribute(name)); this.updateImage(this.getAttribute(src)); this.updateSize(this.getAttribute(size)); } updateName(name) { if (!name || this.getAttribute(src)) return; this.avatarEl.textContent name.charAt(0).toUpperCase(); } updateImage(src) { if (!src) { this.avatarEl.textContent this.getAttribute(name)?.charAt(0).toUpperCase() || ?; return; } const img document.createElement(img); img.src src; this.avatarEl.innerHTML ; this.avatarEl.appendChild(img); } updateSize(size) { const parsedSize parseInt(size, 10) || 48; this.avatarEl.style.width ${parsedSize}px; this.avatarEl.style.height ${parsedSize}px; this.avatarEl.style.lineHeight ${parsedSize}px; } } customElements.define(user-avatar, UserAvatar);在页面里这样使用user-avatar name张三 size64/user-avatar user-avatar srchttps://example.com/avatar.jpg size32/user-avatar注意几个关键点attachShadow({ mode: open })里的mode可以根据需要选open或closed。open允许外部通过element.shadowRoot访问组件内部排查问题时更友好closed严格封闭但外部无法调试不推荐。模板克隆使用template.content.cloneNode(true)是因为每个组件实例都需要独立的 DOM 树。如果直接把template.content赋值给多个实例会发生元素被抢占的问题。observedAttributes中列出的属性发生变化时会触发attributeChangedCallback。注意这个回调在connectedCallback之前也可能触发因此要保证回调方法的幂等性。3.3 属性变化监听与事件派发组件不能只是静态展示还要能和外部交互。Web Components 的数据输出方式主要是自定义事件。使用CustomEvent可以让事件携带任意复杂数据这点比原生事件更灵活。举个例子做一个简单的评分组件用户点击星标后组件对外触发rate-change事件class RatingStars extends HTMLElement { constructor() { super(); this.attachShadow({ mode: open }); this.shadowRoot.innerHTML style .star { cursor: pointer; font-size: 24px; color: #d1d5db; user-select: none; } .star.active { color: #f59e0b; } /style div classwrapper/div ; this.wrapper this.shadowRoot.querySelector(.wrapper); this.wrapper.addEventListener(click, (e) this.handleClick(e)); } static get observedAttributes() { return [value, max]; } attributeChangedCallback() { this.render(); } connectedCallback() { this.render(); } get value() { return parseInt(this.getAttribute(value) || 0, 10); } set value(val) { this.setAttribute(value, String(val)); } render() { const max parseInt(this.getAttribute(max) || 5, 10); const current this.value; this.wrapper.innerHTML ; for (let i 1; i max; i) { const span document.createElement(span); span.className star${i current ? active : }; span.textContent ★; span.dataset.value i; this.wrapper.appendChild(span); } } handleClick(e) { const span e.target.closest(.star); if (!span) return; const newValue parseInt(span.dataset.value, 10); this.value newValue; this.dispatchEvent(new CustomEvent(rate-change, { detail: { value: newValue }, bubbles: true, composed: true })); } } customElements.define(rating-stars, RatingStars);这里有两个细节特别重要composed: true。如果组件使用了 Shadow DOM那么 Shadow DOM 内部触发的原生事件默认无法穿透到组件外部。不加composed: true外部监听器根本收不到组件内部派发的事件。bubbles: true。让事件能够沿着 DOM 树向上冒泡方便在父级容器统一监听。在使用方监听事件时外部代码这样写document.querySelector(rating-stars).addEventListener(rate-change, (e) { console.log(用户选择的分值, e.detail.value); });3.4 样式隔离与主题定制Shadow DOM 最大的吸引力就是样式隔离。但很多刚开始用的人会发现“隔离过头了”——外部传入的 class 和继承的字体样式在组件内部完全不生效导致组件在不同页面中的观感不一致。解决方案不是放弃 Shadow DOM而是通过 CSS 自定义属性预留主题入口。组件内部使用 CSS 变量声明颜色、圆角、间距外部通过修改变量来定制/* 组件内部 */ :host { --avatar-bg: #e5e7eb; --avatar-color: #6b7280; } .avatar { background-color: var(--avatar-bg); color: var(--avatar-color); }外部页面或者全局样式中覆盖user-avatar { --avatar-bg: #3b82f6; --avatar-color: #ffffff; }需要注意:host选择器的作用对象是组件宿主元素本身即user-avatar标签不是组件的 shadowRoot 内部元素。CSS 自定义属性具有继承性可以穿透 Shadow DOM 边界这是目前解决 Shadow DOM 样式定制的标准做法。不要试图在外部通过user-avatar .avatar { ... }来改内部样式这在 Shadow DOM 模式下是无效的。3.5 组件发布与按需加载组件写好后打包发布也很重要。推荐单组件独立打包产物格式选择 ESM。Vite 的库模式配置可以针对user-avatar.js做单文件输出// vite.config.js import { defineConfig } from vite; export default defineConfig({ build: { lib: { entry: src/components/user-avatar.js, formats: [es], fileName: user-avatar, }, outDir: dist/user-avatar, }, });这样每个组件产物只包含自身代码体积小且可以按需加载。使用时通过动态import实现懒加载button idload-avatar加载头像组件/button script typemodule document.querySelector(#load-avatar).addEventListener(click, async () { await import(./dist/user-avatar/user-avatar.js); const avatar document.createElement(user-avatar); avatar.setAttribute(name, 李四); document.body.appendChild(avatar); }); /script如果组件之间有公共依赖比如工具函数库建议提取为单独的 chunk避免每个组件都打包一份重复代码。4. 避坑指南我在实战里踩过的几个坑4.1 表单类组件的 value 同步问题表单类组件比如下拉选择、日期选择在 Web Components 里有个很隐蔽的坑外部通过formData获取表单值时Web Components 内部的值不会自动提交。因为自定义元素默认不参与表单提交除非你主动实现了ElementInternals的表单关联能力。我在开发一个自定义下拉框时遇到new FormData(form)拿不到组件值的问题排查了很久。现代浏览器支持ElementInternals来将自定义元素与宿主表单关联实现真正的表单参与class UserSelect extends HTMLElement { constructor() { super(); this.internals this.attachInternals(); } set value(val) { this.internals.setFormValue(val); } get value() { return this.internals.formValue; } }使用attachInternals()时有一个注意点并非所有自定义元素都适合表单关联。只有真正具有表单语义的组件才需要这样处理普通展示组件没必要引入额外复杂度。4.2 框架配合时的 ref 与事件绑定问题在 React 中使用 Web Components最常踩的坑是 React 的合成事件系统不识别自定义事件。比如在 JSX 里写onRateChangeReact 并不会把它绑定到组件的rate-change事件上。需要先获取原生 DOM 元素的引用再用原生addEventListener绑定import { useRef, useEffect } from react; function RatingWrapper() { const ref useRef(null); useEffect(() { const el ref.current; const handler (e) console.log(rate:, e.detail.value); el.addEventListener(rate-change, handler); return () el.removeEventListener(rate-change, handler); }, []); return rating-stars ref{ref}/rating-stars; }Vue 3 的情况稍好一些在模板里可以直接监听自定义事件rating-stars rate-changehandleRate/rating-stars但要注意 Vue 3.2 之前的版本对属性attribute和属性property的处理有差异。如果组件属性名包含大写字母或者短横线在 Vue 模板里使用的写法可能不一致最好统一使用短横线命名法。4.3 SSR 与首屏渲染的注意事项服务端渲染场景下Web Components 会遇到一些问题。主要原因是组件逻辑依赖connectedCallback在浏览器端运行时执行而 SSR 阶段没有 DOM 环境组件模板内容不会渲染到 HTML 中。我在 Next.js 项目里使用 Web Components 时遇到的情况是组件区域在首屏显示为空客户端水合之后才出现内容。解决思路有两种服务端渲染容器。在服务端输出组件的占位结构比如一个同样尺寸的骨架屏等客户端激活后替换。避免在首屏关键路径使用 Web Components。把核心内容用普通 HTML 直接输出Web Components 只负责增强型交互比如对话框、折叠面板、评分等非关键内容。另外connectedCallback可能执行多次。在 Vue/React 客户端激活和虚拟 DOM 更新过程中元素可能被反复创建和销毁如果组件里有事件监听或者全局变量务必在disconnectedCallback中清理。4.4 可访问性容易被忽略对于自定义元素浏览器无法自动推断组件的角色和键盘交互方式。ARIA 属性需要手动设置。很多 Web Components 组件做完之后用鼠标操作没问题但键盘用户直接卡住。以评分组件为例星标区域应该支持方向键切换并设置roleradiogroup和aria-label。在组件内部添加键盘支持this.wrapper.addEventListener(keydown, (e) { if (e.key ArrowRight) { this.value Math.min(this.value 1, max); this.render(); } if (e.key ArrowLeft) { this.value Math.max(this.value - 1, 1); this.render(); } });同时给整个组件设置tabindex0。这部分工作很容易被忽略但在实际上线后无障碍评审和用户体验都会因此受益。5. 选型建议什么时候用什么时候别硬上3.1 什么时候适合优先考虑跨技术栈组件复用你同时维护 Vue、React、Angular 项目希望 UI 组件只写一套。第三方嵌入式组件你要给外部网站或低代码平台提供可嵌入的组件宿主环境完全不受你控制。长周期系统不想被框架生态绑架希望组件在框架大版本升级周期内仍然稳定存活。团队协作边界明确组件由基础架构组负责业务团队只负责使用且组件内部不需要深度联动业务状态管理。3.2 建议谨慎使用的场景复杂业务组件。组件内部包含大量状态联动、异步校验、嵌套表单逻辑用 Web Components 写会非常痛苦Vue/React 的响应式开发体验远超原生实现。项目初期快速迭代。组件边界还不稳定频繁改动组件 API 时框架组件的热更新和类型提示会让你省很多事。团队缺乏相关经验。团队成员都熟悉某框架但对 Shadow DOM、Custom Elements 的调试手段不熟初期效率会明显下降。3.3 实用心得把它当“通用积木”而不是框架替代品我个人的体会是Web Components 最理想的定位是“通用积木层”而不是框架的对手。框架组件负责业务场景Web Components 负责跨场景的底层能力。两者可以共存在 Vue 项目里业务页面使用 Vue 组件公共头像、选择器、提示框等基础组件用 Web Components在 React 项目里同理。实际维护中这种模式非常舒服。基础组件升级一次所有项目同步生效不需要在不同框架里维护多套同功能组件。另一个好处是老项目里的 jQuery 页面也能用上新组件这在存量系统改造中意义很大。最后再分享一个小技巧给 Web Components 设计属性时尽量采用“短横线命名 字符串类型”的简洁风格。短横线命名在 HTML 里是最自然的写法字符串类型可以避免布尔属性带来的歧义。比如不要用visiblefalse来表达隐藏而是用hidden属性存在与否来控制。这样使用方的理解成本最低也能减少跨框架场景下的类型转换麻烦。