Web Components:原生组件化开发的新答案
上周五晚上十点半,我正瘫在成都的出租屋里刷 LeetCode 准备跳槽面试——没错,最近被几道“手写 Web Components”的题狠狠教育了。说实话,以前我一直觉得这玩意儿是“前端考古学”范畴,直到我们团队接了个跨框架共享 UI 组件的需求,才真正意识到:Web Components 可能比我们想象中更实用。
我是谁?一个在成都某中型互联网公司搬砖的前端,日常用 VSCode + 一堆插件(比如 Live Server、Auto Rename Tag、ES7+ React/Redux Snippets),喜欢折腾新技术但上线代码永远选最稳的方案。去年双11期间因为乱用新框架导致线上白屏,被产品经理追着问“你是不是故意的”,从此对“炫技”有了 PTSD。
被逼上梁山:为什么我们开始用 Web Components?
事情起因是我们要做一个“设计系统统一平台”。公司内部同时维护 React、Vue 2、Vue 3 三套前端项目,每次改个按钮样式,设计师要提三次需求,开发要改三套代码,测试要测三个环境。领导拍板:“搞一套通用组件库,一次开发,到处使用!”
最初的想法是封装成 npm 包,按需引入。但很快发现:框架耦合太深。React 的 JSX、Vue 的 SFC、状态管理方式完全不同,强行抽象反而增加维护成本。这时候,同事小王(就是那个总穿拖鞋来公司的后端转前端)弱弱地说了一句:“要不……试试 Web Components?”
全场沉默三秒。
“那不是 IE 时代的老古董吗?”
“Shadow DOM 性能是不是很烂?”
“浏览器兼容性咋办?”
但现实是:我们已经被 deadline 逼到墙角。于是,抱着“死马当活马医”的心态,我们开始了 Web Components 的实战之旅。
别被名字骗了:Web Components 不是“过时技术”
很多人一听 Web Components 就想到 2014 年的草案,觉得它已经凉透了。其实不然。现代 Web Components(基于 Custom Elements v1 + Shadow DOM v1)早在 2017 年就成为 W3C 标准,并且被所有现代浏览器原生支持(连 Edge 都投降了)。
简单来说,Web Components 由三部分组成:
- Custom Elements:定义自己的 HTML 标签,比如
<my-button> - Shadow DOM:封装样式和 DOM,避免外部污染
- HTML Templates:声明式模板,配合 JS 动态渲染
最关键的是:它不依赖任何框架。你可以把它当成“浏览器原生的组件系统”。
// 定义一个简单的自定义元素
class MyButton extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// 添加样式(隔离的!)
const style = document.createElement('style');
style.textContent = `
button {
background: #007aff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
button:hover { opacity: 0.9; }
`;
// 创建按钮
const btn = document.createElement('button');
btn.textContent = this.getAttribute('label') || 'Click me';
shadow.appendChild(style);
shadow.appendChild(btn);
}
}
// 注册组件
customElements.define('my-button', MyButton);
然后在任意 HTML 中直接用:
<my-button label="提交订单"></my-button>
React、Vue、Svelte 甚至纯静态页面都能无缝使用。这就是我们想要的“跨框架”能力。
实战踩坑:那些文档不会告诉你的细节
理想很丰满,现实很骨感。我们在落地过程中踩了几个大坑,分享出来帮大家避雷。
坑 1:属性 vs 属性(Attribute vs Property)
Web Components 默认只监听 HTML attribute(字符串),但很多时候我们需要传对象、函数等复杂类型。
比如:
<user-card user="{ name: '张三', id: 123 }"></user-card>
这里 user 是字符串,不是对象!
解决办法:优先使用 JavaScript property 赋值,而不是 attribute。
const card = document.querySelector('user-card');
card.user = { name: '张三', id: 123 }; // 这样才是对象
或者在组件内部做解析(不推荐,性能差):
const userStr = this.getAttribute('user');
const user = JSON.parse(userStr); // 危险!可能抛错
坑 2:事件通信:怎么让父应用知道子组件发生了什么?
Web Components 不能直接用 Vue 的 $emit 或 React 的 onXXX。但我们可以通过 CustomEvent 实现。
// 在组件内部触发事件
this.dispatchEvent(new CustomEvent('submit', {
detail: { value: '用户输入的内容' },
bubbles: true,
composed: true // 关键!允许事件穿透 Shadow DOM
}));
外部监听:
document.querySelector('my-form').addEventListener('submit', (e) => {
console.log(e.detail.value);
});
💡 提醒:一定要加
composed: true,否则事件在 Shadow DOM 边界就被拦住了!
坑 3:样式定制:如何让外部能覆盖内部样式?
Shadow DOM 默认完全隔离样式,这既是优点也是痛点。比如设计师说:“这个按钮在暗色模式下要变白”,怎么办?
解决方案:使用 CSS custom properties(CSS 变量)暴露可配置项。
// 组件内部
const style = document.createElement('style');
style.textContent = `
:host {
--btn-bg: #007aff;
--btn-color: white;
}
button {
background: var(--btn-bg);
color: var(--btn-color);
}
`;
外部使用时:
my-button {
--btn-bg: #333;
--btn-color: #fff;
}
这样既保持了封装性,又给了外部定制空间。这是 Web Components 最优雅的样式扩展方式。
性能与兼容性:真的能上生产吗?
很多人担心 Shadow DOM 性能差。实测结果让人意外。
我们在一个包含 500 个 <data-table-row> 组件的页面做了对比:
| 方案 | 首屏渲染时间 | 内存占用 | 滚动流畅度 |
|---|---|---|---|
| 纯 DOM(无 Shadow) | 320ms | 45MB | 60fps |
| Shadow DOM | 340ms | 48MB | 58fps |
| React 虚拟列表 | 280ms | 52MB | 60fps |
差异几乎可以忽略。现代浏览器对 Shadow DOM 优化得很好,除非极端场景(比如每帧更新上千个组件),否则不用过度担心。
至于兼容性?看这张表:
| 浏览器 | 支持情况 |
|---|---|
| Chrome | ✅ 54+ (2016) |
| Firefox | ✅ 63+ (2018) |
| Safari | ✅ 10.1+ (2017) |
| Edge | ✅ 79+ (Chromium 版) |
| IE11 | ❌(但可用 polyfill) |
我们公司用户基本没有 IE,所以直接放弃兼容。如果你需要支持老浏览器,可以用 @webcomponents/webcomponentsjs 这个 polyfill,但它会带来 ~15KB 的额外体积和轻微性能损耗。
面试题挑战:Web Components 高频考点总结
既然我被面试题虐过,那就帮大家整理一下常见考点(附答案思路):
Q1:Web Components 和 React/Vue 组件有什么区别?
- 核心差异:Web Components 是浏览器原生标准,无运行时依赖;框架组件依赖特定生态。
- 适用场景:跨框架共享用 Web Components;复杂交互逻辑用框架。
Q2:Shadow DOM 的 mode: 'open' 和 'closed' 有什么区别?
'open':可通过element.shadowRoot访问内部 DOM(调试友好)'closed':外部无法访问,彻底封闭(安全性更高,但调试困难)- 实践中几乎都用 'open'
Q3:如何在 Web Components 中实现响应式更新?
- 监听 attribute 变化:通过
observedAttributes+attributeChangedCallback - 监听 property 变化:用 getter/setter 或 Proxy(需手动触发 render)
static get observedAttributes() {
return ['label'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'label') {
this.render(); // 重新渲染
}
}
Q4:Web Components 能用 TypeScript 吗?
当然能!而且体验不错。配合 @web/dev-server 或 Vite,开发体验接近现代框架。
工具链推荐:让开发不那么痛苦
虽然 Web Components 是原生 API,但纯手写还是太原始。这些工具能大幅提升体验:
- Lit:Google 出的轻量库(~5KB),提供响应式、模板语法,类似“Web Components 的 React”
- Stencil:Ionic 团队出品,支持 TSX、懒加载、SSR,适合构建组件库
- Vite + @vite/plugin-react:即使你用 React,也可以把 Web Components 当普通标签用
我个人最爱 Lit,因为它最小巧,且不改变 Web Components 的本质:
import { LitElement, html, css } from 'lit';
class MyButton extends LitElement {
static styles = css`
button { background: var(--bg, #007aff); }
`;
render() {
return html`<button @click=${this._onClick}>点我</button>`;
}
_onClick() {
this.dispatchEvent(new CustomEvent('click'));
}
}
最终效果:我们真的做到了“一次开发,到处使用”
经过两个月迭代,我们的设计系统组件库(基于 Lit + Web Components)成功接入了 React、Vue 2、Vue 3 三个项目。设计师改一次 Figma,我们发一次 npm 包,全平台自动更新。
更重要的是:Bundle Size 降了 20%。因为不用再为每个框架打包一份 UI 逻辑。
上周上线后,产品经理居然请我们喝了奶茶(虽然只是蜜雪冰城)。那一刻我觉得,折腾 Web Components 是值得的。
写在最后:它不是银弹,但值得你了解
Web Components 不会取代 React 或 Vue。它的定位很清晰:解决跨框架复用、微前端集成、低侵入式嵌入等特定场景。
如果你:
- 正在维护多技术栈项目
- 想构建可被任意前端使用的 UI 库
- 对框架 lock-in 感到焦虑
那 Web Components 值得你花一周时间深入研究。别被“原生”两个字吓到——现代 Web Components 开发体验已经非常成熟。
至于面试?现在大厂(尤其偏底层或基建的岗位)越来越爱考它。毕竟,理解浏览器原生能力,是高级前端的基本素养。
哦对了,我跳槽成功了。新东家第一周就让我用 Web Components 重构他们的插件系统。看来,这波技术债,还得继续还啊 😅
PS:如果你也在成都,欢迎约茶(最好是宽窄巷子那家竹叶青)。我们可以聊聊 Web Components,或者吐槽产品经理。反正,生活节奏舒服点,代码才能写得稳。

评论 0