1. 框架之外的第三条路:原生组件化解决的现实痛点
这两年前端圈有个很有意思的现象:一边是各种框架的组件生态越来越庞大,一边是越来越多团队开始回头研究浏览器原生能力。我在某中型项目里负责前端基建时,就遇到过典型的困局——技术栈从Vue迁到React时,之前沉淀的几十个业务组件几乎全部作废,重写成本高到让人崩溃。后来我开始系统研究Web Components,才发现组件化的终点可能根本不在框架里,而在浏览器标准里。
先说清楚Web Components到底是什么。它不是某个库,也不是框架,而是浏览器原生提供的一组标准API组合,让我们可以不依赖任何第三方依赖,直接创建带封装逻辑的自定义HTML标签。因为它是W3C标准的一部分,所以原生支持它的浏览器都能直接运行,不需要编译、不需要运行时注入、不需要打包成特定格式才能用。简单说,就是浏览器自己变成了组件化平台。
这个思路的价值在跨团队、跨项目协作时特别明显。一次在多方合作中,几个小组分别使用不同的技术栈,但是都要用到同一个业务组件,用框架封装就必须要统一技术栈,而用Web Components封装,则是一个标准HTML标签,谁都能直接引用。这就是组件化开发的“终极形态”——真正做到了技术栈无关。
而且要注意,Web Components并不是要取代框架。Vue、React这类框架解决的是状态管理、渲染调度、开发体验的问题,而Web Components解决的是组件复用标准的缺失。两者不是对立关系,反而是可以共存的。React官方文档里就明确支持在React中嵌入Web Components组件,反过来也一样。这意味着我们把通用业务组件用原生标准封装一层,框架用来做应用壳和状态层,边界很清晰。
那么问题来了,既然这么好,为什么过去几年一直不温不火?我分析下来主要有三个原因。
第一是早期浏览器兼容性确实不佳。IE11时代Shadow DOM的polyfill跑起来性能很差,很多开发者试过一次就放弃了。但现在Chrome、Edge、Firefox、Safari都已经原生支持三大核心API,2020年后基本可以放心在生产环境使用,兼容性问题已经翻篇了。
第二是开发体验不完善。没有框架提供的响应式数据绑定,没有虚拟DOM的渲染优化,原生写法在复杂状态管理上确实会显得啰嗦。但如果只封装独立的、带自治状态的UI组件,原生API完全够用。
第三是生态惯性。大家都习惯了用框架封装组件,很少有人停下来想一想:如果有一天框架不再维护了,我们的组件资产怎么办。这是组件资产长期沉淀需要考虑的实际问题。
我之前接手过一个已经运行多年的项目,前前后后经历了三轮技术栈切换,里面的业务组件反复被推翻重写。如果一开始就用Web Components封装底层能力,至少通用层是能留下来的。这也是我写这篇文章的初衷——把实际用下来觉得可靠的核心知识、踩过的坑、推荐的做法整理成一份可以照着做的路线图,让新团队在决定“要不要引入原生组件化”之前,先把底牌看清楚。
2. 三大核心API拆解:Custom Elements、Shadow DOM、Templates的协作方式
Web Components不是单一规范,而是三份独立的浏览器标准:Custom Elements(自定义元素)、Shadow DOM(影子节点)、HTML Templates(模板)。它们各自解决了一部分问题,组合在一起才构成完整的组件化能力。我刚开始学的时候,最困惑的就是这三者之间到底怎么分工、怎么协作。下面拆开来讲。
2.1 Custom Elements:让浏览器认识新标签
Custom Elements解决的问题很直接:浏览器默认只认识div、span、button这些内置标签,而它允许我们定义一个新的标签名,比如<user-card>,让浏览器知道这个标签该渲染什么、怎么响应属性变化。
自定义元素分两种:自治自定义元素(Autonomous Custom Elements)和自定义内置元素(Customized Built-in Elements)。前者是完全独立的新标签,比如<my-widget>;后者是继承内置标签能力,比如让<button>增加额外行为,用is="my-button"调用。实际项目中我几乎只用自治自定义元素,因为自定义内置元素需要依赖浏览器的is属性支持,兼容性问题比较麻烦,而且收益不大。
定义一个自定义元素需要继承HTMLElement,然后在customElements.define()中注册:
class UserCard extends HTMLElement { constructor() { super(); this.username = '默认用户'; } static get observedAttributes() { return ['username', 'avatar']; } connectedCallback() { this.render(); } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'username') this.username = newValue; this.render(); } render() { this.textContent = `用户:${this.username}`; } } customElements.define('user-card', UserCard);这段代码里有几个关键点。observedAttributes静态属性声明了组件要监听的属性名单,只有在这个名单里的属性变化时才会触发attributeChangedCallback,这个设计是为了避免每次属性变化都执行不必要的回调,提升性能。connectedCallback在元素被插入DOM时触发,适合做初始化。disconnectedCallback在元素从DOM移除时触发,适合清理事件监听器。
实际上生命周期回调有五个:constructor(创建实例)、connectedCallback(挂载)、disconnectedCallback(卸载)、attributeChangedCallback(属性变化)、adoptedCallback(被移入新文档)。其中adoptedCallback日常很少用到,但如果在iframe之间移动元素时会触发。
2.2 Shadow DOM:真正的样式隔离
如果说Custom Elements解决了“标签定义”问题,那么Shadow DOM解决的是“封装”问题。它能给自定义元素挂上一个独立的DOM子树,这个子树内部的样式和结构对外部是隐藏的,外部的全局样式也无法作用进来,反过来内部样式也不会泄漏到外部。这就实现了真正的样式隔离。
我举个例子你就明白了。普通DOM结构里,如果外部样式写了p { color: red; },组件内部所有p标签都会变成红色。但用Shadow DOM包裹之后,外部样式就进不来了,组件内部可以随心所欲地写样式而不影响外部。
class ShadowCard extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); } connectedCallback() { this.shadowRoot.innerHTML = ` <style> .card { border: 1px solid #eee; padding: 16px; border-radius: 8px; } .title { font-size: 18px; font-weight: bold; } </style> <div class="card"> <div class="title"><slot name="title"></slot></div> <div><slot></slot></div> </div> `; } } customElements.define('shadow-card', ShadowCard);attachShadow({ mode: 'open' })里的mode参数值得特别说下。open模式表示外部可以通过element.shadowRoot访问到内部的Shadow DOM,便于调试;closed模式则会返回null,外部完全无法访问。我的建议是一律用open,因为closed并不能真正阻止恶意访问(页面自己总归有办法拿回来),反而会让DevTools调试和单元测试变得很麻烦。之前有团队为了“安全”用closed,结果排查问题时要各种hack才能看到内部结构,非常痛苦。
另外一个容易忽略的点是:Shadow DOM创建后,元素内部的操作就分成了两层——外部使用document.querySelector是找不到Shadow内部元素的,必须用shadowRoot.querySelector。这对测试也有影响,使用@testing-library时要用container.shadowRoot来查询内部节点,好多初学者在这里卡住过。
2.3 Templates与Slot:模板复用与内容分发
HTML Templates解决的问题是“组件内容模板”的组织。传统写法里,组件结构写在JavaScript字符串里,既要管引号转义又要拼字符串,很痛苦。<template>标签则可以将HTML片段定义在页面中,它不会被渲染、不会产生额外请求,之后在JavaScript中通过content属性取出模板内容进行克隆。
<template id="card-tpl"> <style> .card { font-family: Arial, sans-serif; border: 1px solid #ddd; } </style> <div class="card"> <span class="label">默认内容</span> </div> </template>这段模板代码写在HTML里,浏览器不会渲染它,但在JavaScript里可以随时取出来用。
Slot(插槽)是模板的延伸机制,解决的是“组件结构定了,但部分内容由使用者填充”的需求。使用者在组件标签里写的内容,会被映射到模板内对应name的<slot>位置。这本质上是组件化的内容分发机制,和Vue的<slot>、React的children概念同源。同一个组件,每个使用场景可以填入不同的标题、正文、按钮文案,而不需要修改组件本身。
三大API的分工可以用一句话概括:Custom Elements定义了组件的生命周期和属性机制,Shadow DOM提供了样式和结构的隔离边界,Templates与Slot负责把结构模板和动态内容组织好。三者组合使用时,一般流程是:在connectedCallback里获取template模板内容,克隆后挂载到shadowRoot上,然后通过slot让外部内容填充进来。
3. 从零封装一个业务组件:Shadow DOM隔离下的完整开发流程
来一个实际的例子吧。我在项目里封装过一个“评分选择器”组件(rating-picker),它包含了属性监听、内部状态、样式隔离、事件触发、插槽分发这几个Web Components核心能力。拿它当样例,整个开发流程可以完整走一遍。
3.1 需求与设计:组件要暴露什么给外部
先想清楚组件的接口。外部使用这个组件时,需要通过属性控制初始评分,监听change事件感知评分变化,并且可以把自定义的提示文案塞进插槽。所以组件外部接口设计为:
- 属性:
max(最大评分,默认5)、value(当前评分,默认0) - 事件:
rating-change,携带detail.value - 插槽:
label,用于显示评分名称
设计接口时有一个原则值得记住:属性是组件对外的入参,事件是组件对外的出参,插槽是组件对外的内容扩展点。把这三个维度想清楚,组件边界就清晰了,不会出现外部需要操作组件内部元素才能完成功能的脏设计。
3.2 完整实现:模板、样式与逻辑的组装
组件定义我写在独立的JavaScript文件里,使用ES Module导出。
const template = document.createElement('template'); template.innerHTML = ` <style> :host { display: inline-block; user-select: none; } .rating { display: flex; gap: 6px; } .star { cursor: pointer; font-size: 24px; color: #ccc; transition: color 0.15s; } .star.active { color: #f5a623; } .label { margin-top: 4px; font-size: 14px; color: #666; } </style> <div class="rating" part="rating"> <span class="star">rating-picker { --star-color: #e74c3c; }组件内部样式改成:
.star { color: var(--star-color, #ccc); }这样一来,外部可以定制组件内特定元素的外观,组件内部的具体结构却不会暴露。这很适合业务组件的换肤需求。
:part则是另一个补充手段。它允许在组件内部元素上标记part="rating",外部通过::part(rating)设置样式。它能自定义的范围比自定义属性更大,但会暴露内部元素的选择入口,对封闭性有要求的场景需要谨慎使用。我在实际项目中几乎都用CSS自定义属性做定制入口,:part只在组件结构稳定且需要高度定制时用。
4. 跨框架复用的边界与协作:和主流框架搭配的真实体验
很多人有个误解:用了Web Components就要抛弃框架,二选一。其实完全不是这样。我实际项目里的最佳实践是:通用业务组件层用原生封装,应用层让框架接管。不过这里有一些协作细节,踩过坑才知道怎么处理。
4.1 在框架中加载原生组件的三种方式
第一种是注册后直接使用。在React中,引入自定义元素后,直接在JSX里写<rating-picker value={score} />就行。React 19之前的版本,对自定义元素的属性支持已经可以传原始字符串值和数字值,但传入对象等功能需要看具体版本。Vue 3的支持相对更顺滑,识别未知标签后会把它们当作自定义元素处理,属性和事件的绑定都直接映射到DOM API上。
第二种是封装适配层。如果原生的属性传递方式让你觉得别扭(比如需要传对象这种复杂类型),可以在React/Vue里包一层适配组件,把框架侧的props转为DOM属性,再把DOM事件转为框架事件。适配层代码不多,但显著提升了开发体验。
第三种是借助框架的命令式接口。比如在React中通过ref拿到DOM节点后直接调用组件暴露的方法(组件可以在类上定义setScore()之类的方法)。这种方式适合偶尔需要命令式操作的场景。
4.2 环境实测:三个框架的接入表现
我在某模拟项目里分别用React、Vue和原生环境接入同一个rating-picker组件,把表现整理成了表:
| 框架 | 属性传递 | 事件监听 | 插槽使用 | 注意点 |
|---|---|---|---|---|
| React 19 | JSX属性会转为DOM属性,字符串/数字正常,复杂类型需额外处理 | 通过onRating-change这类事件监听不直观,最好用ref添加原生监听 | 通过children传入,可映射到slot | 需要为事件名做适配,否则会被React的合成事件机制干扰 |
| Vue 3 | :value="score"直接绑定,复杂类型也能传(通过DOM属性封装的setter) | @rating-change正常监听 | <template v-slot:label>正常 | 体验最接近原生,接入成本最低 |
| 原生HTML | document.createElement('rating-picker')或标签字符串 | addEventListener('rating-change') | 标签内直接写内容 | 无额外适配成本 |
实测下来,Vue的接入体验最顺滑,React需要额外留意属性和事件的适配。但这些问题都可以通过一层薄的适配组件解决,不构成阻碍。
4.3 难点场景:复杂类型属性传递与DOM事件命名
给自定义元素设置对象类型的属性要特别小心。原生HTML属性只能存字符串,所以setAttribute('config', JSON.stringify(obj))之后再手动JSON.parse是常见方案。可如果直接把这个字符串塞给属性,attributeChangedCallback里的newValue也是字符串,需要组件内部做好解析。
更优雅的做法是让组件提供一个属性setter。在自定义元素的类里,定义一个config属性访问器(getter/setter),外部在框架侧拿到DOM节点后直接赋值element.config = obj。但这种方式绕过了属性监听,组件需要在setter内部主动触发更新逻辑。我的建议是:对象型配置尽量走属性setter,字符串/数字型配置走attribute,两种方式在组件里共存,互不干扰。
事件的命名也需要遵守规范。自定义事件名不要用英文大写,避免和框架的合成事件机制产生歧义。像rating-change、select-change这样的kebab-case命名,在Vue和React里都能正常处理,不会发生事件名大小写导致的匹配问题。
4.4 跨框架复用的适用边界:什么场景值得、什么场景不值得
不是所有组件都值得用Web Components封装。我在内部复盘时总结了一张适用边界清单:
值得封装的情况:
- 会被多个不同技术栈团队复用的基础组件(如评分、上传、日期选择)
- 生命周期长、迭代慢的通用UI件(如公司统一设计系统的核心组件)
- 嵌入第三方站点或微前端的组件,宿主环境技术栈无法控制
- 存量系统的局部渐进式改造,不想为局部功能引入重型框架
不值得封装的情况:
- 只在一个框架内使用、没有跨栈复用需求的组件
- 状态逻辑复杂、和框架状态管理深度绑定的业务组件
- 需要大量依赖框架生态的组件(如需要Router、Store上下文)
- 团队没有长期维护标准封装能力的意愿,只是为了追新技术
用一句话总结:Web Components更适合做“底层通用资产层”,不适合做“业务逻辑应用层”。这个边界意识比技术本身更重要。
5. 原生组件实战中的高频坑位与排查思路
任何技术都有坑,Web Components也不例外。我实际用了两年多,整理了几个最容易踩的坑,每个都配了排查思路,希望你能跳过这些路障。
5.1 样式隔离的“过犹不及”:全局样式进不来,怎么办
Shadow DOM的样式隔离是双刃剑。隔离保护了组件,但也意味着外部定义的设计系统变量、重置样式、字体设置无法自动渗透。团队里有人把组件引入后发现中文字体全都变成了默认字体,非常困惑,排查了一下午才明白是全局的font-family没有穿透Shadow边界。
解决思路有三条:
- 组件内部显式继承字体相关属性,在
:host上设置font: inherit。 - 使用CSS自定义属性作为设计令牌入口,在设计系统里定义
--font-family-base等变量,组件内部统一通过var()引用。 - 对必须穿透的全局重置类样式,使用
::part暴露关键元素。
最稳妥的是第一条:组件内部不要依赖外界的中文或衬线字体,直接font: inherit,让组件自动跟随宿主环境字体风格。
5.2 表单控件提交黑盒:formAssociated的一次排查记录
有次业务反馈:用了自定义封装的输入框组件,表单提交时值不在FormData里。排查发现这是因为自定义元素不是标准的表单控件,浏览器不会自动把它纳入表单提交。用过<input>、<select>等标准控件时,这个问题不存在,但自定义元素需要主动声明自己是表单关联控件。
解决方法是使用ElementInternals的formAssociated特性:
class FormInput extends HTMLElement { constructor() { super(); this._internals = this.attachInternals(); } set value(v) { this._value = v; this._internals.setFormValue(v); } get value() { return this._value; } }启用formAssociated后,组件内部的_internals.setFormValue()会将值纳入所属表单的FormData,还能参与表单校验。这个API在Chrome和Firefox里支持度很好,Safari也已经有支持,可以放心使用。
5.3 生命周期时序陷阱:connectedCallback里的异步加载
另一个高频问题:在connectedCallback里加载异步数据,结果组件被移动位置后,旧的异步回调还在更新已经被卸载的元素。或者加载过快时,回调还没绑定就已经完成了异步操作,导致数据丢失。
我的处理方式是在disconnectedCallback里设置卸载标记:
connectedCallback() { this._isConnected = true; this._loadData(); } disconnectedCallback() { this._isConnected = false; } async _loadData() { const data = await fetchData(); if (!this._isConnected) return; this._render(data); }核心思想:异步回调回来后先判断元素是否还在文档中,不在就直接返回。这个模式也适用于定时器、滚动监听等场景。顺便一提,disconnectedCallback里要记得移除事件监听器和取消定时器,否则页面切换时容易触发内存泄漏。
5.4 事件重定向与composed标志
在Shadow DOM内部触发的事件,默认情况下外部监听不到,因为事件在Shadow边界被拦截了。如果想向外传递,需要设置composed: true。我在rating-picker里发rating-change事件时就用了bubbles: true, composed: true。
但要注意,composed: true的事件虽然能穿过Shadow边界,事件路径上冒泡经过Shadow Root之外的元素时,事件的target会被重定向为宿主元素本身。这是浏览器为了封装性做的设计——外部不该知道事件到底来自Shadow内部哪个子节点。因此如果外部监听事件后还想获取内部具体信息,需要使用event.detail来传递额外数据,而不是依赖event.target。这一点和React合成事件机制差异明显,团队第一次接入时容易在这里踩坑。
5.5 可访问性与空元素判断的隐性成本
自定义元素的自动化测试也容易踩坑。querySelector无法查到Shadow DOM内部的元素,必须改用shadowRoot查询。如果用了closed模式Shadow Root直接无法访问,测试框架还要绕路,浪费大量时间。还有内部输入框这类元素,需要手动设置aria-label、role等属性,否则读屏软件会把它当成无意义的黑色区域。组件化不只是功能可用,无障碍细节也需要纳入自检清单。
6. 写在最后:原生组件化适合谁,不适合谁
如果你正在考虑要不要在团队引入Web Components,我的建议是分三步走。
第一步,先盘点团队手里的组件资产。如果大部分组件都只在当前框架内使用,且没有跨项目复用需求,那暂时不需要专门封装成原生组件。如果底层通用件反复在多个项目间复制粘贴,或者你正在建设统一的设计系统,那原生组件非常值得作为载体标准引入。
第二步,小范围试点。选1-2个边界清晰、不含业务逻辑的基础组件,用原生方式封装,接入到至少两个不同技术栈的项目里跑一段时间。重点观察:开发效率能不能接受,样式隔离效果怎么样,团队的维护成本会不会明显上升。
第三步,根据试点结果决定是否铺开。如果试点的结论是“开发慢、限制多”,退回框架方案也不丢人;如果结论是“跨项目复用顺畅、维护成本可控”,就可以逐步扩大范围。
从我个人的实践体会来看,Web Components最大的价值不在于“技术多炫”,而在于它给组件资产提供了一个不绑定任何框架的存放形式。框架三五年可能迭代或更换一次,但浏览器标准会长期稳定存在,这让沉淀下来的通用组件经得起时间考验。只要把握好“通用层用原生、应用层用框架”的边界,它不激进,也不保守,是一条相当务实的组件化路径。