Web Components:我在项目中亲历的原生组件化尝试
开篇背景:从一个重构需求说起

大概一年前,我所在的团队接手了一个内部工具系统的重构任务。这套系统已经运行了三年多,最初的版本是使用 jQuery + Handlebars 搭建的,后来逐步加入了 Vue.js 来替换部分老旧代码。但问题也出在这:前端技术栈混用、页面结构不统一、重复代码高、维护成本越来越高。
我们希望能统一整个项目的 UI 组件体系,并在多个项目中复用这些组件。当时摆在面前的有几个选择:
- 继续用 Vue 或 React 构建可复用组件
- 引入第三方组件库(如 ElementUI、Ant Design)
- 试一试 Web Components —— 这个一直听说但没实际用过的原生方案
出于对未来技术演进和跨框架能力的考量,我们决定挑战一下——用 Web Components 打造一套跨框架的组件库,先做几个关键组件验证可行性。接下来的故事,就是我在其中的一些经历与思考。
问题描述:老系统带来的三大痛点

1. 技术栈分散,协作混乱
由于历史原因,不同模块由不同的人开发,有的用 Vue,有的还保留着原始的 JavaScript 写法,甚至有些地方用了 AngularJS!这就导致每次新功能开发都要先判断“在哪块代码里写”,沟通成本很高。
2. UI 样式不一致,视觉体验差
虽然有统一的设计规范文档,但由于缺乏实际的组件封装和复用机制,开发者基本靠手动复制 HTML 和样式。长此以往,相同类型的控件在不同页面上的表现千差万别,用户反馈也很频繁。
3. 维护复杂,升级困难
一旦某个基础交互方式或布局方式需要变更,就不得不在各个页面上逐一修改,容易遗漏、风险大、测试难覆盖。比如改一个按钮的 class 名称,往往要找遍几十个文件。
解决方案:为什么是 Web Components?

Web Components 是一组 Web 平台标准特性,包括 Custom Elements、Shadow DOM、HTML Templates 以及 ES Modules 等,它允许你创建自定义元素并封装其行为和样式。这恰恰是我们想要的:与框架无关的、真正可复用的组件单元。
我们想打造的是一种“任何项目、任何人只要引入就能直接用”的组件模式。最终目标是让其他部门也能无缝接入我们的组件库,不管是用 Vue、React、Angular 还是纯 JS 的项目。
实战项目:从 Button 到 FormKit

我们决定从小处入手,先实现几个核心组件,看看是否能在真实项目中落地。
第一个组件:<custom-button>
目标非常明确:替代项目中所有的 <button> 元素,统一外观、支持 loading 状态、点击反馈动效等。
class CustomButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.style.backgroundColor = '#4CAF50';
button.style.color = 'white';
button.style.border = 'none';
button.style.padding = '10px 20px';
button.style.fontSize = '16px';
button.textContent = this.getAttribute('label') || 'Submit';
const loading = document.createElement('span');
loading.style.display = 'none';
loading.innerHTML = 'Loading...';
button.addEventListener('click', () => {
if (this.loading) return;
this.dispatchEvent(new CustomEvent('clicked'));
});
// 提供外部设置 loading 的接口
Object.defineProperty(this, 'loading', {
get: () => this.hasAttribute('loading'),
set(val) {
if (val) {
this.setAttribute('loading', '');
loading.style.display = 'inline-block';
button.disabled = true;
} else {
this.removeAttribute('loading');
loading.style.display = 'none';
button.disabled = false;
}
},
});
shadow.appendChild(button);
shadow.appendChild(loading);
}
}
customElements.define('custom-button', CustomButton);
这段代码实现了:
- 自定义标签名:
<custom-button> - 支持通过
label属性控制显示文字 - 封装样式到 Shadow DOM 中避免冲突
- 可控制 loading 状态和事件通信
接着我们在 Vue 页面中这样使用它:
<template>
<div>
<custom-button label="提交" @clicked="handleSubmit"></custom-button>
</div>
</template>
<script>
export default {
methods: {
handleSubmit() {
this.$el.querySelector('custom-button').loading = true;
setTimeout(() => {
this.$el.querySelector('custom-button').loading = false;
}, 1500);
},
},
};
</script>
Vue 和 Web Component 同时存在居然很和谐!
持续扩展:构建更复杂的组件
尝到了甜头后,我们开始构建更复杂的组件,例如:
- 表单输入组件组合
<form-input>,内置验证逻辑 - 分页器
<pagination> - 数据表格
<data-table>,支持异步数据加载和排序
在这个过程中,我们也遇到了不少坑,以下是一些典型问题和解决方法。
踩坑经验分享:从入门到踩雷
🧱 Shadow DOM 风格隔离 vs 外部样式穿透
Shadow DOM 的最大优势就是样式隔离,但这也有副作用:有时候我们希望某些全局样式能作用到组件内(比如字体、主题色)。
解决方案:使用 CSS 自定义属性(CSS Variables)
:host {
--main-color: #4CAF50;
}
然后在组件内部:
button.style.backgroundColor = 'var(--main-color)';
这样即使组件嵌套在外部样式环境下,也可以灵活控制主题色,而不会被污染。
🔄 与主流框架的绑定问题
尽管 Web Components 是框架中立的,但像 Vue 和 React 这类框架对动态属性和事件处理有自己的一套机制。
比如 React 中,默认是不会自动识别 Web Components 的自定义属性。你需要告诉 React 不要用驼峰命名转换,而是原样传递属性:
<custom-button label="提交" onClicked={handleClick}></custom-button>
在 React 组件中需要用如下方式注册属性白名单:
import { attributesToProps } from 'html-react-parser';
// 或者你可以显式声明允许的 props
const allowedProps = ['label', 'on-clicked'];
function registerCustomElement(tagName, Component) {
customElements.define(
tagName,
class extends HTMLElement {
connectedCallback() {
const props = {};
for (let attr of this.attributes) {
if (allowedProps.includes(attr.name)) {
props[attr.name] = attr.value;
}
}
const reactElement = React.createElement(Component, props);
ReactDOM.render(reactElement, this);
}
}
);
}

