用原生的力量重构组件——Web Components 实践之路
引言:为什么选在项目中试水 Web Components?

我所在的公司是一家偏向于 B2B 领域的互联网企业,前端技术栈以 React 为主。然而,在去年的一个多系统协同项目中,我们面临了一个很现实的问题:
我们需要为多个独立部署的产品(有些甚至使用了 Vue 和 Angular)提供统一 UI 组件库 + 主题配置能力,同时又不希望引入额外的依赖。
这听起来像是一个“跨框架共享 UI 库”的经典需求。当时我们的第一反应还是继续沿用 Storybook + Monorepo 的方式,封装 React 组件并通过构建工具打包成 JS 模块供其他团队接入。
但是当真正落地的时候,我们发现很多问题:
- 非 React 项目的集成成本太高;
- 各个项目使用的 React 版本不一致导致样式/行为异常;
- 样式污染严重(CSS in JS 或 CSS Modules 在不同架构下的兼容性差异明显);
- 升级和维护时牵一发而动全身。
面对这些问题,我和团队中的几个同学开始调研 Web Components 是否可以作为替代方案来解耦我们的组件体系。
简要介绍一下什么是 Web Components
简单来说,Web Components 是一组浏览器原生支持的标准 API,主要包括三个部分:
Custom Elements:允许我们定义自己的 HTML 标签;Shadow DOM:为组件创建一个隔离的 DOM 子树,样式与外界互不影响;HTML Templates:通过<template>和<slot>定义可复用的内容片段。
它最大的优势在于 不需要依赖任何框架,也不需要编译器或者打包工具,直接用浏览器原生机制运行,天生具备跨框架、低耦合、可重用、易维护等特性。
项目背景:一次组件复用的尝试

为了验证 Web Components 的可行性,我们决定先从一个小模块入手。
我们负责开发的是一个数据展示仪表盘功能,其中有一个核心组件叫 data-card,它的作用是渲染带标题、统计数值、趋势箭头和颜色状态的小卡片。
这个组件被广泛使用在多个产品线中,包括:
- 一个基于 React 的管理后台;
- 一个正在迁移到 Vue 的数据平台;
- 一个完全静态的营销页面(纯 HTML + Vanilla JS)。
也就是说,它是一个典型的需要跨技术栈、高度可复用的通用 UI 组件。而且我们还希望能够让它支持动态主题切换、国际化文案,以及自定义内容插槽等功能。
于是,我们决定用 Web Component 的方式把这个组件重新实现一遍,并在内部推动大家进行小范围试点。
挑战:真实场景下的坑比想象中多得多

