Web Components:用原生的方式构建现代 UI 组件体系
开篇背景:一次重构项目让我重新认识组件化开发

去年我参与了一个公司内部系统的前端重构项目。这个系统原本是使用 jQuery 搭建的,后来逐步引入了 Vue.js 来管理部分模块。但随着业务越来越复杂,Vue 和 jQuery 之间的耦合越来越高,代码维护成本直线上升。
项目初期,我们评估了多种技术方案:继续升级到 Vue 3?还是尝试迁移到 React?抑或采用微前端架构?
不过最后我们的目光聚焦在一个有点“复古”却又非常“现代”的技术点上 —— Web Components。
当时我对这项技术了解不多,但在做技术选型时,我被它那句「Write Once, Use Everywhere」深深吸引了。更重要的是,它不依赖任何框架、可以自由地和现有系统集成 —— 这恰恰是我们这种渐进式重构项目的福音。
于是,我们决定以 Web Components 为核心来构建全新的 UI 组件体系,并逐步替代旧系统中的自定义组件。这不仅让整个项目结构变得清晰,也让前后端协作更加顺畅。
这篇文章,我想结合这次亲身经历,聊聊我们是如何在实际项目中落地 Web Components 的,也分享一下过程中的经验教训。
问题描述:老系统痛点 + 现有方案局限


我们的项目背景其实比较典型:一个运行多年的企业管理系统,页面种类多、交互复杂、用户量稳定但不容宕机。
老系统的问题:
jQuery 手动操作 DOM 风格导致结构混乱
有很多地方为了方便直接写$().append(),后续没人敢轻易改动这些逻辑。Vue 模块和原生代码混杂
不少新功能用 Vue 写的,而旧页面仍然用 jQuery 控制状态,导致组件复用极其困难。组件难以统一,样式/行为存在差异
比如弹窗组件每个模块都自己写一套,UI 样式和接口调用方式各不相同。团队新人适应成本高
新加入的同学需要花大量时间理解不同页面的“风格”,甚至要同时掌握 jQuery 和 Vue 两套思维模型。

