从 Vue 到 Web Components:我在项目重构中的组件化演进之路
开篇背景:为什么选择写 Web Components?

两年前,我加入了一个正在经历技术转型的中型电商平台团队。我们的目标很明确——打造一套高度可复用、支持多框架接入的 UI 组件库,并且能够嵌入到多个业务线中,实现“一次开发,到处可用”。最初我们考虑过使用主流框架封装方案,但都存在较强的耦合性问题。
就在那个节点,我对 Web Components 产生了浓厚兴趣,因为它不依赖任何框架,直接运行于浏览器标准之上。于是我们开始尝试用原生的方式重构组件库的核心模块。今天我想分享的就是这段过程中的真实体验和踩坑心得。
问题描述:框架锁定带来的痛点

在接手组件库维护之前,我们团队使用的是一套基于 Vue 封装的组件集。虽然功能完善、文档齐全,但在实际使用时却出现了很多协作障碍:
- 不同业务线的技术栈不同:有的用 React,有的用 Angular,甚至还有使用 jQuery 的老项目。
- 共享组件版本管理困难:每次更新一个基础组件都需要各个团队同步升级,否则界面行为可能不一致。
- 样式污染严重:Vue 的 scoped CSS 在某些动态加载场景下并不能完全隔离。
- 性能瓶颈明显:某些复杂列表组件在低端设备上表现卡顿。
这些痛点促使我们重新思考构建方式,而 Web Components 成为了我们的突破口。
解决方案:用原生组件打破技术壁垒
Web Components 是一套 W3C 标准,它允许我们创建可重用的自定义元素,其核心包括:
- Custom Elements:自定义 HTML 元素(如
<my-button>)。 - Shadow DOM:为组件提供独立的 DOM 和 CSS 作用域。
- HTML Templates:通过
<template>和<slot>定义结构模板。 - ES Modules:标准化模块导入导出机制。
我们的目标:
- 构建与框架无关的组件
- 确保样式隔离和行为一致性
- 支持按需加载和懒加载
- 兼容现代浏览器和部分旧版本(如 IE11)
第一版尝试:从 lit-element 入手
早期我们选择了 Google 的 lit-element,它基于 LitHTML,提供了类组件 API,适合习惯类写法的人快速上手。
举个简单的例子:
import { LitElement, html, css } from 'lit';
export class MyButton extends LitElement {
static get styles() {
return css`
button {
background-color: #0366d6;
color: white;
border-radius: 4px;
padding: 8px 16px;
}
`;
}
render() {
return html`<button><slot></slot></button>`;
}
}
customElements.define('my-button', MyButton);
这样我们就有了一个可以全局使用的按钮组件,在任意 HTML 页面中都可以直接使用:
<my-button>提交</my-button>
它的 Shadow DOM 中会渲染出带样式的按钮,而且不会影响外部页面的样式。
这个方案看起来可行,但很快我们遇到了一些挑战。
踩坑经验:那些你必须知道的坑点
坑1:浏览器兼容性没那么友好
虽然 lit-element 号称支持 IE11,但我们实测下来发现:
- 不支持 ES Module 动态导入
- 需要引入大量的 polyfill
- 构建出来的包体积大(尤其对于简单组件来说)
解决方案:
我们改用了更轻量的库如 Stencil.js 或者纯原生方式编写,同时结合 Babel + Webpack 打包降级处理。最终我们选择了自己搭建基础组件模板,不再依赖第三方框架。
坑2:事件传递与跨组件通信难
由于每个组件都在自己的 Shadow DOM 中,传统的事件监听方式无法穿透边界。
解决方案:
this.dispatchEvent(new CustomEvent('some-event', {
detail: { data: 'something' },
bubbles: true,
composed: true
}));
记得加上 composed: true,才能让事件“穿透” Shadow DOM 上升到主文档。
坑3:样式隔离 vs 外部定制需求冲突
有时候客户希望可以自定义组件外观,但 Shadow DOM 的样式是封闭的。
解决方案:
- 使用 CSS 自定义属性来暴露变量接口:
:host {
--primary-color: #0366d6;
}
button {
background-color: var(--primary-color);
}
然后用户可以在外部修改主题颜色:
<style>
my-button {
--primary-color: red;
}
</style>
- 或者使用开放模式(
mode: 'open')暴露 Shadow DOM,供 JS 直接操作(但风险较大)。
代码实践:一个完整组件是如何工作的?
以下是一个基于原生 JS 实现的 Tab 组件示例,包含基本交互逻辑和样式封装。
class MyTabs extends HTMLElement {
constructor() {
super();
this._tabs = [];
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
const template = document.createElement('template');
template.innerHTML = `
<style>
.tab-nav {
display: flex;
list-style: none;
padding: 0;
}
.tab-nav li {
margin-right: 10px;
cursor: pointer;
}
.tab-content {
margin-top: 1em;
}
</style>
<ul class="tab-nav"></ul>
<div class="tab-content"><slot></slot></div>
`;
this.shadowRoot.appendChild(template.content.cloneNode(true));

this.$nav = this.shadowRoot.querySelector('.tab-nav');
this.$content = this.shadowRoot.querySelector('.tab-content');
this.initTabs();
}
initTabs() {
this._tabs = Array.from(this.querySelectorAll('[role="tabpanel"]'));
this._tabs.forEach((panel, index) => {
const label = panel.getAttribute('aria-labelledby') || `Tab ${index + 1}`;
const li = document.createElement('li');
li.textContent = label;
li.addEventListener('click', () => this.selectTab(index));
this.$nav.appendChild(li);
});
if (this._tabs.length > 0) {
this.selectTab(0);
}
}
selectTab(index) {
this._tabs.forEach((panel, i) => {
panel.hidden = i !== index;
});
Array.from(this.$nav.children).forEach((li, i) => {
li.classList.toggle('active', i === index);
});
}
}
customElements.define('my-tabs', MyTabs);
使用方式如下:
<my-tabs>
<div role="tabpanel" aria-labelledby="Home">首页内容...</div>
<div role="tabpanel" aria-labelledby="Profile">个人中心内容...</div>
</my-tabs>
这个组件完全封装了自己的布局与交互,对外只需传入内容即可,非常适合集成到各种框架项目中。
效果总结:重构后的变化