理想很丰满,但实际做起来之后才发现并不轻松。
✅ Shadow DOM 带来的样式隔离确实好用,但也带来了调试困难
原本我们认为 Shadow DOM 可以自动解决样式污染问题,但实际情况是:
- Chrome DevTools 中默认隐藏 shadowRoot 内容,查看调试比较麻烦;
- 不能直接通过 document.querySelectorAll 来获取 shadow root 内部元素,这对测试或自动化脚本不太友好;
- 某些 CSS 属性无法穿透 Shadow Root,例如
font-family必须显式声明,否则继承失败; - 动态计算的主题变量如何传入组件内部也是一个挑战。
为此,我们最后不得不在组件外部设置字体、颜色变量,并通过 CSS Custom Properties 透传进组件内。
✅ 跨框架通信和 DOM 交互不如框架里优雅
比如在 React 中使用 Web Components,虽然能够正常渲染,但在事件处理上遇到了一些障碍:
<DataCardComponent
value={totalOrders}
label="订单总数"
trend="up"
color="#50a3ba"
/>
这段代码看起来没问题,但我们发现 Web Component 并不会自动响应属性变化。我们需要手动监听 attribute 的变更:
static get observedAttributes() {
return ['value', 'label', 'trend', 'color'];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'value') {
this._renderValue(newVal);
}
}
相比之下,React 自己的状态绑定更丝滑。
此外,在某些复杂交互中,我们还需要让组件抛出事件给外层框架处理:
this.dispatchEvent(new CustomEvent('select', {
detail: { item: selectedItem },
bubbles: true,
composed: true
}));
但如果外部监听者没有注册这些自定义事件,很容易造成交互失败。
✅ 构建流程与 CI 系统集成不够顺畅
我们之前所有的 JS 包都是通过 webpack/babel 处理的。但 Web Component 的构建相对“轻量”,我们一开始想当然地以为可以直接通过 ES Module 原生加载。
结果发现:
- 浏览器兼容性是个大问题(特别是对旧版本 Safari 支持不好);
- 没有 Tree Shaking,体积优化受限;
- 与现有的打包流程割裂,无法统一发布到 NPM。
最终我们选择借助 Rollup 打包 Web Component,将组件输出为 UMD 模块,并增加 Polyfill 兼容性支持。
实现思路与关键技术细节
✅ 结构设计和组件生命周期
我们采用 Class + CustomElementRegistry 的方式定义组件类:
class DataCard extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// 使用 template 插入结构
const template = document.getElementById('data-card-template');
const content = template.content.cloneNode(true);
// 初始属性绑定
this._label = this.getAttribute('label') || '默认标签';
this._value = this.getAttribute('value') || 0;
// 插入模板内容
shadow.appendChild(content);
// 获取关键节点引用
this.$labelEl = this.shadowRoot.querySelector('.card-label');
this.$valueEl = this.shadowRoot.querySelector('.card-value');
}
connectedCallback() {
// 当组件插入文档时执行初始化逻辑
this.render();
}
attributeChangedCallback(attrName, oldVal, newVal) {
if (!oldVal || oldVal === newVal) return;
switch (attrName) {
case 'label':
this._label = newVal;
break;
case 'value':
this._value = Number(newVal);
break;
}
this.render();
}
static get observedAttributes() {
return ['label', 'value'];
}
render() {
if (this.$labelEl) this.$labelEl.textContent = this._label;
if (this.$valueEl) this.$valueEl.textContent = this._value;
}
}
customElements.define('data-card', DataCard);
这种方式让整个组件结构非常清晰,也可以很好地结合 Shadow DOM 进行样式的封闭性处理。
✅ 用 Shadow DOM 控制样式隔离
我们在 shadowRoot 中添加了如下样式:
<template id="data-card-template">
<style>
.card-container {
background-color: #f8f8f8;
border-radius: 8px;
padding: 16px;
font-family: inherit; /* 保持与主文档一致 */
}
.card-label {
color: #666;
}
.card-value {
font-size: 24px;
font-weight: bold;
margin-top: 4px;
}
</style>
<div class="card-container">
<div class="card-label">Label</div>
<div class="card-value">0</div>
</div>
</template>