我们的期望目标:
- 构建可复用的跨框架 UI 组件(兼容 Vue、React 或纯 HTML 页面)
- 减少全局 CSS 冲突
- 提供统一的 API 接口
- 易于调试和测试
- 渐进式迁移,不影响现有业务运行
我们试过一些其他方案,比如用 Stencil 构建跨框架组件,或者通过 Shadow DOM + 类似 LitElement 的库封装组件。但最终我们选择了原生的 Web Components API 实现核心能力,搭配一些轻量级辅助库来提升开发体验。
解决方案:从 Shadow DOM 到 Custom Elements 全家桶
我们在项目里构建了一套基于 Web Components 的通用组件库,命名为 @company/ui。这套组件的核心是使用浏览器原生支持的三大技术:
- Custom Elements:用于定义新的 HTML 元素类型
- Shadow DOM:用于封装组件内的 DOM 和 CSS,避免污染全局环境
- HTML Templates:提前声明模板内容,提高渲染效率
另外,我们还借助了一些开源工具来提升开发体验:
举个例子,我们有一个核心组件 ui-button,它的作用就是提供一个统一的按钮风格,并对外暴露 type="primary"、loading、disabled 等属性:
<ui-button type="primary" loading>提交</ui-button>
下面是我为这个组件定义的结构:
核心结构:
// ui-button.js
import { LitElement, html, css } from 'lit';
export class UIButton extends LitElement {
static properties = {
type: { type: String },
disabled: { type: Boolean, reflect: true },
loading: { type: Boolean, reflect: true }
};
static styles = css`
button {
padding: 8px 16px;
font-size: 14px;
border-radius: 4px;
background-color: var(--button-bg, #007BFF);
color: white;
border: none;
cursor: pointer;
}
:host([loading]) button {
opacity: 0.6;
pointer-events: none;
}
`;
render() {
return html`
<button ?disabled="${this.disabled || this.loading}">
${this.loading ? html`<span class="loader"></span>` : ''}
<slot></slot>
</button>
`;
}
}
customElements.define('ui-button', UIButton);
这个组件一旦定义完成,就可以在任意页面中使用,无论是 Vue 项目、React 应用,甚至是纯 HTML 页面。
关键实现点与开发技巧
在整个实践中,我们踩了不少坑,也摸索出了一些有效的开发模式。
✅ 局部封装 vs 全局样式
我们一开始就遇到了一个问题:如何平衡组件内部样式隔离和整体主题控制。
Shadow DOM 确实帮助我们隔绝了全局样式污染,但也带来了灵活性下降的问题。例如,如果我们要根据不同品牌定制主题色,怎么办?
解决方法:使用 CSS Variables
:host {
--button-bg: red;
}
然后在父级页面中控制主题变量:
<style>
ui-button.brand-a {
--button-bg: blue;
}
</style>
<ui-button class="brand-a">蓝色按钮</ui-button>
这样既保证了组件本身的封装性,又保留了灵活配置的可能性。
✅ 自定义事件和数据流设计
Web Components 的通信方式不像 Vue 或 React 那样直观。我们早期在父子组件传值时遇到了麻烦。
举个例子:我们的 ui-modal 组件点击关闭按钮时,应该触发一个 close 事件:
this.dispatchEvent(new Event('close', { bubbles: true }));
而在外部 Vue 项目中监听这个事件:
<ui-modal @close="handleClose"></ui-modal>
Vue 对此类事件默认是可以处理的,但如果你在某个 <div is="ui-modal"> 中使用 is 特性,事件监听就可能会失效。
解决办法:始终确保使用正确的自定义元素标签名,并在事件传递时设置 { bubbles: true, composed: true },以保证事件能穿透 Shadow DOM 并正确冒泡。
✅ 性能优化小贴士
虽然 Web Components 是原生 API,但如果滥用,也会带来性能问题。以下是我们总结的一些优化建议:
- 延迟加载 Shadow DOM 内容:对于复杂的组件,可以在 connectedCallback 中才真正创建 Shadow DOM。
- 避免频繁重绘:尤其是带有动画的组件(如 Loading 动画),尽可能利用 GPU 加速。
- 合理使用 slots:过多嵌套
<slot>会增加浏览器渲染负担。 - 组件懒加载:将组件注册推迟到首次被使用时。
// 懒加载组件示例
if (!customElements.get('ui-card')) {
customElements.define('ui-card', UICard);
}
✅ DevTools 调试技巧
Chrome DevTools 对 Web Components 支持非常好:
- 右键查看 Shadow DOM 内容
- 元素面板可以看到组件的类和构造函数信息
- 在 Sources 面板下打断点也非常方便
我们也推荐大家在开发时使用 Open WC 的开发者服务,它可以自动热更新、ESM 支持,非常适合本地开发。
实战效果:组件复用率大幅提升,开发效率显著改善
项目上线后,我们做了几个维度的数据统计:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 公共组件数量 | 50+ | 18 |
| 页面平均组件复用率 | ~30% | ~85% |
| 新人学习时间(小时) | 40+ | 15~20 |
| 组件样式冲突数 | 月均 12 例 | 月均 1~2 例 |
最明显的一个变化是:从前每次新增一个表单控件都需要去各个页面拷贝代码、调整样式;现在只需要 <ui-input /> 就搞定了。
而且因为组件是原生的,我们可以很方便地在各种上下文中复用。比如后端同学写的 Django 模板页也能轻松集成我们的组件,前端同学也不再需要为每种场景单独写适配层。
经验与反思:适合你的项目吗?
Web Components 并不是银弹。在实际使用中,我也总结了一些是否适用它的判断标准:
✅ 适合使用 Web Components 的场景:
- 企业级系统长期维护项目
- 多个团队共建组件体系
- 需要在多个框架之间共享组件
- 强调封装性和可维护性的项目
❌ 不太适合的场景:
- 项目完全由单一团队开发且只使用一个框架
- 需要极致性能优化(比如大型 Canvas / WebGL 游戏)
- 开发周期非常紧张的小项目
如果你的团队已经有成熟的 Vue/React 项目,也没有强烈的跨框架需求,那么 Web Components 可能反而增加了复杂度。
但如果你正在做一个需要组件长期维护、多人协作的大型应用重构,那我真的强烈建议你试试看这条路。
结语:拥抱浏览器原生的力量
这一年来,看着 Web Components 逐渐从小众走向主流,我内心其实蛮感慨的。
以前我们总在抱怨 “浏览器不给力”、“又要学新框架”。但现在回头看,很多框架的底层设计理念,其实都在朝着更接近浏览器原生的方向靠拢。
比如 React Fiber 的异步渲染、Vue 3 的 Composition API、以及越来越多对 Web Components 的官方支持(Vue 已经推出了 defineCustomElement API)。
Web Components 让我们重新思考了一个问题:前端的本质是什么?
在我看来,就是构建一个个可以在浏览器中“独立运行”的界面单元,无论背后用什么框架,只要浏览器能执行、用户能看到。
所以,在接下来的技术选型中,我不会再轻易说“这个必须用 Vue 写”。取而代之的是:“这个可以用 <ui-form-item /> 吗?”
这或许才是组件化的终极理想。
❤️ 小彩蛋:一个深夜 Debug 小插曲
记得有一次上线前夕,我在本地怎么也看不到某个 Shadow DOM 中的字体图标,反复检查 CSS 却没发现问题。
最后发现是因为组件内引用了外部字体文件,路径是从 ui-icon 当前文件的位置出发计算的。
但浏览器在渲染 Shadow DOM 时并不知道相对路径该从哪开始解析!
解决方案很简单 —— 使用绝对路径:
// 错误:
const iconUrl = './icons/arrow-up.svg';
// 正确:
const iconUrl = new URL('./icons/arrow-up.svg', import.meta.url).href;
这个问题花了我两个钟头才定位,但它也让我意识到:Web Components 是真正的“浏览器原生组件”,调试时你得像浏览器一样思考。
希望你们在使用过程中少走弯路 😊
如果你也在做类似的技术探索,欢迎留言交流!

评论 0