经过半年的努力,我们将整个平台的基础组件逐步迁移到 Web Components 模式下,效果显著:
- 统一了 UI 展示:无论哪个团队接入,看到的组件外观和行为都一致。
- 提高了可维护性:组件本身不需要绑定特定框架生命周期,更容易维护。
- 增强了灵活性:支持多种打包方式(ESM、UMD),方便被不同环境引入。
- 减少了构建时间:去掉了框架依赖后,组件库整体包体积减少了约 40%。
- 兼容性更强:经过适配后可在 IE11 上稳定运行。
更重要的是,我们在内部推行了一种新的协作流程:前端组件以“微前端”的形式进行管理和发布,真正实现了“开箱即用”。
经验分享:几点建议给还在观望的同学
不是所有项目都要用 Web Components
- 如果你的项目本身就已经完全使用单一框架(比如全站 Vue),没必要强行迁移。
- 但如果你有组件复用、多团队协作、跨平台等需求,WC 是非常值得投入的方向。
不要低估兼容性和构建成本
- 对于需要支持老浏览器的项目,构建配置会比较繁琐,建议使用 Stencil、Open WC 这样的成熟工具链。
- 可以使用 Playwright 编写端到端测试确保组件行为的一致性。
拥抱渐进式迁移
- 不必一开始就全部重写。可以从最小化的组件(如 Button、Input)开始试点。
- 在已有项目中局部替换,逐步验证价值再推广。
关注无障碍和语义化设计
- Web Components 提供了更好的结构控制能力,但也容易写出不符合语义的标签。
- 使用正确的 ARIA 属性、保持合理的 DOM 结构,是组件用户体验的关键。
调试技巧:利用 Chrome DevTools 的优势
- 右键点击自定义元素 → “Reveal in Elements panel”
- 可以看到完整的 Shadow DOM 结构和样式继承关系
- 用
$0.__proto__查看组件原型链,便于调试内部状态
写在最后:未来的趋势在于“去框架化”
这几年前端生态发展得太快了,Angular、Vue、React 各领风骚,但它们本质上还是语言层面的抽象。Web Components 是唯一一个在浏览器层面上达成共识的标准。随着 Svelte、SolidJS 等无虚拟 DOM 方案的兴起,开发者对“原生能力”的关注度也在提升。
对于我们这些一线开发人员来说,与其不断学习新框架,不如回归标准和本质,用最接近浏览器的方式构建产品。这不仅有助于提高性能和可维护性,也能让我们更专注在业务逻辑而不是框架语法上。
希望这篇文章能为你打开 Web Components 的大门。也许下一个项目的组件架构,就从这里开始。

评论 0