这种适配过程确实有点繁琐,但在 Vue 中会友好很多,因为 Vue 对属性的映射比较宽松。
🔍 开发调试小技巧
刚开始写 Web Components 的时候,我发现浏览器的 DevTools 显示的只有 <custom-button> 这么个黑盒子,里面啥都看不见,非常难受。
解法一:使用 console.dir() 输出 Shadow Root
const btn = document.querySelector('custom-button');
console.dir(btn.shadowRoot);
解法二:DevTools 的 “Computed” 面板 + “Show all computed styles”
这个面板可以帮你看到 Shadow DOM 中的元素和样式应用情况。
💥 性能优化与懒加载
对于大型组件库来说,一次性加载所有组件肯定是不可取的。我们采用的是基于路由的按需加载策略:
// 动态 import 加载
async function lazyLoadComponent(name, path) {
await import(path);
console.log(`Component ${name} registered`);
}
lazyLoadComponent('custom-button', './components/button.js');
此外,我们还使用了 vite-plugin-web-components-rollup 这样的插件,结合 Rollup 对组件进行打包压缩和代码拆分。
效果总结:我们得到了什么?
经过三个月的摸索和迭代,我们成功将 Web Components 应用于生产环境,以下是具体的收益点:
✅ 组件复用率提升 70%
过去每个页面都要 copy-paste 的按钮、表单、分页组件现在都变成了 <custom-button>、<form-field> 这种标准化组件。无论哪个工程师开发新功能,都能快速调用现成组件。
✅ 样式一致性大大提高
得益于 Shadow DOM 的封装,所有组件的 UI 样式不再受全局样式干扰。设计文档中的变量和规范也更容易落地。
✅ 渐进迁移友好
最让人惊喜的是,在已有 Vue/React/Angular 项目中引入 Web Components 几乎没有障碍,反而成为了我们统一各业务线 UI 风格的桥梁。
✅ 团队协作效率提升
大家不用再争论“该不该用某个框架组件”、“要不要重新封装”,只需要关注怎么高效地使用现有组件库,大大降低了沟通成本。
我的一些体会和建议
从这次实践中,我也积累了不少经验和感悟,如果你也在考虑是否使用 Web Components,这里有一些真心建议:
🧱 不要追求“一次封装处处通用”
Web Components 虽然框架中立,但它本质上是一种低级抽象。它适合那些需要高度稳定、不易变化的 UI 基础组件。对于复杂的交互逻辑或业务组件,还是推荐用 Vue/React 这样的框架来实现。
🛡️ 做好渐进演进的准备
迁移到 Web Components 是一个渐进的过程,不要指望一夜之间全部组件化。我们可以像搭积木一样,一边开发一边打磨,逐步丰富自己的组件库。
🧪 测试不能少,自动化才是王道
建议为 Web Components 编写单元测试和集成测试。我们可以用 Jest + Puppeteer 或 Playwright 来模拟真实渲染场景,确保组件在不同上下文下表现一致。
📦 发布方式也要注意
如果你打算将组件库对外发布,可以通过 npm 包的形式提供。配合 Rollup/Webpack 打包,让用户可以通过 ES Module 方式导入:
npm install my-components
import 'my-components/dist/custom-button';
结语:未来的组件化方向值得期待
其实写这篇文章的时候,我还回想起当初刚听到 Web Components 的时候那种疑惑:“真的有人用吗?”、“性能会不会很差?”、“兼容性怎么办?”
但实践之后发现,只要你了解它的特点、合理使用,它不仅能解决我们实际遇到的问题,还能带来前所未有的灵活性和可维护性。
在我看来,Web Components 正在悄悄成为前端组件化的“新大陆”。它不代表放弃现代框架,而是让我们在更高维度上建立统一的基础能力。
如果你正在寻找一种能让团队协作更顺畅、项目结构更清晰、未来更可维护的技术路径,不妨试试 Web Components,也许会有意想不到的收获。
最后,如果你喜欢这样的实战分享,欢迎留言交流,说不定下次我会讲讲我们是如何用 LitElement 快速构建组件的 😄

评论 0