通过这种方式,即使父应用中有同名类名也不会覆盖组件内部样式。
如果你希望让用户自定义某些主题色,可以暴露一些 CSS Variables:
.card-container {
--card-bg: #f8f8f8;
background-color: var(--card-bg);
}
这样就可以在外部控制组件风格:
<style>
data-card {
--card-bg: #e8f4ff;
}
</style>
<data-card label="销售额" value="12345"></data-card>
✅ 插槽机制提升扩展性
为了让用户可以在组件中间插入任意内容,我们加入了插槽支持:
<template id="data-card-template">
<div class="card-container">
<div class="card-label"><slot name="label">默认标签</slot></div>
<div class="card-content"><slot>默认内容</slot></div>
</div>
</template>
使用时:
<data-card>
<span slot="label">总收益</span>
<div style="font-size: 20px;">¥12345.67</div>
</data-card>
这种机制非常适合组件中某些灵活区域的设计。
一些踩过的坑和解决方法
⚠️ 浏览器兼容性问题
Web Components 不是所有浏览器都支持良好。尤其是对于 IE11 或旧版 Safari,我们做了如下处理:
- 使用 polyfills
- 检测浏览器是否支持 Web Components 并降级回退到普通组件:
if (window.customElements) {
customElements.define('data-card', DataCard);
} else {
console.warn('当前浏览器不支持 Web Components,已使用备用组件');
}
⚠️ 性能问题:频繁重绘优化
早期我们没有优化 attributeChangedCallback 中的 render 方法,导致组件频繁更新时性能下降明显。
解决方案是我们加了个简单的 diff 机制:
_updateProp(name, newVal) {
if (this[`_${name}`] !== newVal) {
this[`_${name}`] = newVal;
requestAnimationFrame(() => {
this.render();
});
}
}
借助 requestAnimationFrame 缓解高频更新带来的渲染压力。
⚠️ 国际化支持难题
为了让 Web Component 支持国际化文案,最初我们打算内置翻译对象:
const translations = {
'zh-CN': {
defaultLabel: '默认标签'
},
'en-US': {
defaultLabel: 'Default Label'
}
};
但这显然不够灵活。后来我们将语言判断交给外部传入一个 translation 对象:
<data-card :i18n="{
defaultLabel: '订单总量'
}" />
然后通过 setAttribute 设置 JSON 字符串传递,组件内部解析后做动态替换。
最终成果和收获
经过大约两个月时间的重构和推广,我们成功地在两个新项目上线了 Web Components 版本的核心 UI 组件库,并逐步迁移老项目。
最终效果如下:
- 组件跨框架兼容性显著提升:Vue/React/Static HTML 页面都能轻松接入;
- 样式污染基本杜绝:得益于 Shadow DOM 的隔离能力;
- 发布流程简化:不再依赖特定框架的构建环境;
- 维护成本降低:修改一处组件即可全平台同步生效;
- 可拓展性强:支持插槽、国际化、主题定制等高级能力。
更重要的是,我们终于有了一个 “干净、无侵入、可演进” 的组件系统。这对我们这类业务复杂、技术多样化的团队来说,是一个重大突破。
分享几点经验给准备入坑的你
1. 从小处着手,不要贪大求全
刚开始别想着一口吃成胖子,先从一个小型组件入手,把 Web Components 的基础用法跑通,再逐步扩大覆盖面。
建议挑选以下几个类型之一作为练手:
- 表单项(Input / Select)
- 展示型组件(Badge / Card)
- 导航栏 / Tab 标签页
这些组件通常不依赖太多框架特性,适合 Web Components 的发挥。
2. 考虑构建流程与团队协作方式
Web Component 没有框架帮你打包优化。所以在推进过程中一定要考虑:
- 如何用 Rollup/Webpack 构建组件;
- 是否要输出 ESM/UMD/umd/umd.min.js 等多种格式;
- 是否要发布到 NPM;
- 如何写文档(推荐使用 Playground + 示例演示);
这些都是影响最终落地的关键因素。
3. 建立统一命名规范和设计体系
如果多个团队共用 Web Component 库,必须提前约定命名规则,比如:
<company-header-bar title="主页"></company-header-bar>
组件名应具备语义性,避免冲突。
同时建议搭配一个统一的设计系统文档站点,说明每个组件的属性、事件、插槽等信息。
4. 注意性能监控与用户体验
尽管 Web Components 的渲染效率不错,但在大规模组件嵌套时也需要注意性能瓶颈。
建议:
- 开启 Lighthouse 性能检测;
- 使用
PerformanceObserver记录组件加载耗时; - 对懒加载策略做优化(如 defer 注册组件);
- 尽量减少不必要的 DOM 重排重绘。
总结:Web Components 是未来的标准吗?
从我个人实践来看,Web Components 并不是银弹,但它提供了一种更加稳定、可控、可持续的组件化方案。
尤其适用于以下几种场景:
- 跨技术栈的组件共享;
- 渴望摆脱框架依赖的微前端架构;
- 提供第三方 SDK 或小工具组件;
- 长期维护的大型项目架构标准化。
如果你的项目也有类似的需求,不妨试试看 Web Components,说不定会打开另一扇新的大门。
如果你也在探索一种真正“通用”的组件方案,欢迎留言交流经验或者遇到的坑。咱们一起把这件事做得更好 😎
文章作者:一名在一线搬砖的前端开发者 🛠️

评论 0