Web Components工程化落差:从标准到落地的实战指南

Web Components工程化落差:从标准到落地的实战指南 去年我做组件中台评审的时候收到过一份让人心情复杂的设计组里一位资深工程师用原生 Web Components 写了一个跨框架的数据图表组件Custom Elements、Shadow DOM、命名规范都挺标准。结果代码走了三轮 review大家最终达成一致——这个组件“技术上没问题但要落地还差得远”。这完全不是他个人能力的问题而是 Web Components 这个技术标准到了真实工程环境里和理想状态之间有一道巨大的鸿沟。这道鸿沟是标准与工程实践之间的落差。组件标准本身没有问题问题在于标准只定义了平台层的能力却没有定义组件在业务里如何协作、如何测试、如何被框架消费、如何做服务端渲染。网上常年有人搜“web components kit”这类关键词本质上就是被原生 API 折磨过之后想找一套开箱即用的工程化套件。可惜这套“套件”到今天也没有一个绝对主流的答案。这篇文章不打算重复标准文档里那套概念解释我只想站在一个真正评估过、试用过、也放弃过、又捡起来过的工程师角度把这几年看到的落差一条条掰开讲清楚。如果你正打算在团队里推 Web Components或者因为技术选型和同事吵过架这篇应该能帮你在吵架前把问题都看明白。1. 标准本身解决的历史问题它们本该是“正确答案”先别急着吐槽 Web Components 难用。要说清楚为什么它普及不起来得先承认一个事实这套标准当年想解决的问题放在十多年前都是真实存在的痛点而且它给出的方案从平台层面看并不差。1.1 Custom Elements先把“自定义标签”这件事定下来Web Components 由四部分组成其中 Custom Elements 是灵魂。它解决了前端发展早期的一个核心问题HTML 的标签是固定的你没法说mychart就是一个组件更没法让这个标签拥有生命周期。Custom Elements 允许你注册一个新标签自己定义 created、attached、attribute changed、detached 这些阶段要执行的逻辑。这在 AngularJS 时代是件不敢想的事那时候所有组件都是“框架内的标签”离开框架就变成一堆未知元素。1.2 Shadow DOM、Template 与 ES Modules封装与组合的原生方案Shadow DOM 解决的则是样式隔离。做过 BEM 命名的人都知道长期维护一套全局 CSS 有多痛苦后写的人永远在小心翼翼地加!important。Shadow DOM 直接把组件内部的样式和脚本关进一个独立的 DOM 子树外面再怎么浑水摸鱼也不容易渗进来。HTML Template 和 Slot 则提供了“组件内部结构模板 外部内容分发”的能力用插槽消费组件内容比字符串拼接 HTML 不知道高到哪里去了。最后再加上 ES Modules组件的引入和依赖管理有了浏览器原生的模块系统。这一整套组合拳从浏览器平台的角度看逻辑是自洽的。组件自注册、样式隔离、模板封装、模块化加载。任何一个有经验的前端都会承认这套设计在标准层面比十几年前的 jQuery 插件机制先进。1.3 标准的发布节奏核心的四件套等了整整十年问题出在节奏上。Web Components 最早在 2011 年左右开始提Custom Elements v1 一直到 2016 年前后才在 Chrome 稳定落地Safari 真正像样地支持 Shadow DOM v1要等到 2019 年前后。也就是说这套“浏览器原生的组件标准”从提出到基本可用花了将近十年。更关键的是标准里那些“补齐缺口”的能力来得比市场耐心更慢。比如 form-associated custom elements让自定义组件能像原生表单控件一样参与表单校验和提交这个能力拖到 2020 年前后才逐渐被浏览器接纳。constructable style sheets 和 adoptedStyleSheets到了 2021 年左右才在主流浏览器里稳定可用。declarative shadow DOM 搞出了服务端渲染的可能性但支持也很晚。标准慢半拍市场就不会等它。在标准逐步完善的这十年里React 和 Vue 已经用虚拟 DOM 组件模型把开发者的习惯牢牢锁住了。等到 Web Components 终于“够用”的时候前端社区已经不是十年前那个嗷嗷待哺的状态了。2. 落差一标准给的是零件不是框架每写一个组件都在造轮子这是我最想展开讲的一条也是很多人骂 Web Components“反人类”的根本原因。标准提供了基础协议但它没有规定组件应该怎么写才能协作得舒服。用原生 API 写几个组件你就会发现重复劳动多到让人崩溃。2.1 用原生 API 写一个 Counter你就明白问题在哪了看看用原生 Custom Elements 写一个最简单的计数器组件class XCounter extends HTMLElement { static get observedAttributes() { return [count]; } constructor() { super(); this._count 0; this.attachShadow({ mode: open }); this.shadowRoot.innerHTML button classdec-/button span classcount/span button classinc/button ; } get count() { return this._count; } set count(value) { this._count Number(value); if (this._countEl) this._countEl.textContent this._count; this.setAttribute(count, String(this._count)); } connectedCallback() { if (!this._boundInc) { this._boundInc () this.count; this._boundDec () this.count--; } this.shadowRoot.querySelector(.inc).addEventListener(click, this._boundInc); this.shadowRoot.querySelector(.dec).addEventListener(click, this._boundDec); this._countEl this.shadowRoot.querySelector(.count); this._countEl.textContent this._count; } disconnectedCallback() { this.shadowRoot.querySelector(.inc).removeEventListener(click, this._boundInc); this.shadowRoot.querySelector(.dec).removeEventListener(click, this._boundDec); } attributeChangedCallback(name, oldValue, newValue) { if (name count) { this._count Number(newValue); if (this._countEl) this._countEl.textContent this._count; } } } customElements.define(x-counter, XCounter);这段代码已经够精简了但你能清楚感觉到响应式绑定要自己写事件绑定和解绑要自己管attribute 和 property 的同步要自己维护。写业务组件的时候状态一多、属性一多、嵌套一多这套代码的规模和复杂度会指数级上升。再看看用 Lit 写同样的东西import { html, LitElement } from lit; import { customElement, property } from lit/decorators.js; customElement(lit-counter) export class LitCounter extends LitElement { property({ type: Number }) count 0; render() { return html button click${() this.count--}-/button span${this.count}/span button click${() this.count}/button ; } }没有任何意外Lit 帮你把组件内层该做的脏活全干了。2.2 每个团队都要自研一套“组件内层协议”原生 Web Components 最大的问题不是“难”而是“没有默认答案”。组件内部用函数式还是 class状态变化怎么触发渲染属性传对象要不要 JSON.parse事件名怎么起这些都写在每个团队各自的规范文档里但浏览器不帮你强制。于是你会发现不同团队写出来的原生 Web Components风格差异堪比不同方言放到一起组合使用时没人能保证它们的行为一致。这正是“web components kit”这类搜索词出现的原因。大家真正想要的不是某个 package而是一层“组件开发框架”让团队不需要每次从零处理响应式、模板更新、属性同步这些通用逻辑。Lit、Stencil、Aurelia 这些尝试都很好但它们各有拥趸没有一个像 React 那样成为绝对默认选项。这种生态上的“标准不统一”反过来又加剧了普及的阻力。2.3 框架时代养成的开发体验Web Components 补不上现在的前端开发者已经被 React hooks、Vue setup、组合式 API 喂惯了。组件内部状态、派生状态、生命周期方法都可以用一套心智模型统一解决。而 Web Components 原生 API 跨了这么多年依然停留在“手写发布订阅 手动 DOM 操作”的层次。这不完全是能力问题更多是设计哲学问题浏览器平台尽量少给你封装把选择权留给上层。这个哲学对基础设施是对的但对于需要快速交付业务的前端团队来说它不够。结果就是一旦组件数量超过两位数团队就会被重复的类公有逻辑拖垮最终无奈自研一套框架。我见过好几个团队最后都绕回到了 Lit 或者 Stencil这就是标准的边界——独自使用原生 API 的建筑师必须一边写业务一边修路。3. 落差二Shadow DOM 的封装在真实工程里总会“漏风”Shadow DOM 一直是被宣传得最响亮的部分什么“样式天然隔离”“组件内部和外部彻底解耦”。听起来很美但真实组件库一旦规模化它反而成了最让人头疼的约束之一。3.1 主题化与设计系统隔离最直接的痛样式隔离确实解决了命名的冲突但代价是外部想要调整组件外观变得极其困难。假设你做了一个设计系统包含十几个基础组件每个组件都用 Shadow DOM 包死。现在设计规范改版要求所有按钮的圆角从 4px 变成 6px。如果组件内部写死了border-radius: 4px你只有两条路要么改组件源码重新发版要么用 CSS 自定义属性把圆角暴露出去。如果你在设计初期没有把每个组件的样式变量定义清楚后面就只能给组件逐个开洞。CSS 自定义属性是唯一能穿透 Shadow DOM 的样式通道所以设计系统要提前约定一套全局变量体系从颜色、间距、圆角到字体大小全部变量化。可一旦公司大了、组件多了这个变量体系的维护就是一份全职工作。而且自定义属性在 shadow 内部的继承规则、在插槽内容上的表现和普通 CSS 完全不同踩过坑的人都知道我说的是什么意思。3.2 可访问性与表单两个绕不开的合规问题组件封得越严可访问性的问题越难窥见。外部页面的屏幕阅读器读不到 shadow DOM 内部的语义结构尤其是在一些老版本读屏和浏览器的组合下表现非常碎片化。焦点管理在跨组件场景下尤其痛苦比如一个弹窗组件它需要把焦点锁在弹窗内部但焦点事件一旦跨越 shadow boundary很多自动行为就断了需要你写大量 polyfill 和特殊处理。表单是另一个大头。普通input放在form里天然参与表单校验、提交、重置。你要是写一个自定义输入组件用 Shadow DOM 包住内部的input很抱歉它默认不参与任何表单行为。标准后来推出的 form-associated custom elements本质上就是为了补这个坑但普及度至今不高。不少团队为了不让业务方抱怨表单提交莫名其妙丢失字段干脆放弃在表单组件中使用 Shadow DOM回到老路。3.3 调试体验shadow DOM 让 DevTools 都变得不好使还有一个特别实际的问题调试。Chrome DevTools 里默认情况下你甚至看不到 shadow root 里面的 DOM 结构要手动打开 “Show user agent shadow DOM” 才能看到。这还不算完当你需要排查一个样式 bug 时样式来源可能既来自组件内部 stylesheet又来自外部注入的全局 CSS 变量又来自 adoptedStyleSheets 构造的样式表在 Elements 面板里层层展开别提多费劲。你面对的是一堆浏览器内部标签和复杂的样式层叠关系排查问题的速度直接掉一个档位。这意味着什么意味着团队里每个人的本地开发体验都会变差。一个个小问题叠加起来就会让团队在技术选型评审时得出“这东西搞不定”的结论哪怕它在架构层面看上去非常有魅力。4. 落差三框架协作、SSR 与工具链“最后一公里”迟迟没打通Web Components 号称“跨框架复用”可实际上主流的 React、Vue 项目在消费自定义元素时体验远不如消费框架自己的组件。这里面的坑不是用几次就能全部遇见的我踩完一遍之后才明白什么叫“标准归标准协作归协作”。4.1 React 与 Vue 传参attribute 和 property 的经典之坑React 在 2023 年之后的版本里对 Web Components 的支持有所改进但长时间里一直存在一个老大难问题React 只擅长设置 attribute不擅长设置 property。你给自定义元素传一个对象React 默认会把它转成字符串[object Object]传一个函数也会因为 attribute 序列化问题直接失效。你只能用 ref 拿到 DOM 节点再手动dom.someProperty xxx。这套操作在业务代码里写多了明显就比在框架内部传 props 别扭。Vue 在这方面聪明一点它用模板编译时区分 property 和 attribute。但在自定义元素上依然需要你把组件标记为 customElement 或使用Vue.extends的 special treatment否则渲染和事件绑定还是会出各种小问题。这不只是写法上的别扭更是心智负担团队里每个人都要时刻记住“我现在的组件是框架组件还是原生 Web Component”对切换的边界必须非常清楚。4.2 SSR 与水合标准组件在服务端渲染上的尴尬服务端渲染是另一个大坑。原生 Web Components 在 Node 环境里没有 DOM、没有 customElements registry几乎没法直接服务端渲染。你切到浏览器端页面初始化加载时自定义元素可能还没升级完成用户会先看到一段空标签或默认内容然后组件才在connectedCallback里渲染出真正的界面。这种闪烁在慢网环境下非常明显。declarative shadow DOM 算是标准层给出的折中方案服务器直接把 shadow 内容以模板形式输出浏览器端无需 JS 也能拿到首屏结构。但要让框架识别并生成这套标记React、Vue 的服务端渲染器都需要额外适配。目前这些适配方案都还处于相对小众和手工的状态根本谈不上开箱即用。对大多数重型内容站来说这个短板直接否决了 Web Components。4.3 测试、类型与构建全靠社区自己补前端开发早就习惯了组件库有完整的测试、类型、构建方案。Web Components 这边每一步都缺一个“官方答案”。JSDOM 对 shadow DOM 的支持长期不完整很多基于 Jest 的组件单测跑到 shadow root 底层就失效需要额外安装 polyfill。类型方面Custom Elements 没有原生的 TypeScript 类型信息社区后来搞了 Custom Elements Manifest但这已经属于生态补丁而不是标准自带能力。构建集成也一样Vite 对 Web Components 的支持是逐步完善的Webpack 5 的模块联邦和自定义元素结合时还得自己处理挂载和卸载。这几个缺口叠加在一起导致团队在用 Web Components 之前必须先“自建一整套基础设施”。大家都是来写业务的不是来建设标准试验田的。对比 React 和 Vue 那边完整的工具生态选择 Web Components 的机会成本就显得非常高。5. 工程复盘什么场景真适合 Web Components什么场景别硬上既然讲了这么多落差是不是 Web Components 就一无是处不是。关键是它有自己适配的场景而且很多场景里优势非常明显。问题在于多数团队一开始就把目标定错了。5.1 真正跑得通的四个场景第一个是跨技术栈的组件分发。如果团队里同时有 React、Vue、Angular 甚至原生 JS 的项目你没法让所有人都用同一个框架版本。这时把通用组件做成 Web Components给各个业务栈一个统一的分发层是性价比非常高的方案。第二个是低代码平台和插件市场。宿主应用要加载第三方组件不可能要求第三方跟宿主用同一个框架Web Components 天然是“最安全”的沙箱形式。第三个是非前端页面嵌入。比如后管系统的一小块区域需要嵌入一个复杂交互组件但又不想把整个页面拖进 React 体系这个时候独立 Web Component 非常方便。第四个是设计系统输出。设计系统做成 Web Components可以同时供多端、多框架消费样式隔离恰好保护了 token 边界。5.2 不建议 Web Components 硬扛的场景反过来如果组件内部有大量业务状态、复杂的路由联动、全局状态管理Web Components 就不是好选择。标准没有提供状态管理的答案你在组件里引入一个 framework-less store 又要处理销毁和同步复杂度和维护成本很快失控。内容类站点需要做 SSR 和 SEOWeb Components 目前也要付出很高的适配成本。团队已经在统一框架下开发硬要把所有组件都改成 Web Components只会多出一层抽象没有任何收益。5.3 团队讨论时的决策框架我后来给团队定了一个简单框架组件是否有跨技术栈消费的需求是否有脱离框架直接运行的场景是否接受首屏轻度白屏/延迟渲染如果三个问题有两个是“是”Web Components 才值得考虑。如果没有就直接用框架本地组件别为了“标准”而标准。很多时候用 Web Components 的理由不是它更潮而是它能解决跨项目协作成本问题。6. 如果团队决定要上这套最小工程方案能少踩一半坑假设你评估完还是决定用那千万别再从原生 API 开始写也别听那些“Shadow DOM 必须全程用”的说法。下面这套方案是我几轮实战后觉得最均衡的。6.1 选型Vite Lit别再从原生 API 开始Lit 几乎是现在最接近“框架体验”的 Web Components 封装层响应式属性、模板更新、事件绑定都有。Stencil 也很强但它的编译链更重且和框架耦合度更高适合需要把一套代码编译到多个目标框架的情况。多数业务组件场景用 Vite 构建 Lit 项目已经足够简单。组件开发过程中直接跑一个静态页面看到的是和浏览器里完全一致的体验不用等框架启动。6.2 样式策略别一上来就用 Shadow DOM 包死这是我吃过亏之后得出的经验。组件如果只是展示型、结构简单Shadow DOM 可以放心用。凡是涉及表单控件、涉及需要外部覆盖样式的业务组件我非常建议用浅封装甚至不用 Shadow DOM而是靠 CSS 自定义属性和命名约定来做约定式隔离。把所有变量提前设计好是保护后期迭代的关键。组件内部样式优先用 adoptedStyleSheets而不是反复塞style标签前者在重复渲染时性能好很多。6.3 状态、事件与调试几个反复踩的细节组件状态传入时能用 attribute 传原始类型就用 attribute传对象时记得 JSON.stringify接收端再 parse 一次否则很容易拿到[object Object]。布尔属性在 HTML attribute 里的语义和 property 不同组件内部用 property 来判断更可靠。组件之间通信尽量用自定义事件事件名带前缀比如x-dialog-open避免和全局事件冲突。还有 connectedCallback 里不要假定外部 DOM 已经全部准备就绪需要操作宿主外部节点时用queueMicrotask保证顺序。调试建议里最重要的一条是把 DevTools 的 Show user agent shadow DOM 打开不然你连子节点都看不到。遇到样式问题时先检查 CSS 变量是否定义在 shadow root 的宿主元素上而不是外部容器上。调试进程里最坑的一种情况是你明明给父容器写了背景色但 shadow 内部没继承到因为 CSS 变量和普通样式属性的继承规则不同提前知道能省半天时间。这套方案不能说没有坑但至少把原生 API 带来的重复劳动降到最低把样式和通信的边界做了明确约定。团队再配合一个简单的 Custom Elements Manifest 自动生成工具类型和文档两头都能跟上就可以相对体面地落地了。我前后在三个项目里评估过 Web Components。第一回被 Shadow DOM 的主题化搞得焦头烂额第二回被 SSR 和水合问题直接劝退第三回把它的定位从“替代框架”改成“跨项目组件分发层”反而稳定跑了一年多。我的体会是Web Components 不是不好也不是会死它更像是处于一个“标准已经能用、体验还没到位”的过渡期。真正跑得远的团队都把它当成组件分发协议而不是前端框架。如果你想在团队里试点建议从对外组件、插件容器、低代码面板这类边界清晰、跨栈需求强烈的场景开始别一上来就把整个业务迁过去。标准是基础设施基础设施解决的是连接问题不是应用问题。想清楚这一点很多坑其实都可以绕开。