Web Components:原生组件化开发新趋势
去年双11大促前夜,我正对着屏幕疯狂调试一个“神奇”的弹窗组件——它在我本地跑得飞起,在测试环境却直接白屏。运维甩锅说 CDN 有问题,测试小姐姐截图发来一堆红叉,而我这个刚从产品经理转岗没多久的“伪前端”,一边啃着泡面一边在 Chrome DevTools 里翻滚,突然意识到一个问题:
我们是不是把组件这事儿搞得太复杂了?
React、Vue、Svelte……每个框架都有一套自己的组件体系,甚至同一个团队里前后端分离项目用 Vue,内部工具又用 React,连个按钮都要写两遍。更别说和后端同学联调时,他们用 SpringBoot 写页面(是的,有些老系统还在 Thymeleaf 时代),根本没法复用我们的 UI 组件。
就在那一刻,我点开 MDN,看到了那个被很多人“遗忘”的技术:Web Components。
从 PM 到码农的“叛逃”之路
先简单自我介绍下:我原本是个天天画原型、怼需求的产品经理,结果某天被自家 CTO 一句“你不是总说技术不懂产品吗?那你来写写看?”给“骗”进了开发组。现在远程在家撸代码,白天写 JS,晚上研究 DOM 渲染原理,周末还得帮老婆修打印机(程序员的宿命)。
但正因为这段 PM 经历,我对“可复用性”和“协作成本”特别敏感。以前开需求评审会,前端同学常吐槽:“这个弹窗和上个项目一模一样,能不能别让我再写一遍?” 而现在,轮到我写代码了,我才真正体会到那种重复造轮子的痛苦。
所以当我发现 Web Components 居然能用原生浏览器能力实现跨框架复用时,简直像发现了新大陆——虽然这“大陆”早在 2011 年就被 Google 提出来了,只是我们一直忙着卷框架,把它当古董供起来了。
为什么是现在?一场被迫的技术突围
契机其实很现实:公司接了个政府项目,要求前端必须支持 IE11(别笑,真的有)。但我们的主力技术栈是 Vue3 + Vite,IE11 直接原地爆炸。领导拍板:“要么降级到 Vue2 + Babel Polyfill,要么找兼容方案。”
降级?那等于放弃 Composition API 和响应式系统的优雅。Polyfill?打包体积直接翻倍,首屏加载慢到用户以为网站挂了。
于是我开始调研“不依赖框架”的组件方案。Web Components 进入视野——它基于 Custom Elements、Shadow DOM、HTML Templates 等原生 API,只要浏览器支持(现代浏览器基本全支持,IE11 需要 polyfill,但至少可控),就能运行。
更重要的是,它和任何框架无关。Vue 能用,React 能用,Angular 能用,甚至 SpringBoot 的 JSP 页面里也能直接塞一个 <my-button>。
上手即踩坑:理想很丰满,现实很骨感
我兴致勃勃地写了第一个 Web Component:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button { background: #4CAF50; color: white; border: none; padding: 8px 16px; }
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('my-button', MyButton);
本地测试完美!绿色按钮,文字居中,还能传 slot 内容。我美滋滋地提交 PR,结果第二天就被测试打回来了:
“在 React 项目里点击没反应,控制台报错:
Cannot read property 'addEventListener' of null”
啊?我写的组件根本没加事件监听啊!
后来才发现,React 在处理自定义元素时,不会自动将 props 转为 attributes,也不会绑定事件到原生 DOM 上。你得手动处理:
// React 中使用 Web Component 的正确姿势
<my-button onClick={(e) => console.log('clicked!')}>
点我
</my-button>
不行!React 会忽略 onClick,因为它不是标准 HTML attribute。
正确的做法是:
const MyButtonWrapper = () => {
const ref = useRef(null);
useEffect(() => {
ref.current?.addEventListener('click', handleClick);
return () => ref.current?.removeEventListener('click', handleClick);
}, []);
return <my-button ref={ref}>点我</my-button>;
};
或者,让 Web Component 自己派发自定义事件:
// 在 MyButton 内部
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('myclick', { bubbles: true }));
});
然后在 React 里监听 onMyclick。
那一刻我真想砸键盘——跨框架集成比想象中麻烦得多。但转念一想,这不正是 Web Components 的价值所在吗?它强迫你思考“如何设计一个真正通用的接口”,而不是依赖某个框架的魔法。
架构设计思考:组件边界与通信模型
作为一个前 PM,我现在特别在意“契约”——组件暴露什么属性、触发什么事件、接收什么方法调用。Web Components 的设计哲学其实很“产品经理”:
- 属性(attributes):用于传递简单、可序列化的数据(字符串、数字)
- 属性(properties):用于传递复杂对象(需通过 JavaScript 设置)
- 事件(events):用于向外通知状态变化
- 方法(methods):提供主动控制能力(如
open(),close())
这种清晰的输入/输出模型,比框架内部的 $emit、props、context 更“标准化”。
举个实际例子:我们有个通知中心组件,需要支持动态添加消息、清除、标记已读。用 Web Components 可以这样设计:
class NotificationCenter extends HTMLElement {
connectedCallback() {
this.render();
// 暴露方法
this.addMessage = (msg) => { /* ... */ };
this.clear = () => { /* ... */ };
}
// 通过 attribute 控制初始状态
static get observedAttributes() {
return ['unread-count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'unread-count') {
this.updateUnreadCount(newValue);
}
}
}
前端框架只需这样调用:
<notification-center unread-count="5"></notification-center>
<script>
const nc = document.querySelector('notification-center');
nc.addMessage({ title: '新订单', content: '...' });
</script>
SpringBoot 后端同学甚至可以直接在 Thymeleaf 模板里用! 他们再也不用等我们出 Vue 组件包了——这对跨团队协作简直是降维打击。
性能与兼容性:别被“原生”二字骗了
很多人一听“原生”就觉得性能无敌。醒醒!Web Components 也有坑:
Shadow DOM 的样式隔离是把双刃剑
虽然避免了样式污染,但也导致无法全局覆盖样式。比如你想统一改所有按钮的圆角,传统 CSS 一句button { border-radius: 4px }就行,但 Shadow DOM 里的按钮你根本 touch 不到。解决方案是使用 CSS Parts 或 CSS Custom Properties:/* 定义可定制的 CSS 变量 */ my-button { --button-bg: #4CAF50; }// 组件内部 <style> button { background: var(--button-bg, #ccc); } </style>IE11 支持需要 polyfill
@webcomponents/webcomponentsjs这个包能搞定大部分问题,但体积不小(~30KB gzipped)。如果项目必须支持 IE,建议按需引入,或者干脆放弃——毕竟微软自己都 EOL 了。生命周期管理比框架组件弱
没有mounted、beforeUpdate这种钩子,你得自己监听connectedCallback/disconnectedCallback。对于复杂逻辑,容易写出回调地狱。
不过好消息是,现代浏览器对 Web Components 的性能优化已经非常成熟。Chrome 的 DevTools 甚至专门有“Custom Elements”面板,可以查看注册状态、实例数量等。
工程化实践:如何融入现有项目?
光有理论不够,得落地。我在 GitHub 上建了个小仓库 wc-playground(名字随便起的),尝试把 Web Components 融入真实工作流:
- 用 Lit(Google 出的轻量库)简化开发,避免手写一堆 boilerplate
- 通过 Rollup 打包,输出 ES Module 和 UMD 两种格式
- 编写 Storybook 文档,方便非前端同事预览组件效果
- 在 CI 中加入 Playwright 测试,确保跨浏览器兼容性
关键配置示例:
// rollup.config.js
export default {
input: 'src/index.js',
output: [
{ file: 'dist/wc-components.esm.js', format: 'es' },
{ file: 'dist/wc-components.umd.js', format: 'umd', name: 'WCComponents' }
],
plugins: [litCss(), resolve(), commonjs()]
};
团队接入也很简单:
<!-- 任何项目,一行 script 引入 -->
<script type="module" src="/assets/wc-components.esm.js"></script>
<!-- 然后直接用 -->
<my-dialog open>
<p>你好,世界!</p>
</my-dialog>
上周五晚上加班部署上线后,后端老哥发来微信:“卧槽,这按钮居然在我们的管理后台直接跑起来了?!” 那一刻,我觉得从 PM 转码农,值了。
未来展望:微前端的新可能?
最近团队在讨论微前端架构。主流方案如 qiankun、Module Federation 都不错,但子应用之间共享 UI 组件依然头疼——版本不一致、样式冲突、事件通信复杂。
而 Web Components 天然具备沙箱隔离 + 标准接口的特性,或许能成为微前端中 UI 层的“通用语言”。
想象一下:主应用提供一套 <header-bar>, <notification-center>, <user-avatar>,所有子应用直接使用,无需关心实现细节。子应用之间切换时,这些组件状态还能保持(因为是同一个 DOM 实例)。
当然,这需要更完善的工具链支持,比如:
- 组件版本管理
- 动态加载策略
- 跨应用状态同步
但方向是对的——回归 Web 本质,用标准解决问题。
写在最后:代码人生,少点套路,多点真诚
从画原型到写 Custom Elements,我越来越觉得:好的技术,应该降低协作成本,而不是制造壁垒。
Web Components 不是银弹,它不适合构建整个 SPA 应用(缺乏状态管理、路由等),但在“可复用 UI 元素”这个细分领域,它可能是最接近“一次编写,到处运行”理想的方案。
而且,当你看到 SpringBoot 项目里直接渲染出你写的 <data-table>,那种跨技术栈的打通感,真的很爽。
所以,别再只盯着 React 19、Vue 5 的新特性了。有时候,回头看看那些“老”标准,反而能找到破局之道。
毕竟,代码人生,不是追逐风口,而是解决真实问题。
P.S. 我把踩过的坑整理成了开源项目,欢迎 Star & PR:github.com/yourname/wc-playground
P.P.S. 前端组长说我再在 PR 里写“这个组件产品经理都能用”,就要把我调去写 Java 了……救命!
| 方案 | 跨框架复用 | 样式隔离 | 学习成本 | IE11 支持 | 适合场景 |
|---|---|---|---|---|---|
| React/Vue 组件 | ❌ | ⚠️ (CSS Modules) | 中 | ✅ (配合 polyfill) | 单框架项目 |
| Web Components | ✅ | ✅ (Shadow DOM) | 低 | ⚠️ (需 polyfill) | 通用 UI 库、微前端共享 |
| iframe | ✅ | ✅ | 高 | ✅ | 完全隔离的独立模块 |
(表格数据基于个人项目经验,仅供参考)
好了,泡面凉了,继续改 bug 去了。

评论 0