35岁还在写代码的我,终于把 Web Components 玩明白了
凌晨一点半,上海的雨敲打着出租屋的窗户。咖啡早就凉了,但脑子里的 bug 还是热乎的。我又一次被产品经理拉进会议室:“这个组件能不能在所有项目里复用?包括那几个用 Vue、React、甚至 jQuery 写的老系统?”
说实话,当时真想回一句:“你当我是 GPT-4o 啊,啥都能一键生成?”但转念一想,这需求其实挺合理——我们团队维护着七八个前端项目,技术栈横跨三代,每次做个通用弹窗或表单控件,都得复制粘贴三套代码,改一个样式全军覆没。
于是,我翻出了尘封已久的 Web Components 文档。没错,就是那个被很多人说“早就过时了”的原生组件化方案。
别急着喷,先看看它能解决什么
去年双11前,我们搞了个“统一登录组件”,要在主站、H5活动页、还有外包团队维护的后台系统里同时上线。三个项目分别用了 React 18、Vue 2 和纯 HTML + jQuery。按老办法,得让三个前端各自实现一套,还得保证交互、样式、API 完全一致。测试同学光是比对 UI 就快疯了。
这时候,Web Components 的优势就冒出来了:它不依赖任何框架,浏览器原生支持(现代浏览器),自带封装性,还能通过属性和事件跟外部通信。换句话说,只要你的浏览器不是 IE6,就能跑。
我试着写了个 <unified-login> 组件,打包成一个 JS 文件,丢给三个项目直接 <script> 引入。结果?除了 Vue 2 里有个小坑(后面讲),其他地方居然一次跑通!那一刻,我仿佛看到了代码复用的乌托邦。
踩坑实录:理想很丰满,现实全是坑
当然,事情没那么简单。下面这几个坑,我差点栽进去出不来。
坑一:Shadow DOM 的样式隔离,太“彻底”了
Web Components 默认用 Shadow DOM 隔离样式,本意是防止外部污染。但问题来了:公司 Design Token 是用 CSS Variables 定义的主题色,比如 --primary-color: #1890ff;。结果在 Shadow DOM 里读不到!
查了半天文档,才发现 CSS Variables 是可以穿透 Shadow DOM 的——但必须在根元素(:root 或 html)上定义,不能只在某个 .app-container 里设。我们之前为了模块化,把变量塞进了业务容器里,导致组件内部拿不到。
解决方案:把设计系统的 CSS Variables 提升到 :root 层级。虽然有点破坏“模块封闭性”,但权衡之下,主题一致性更重要。
/* 正确姿势 */
:root {
--primary-color: #1890ff;
--border-radius: 4px;
}
/* 错误姿势(Shadow DOM 里读不到) */
.app-theme {
--primary-color: #1890ff;
}
坑二:Vue 2 对自定义元素的“敌意”
Vue 2 默认会把不认识的标签(比如 <my-button>)当成普通 HTML 元素处理,不会等 Web Component 加载完再渲染。结果就是:组件还没注册,Vue 就把它当空 div 渲染了,里面的 slot 内容全丢了。
网上搜了一圈,发现得手动告诉 Vue:“这是个 Web Component,别动它”。方法是在 Vue.config.ignoredElements 里加白名单:
// main.js
Vue.config.ignoredElements = [
'unified-login',
'smart-table',
// ...其他自定义元素
];
但问题是,每次新增组件都要手动加,容易漏。后来我写了个 Babel 插件,自动扫描项目里的 customElements.define 调用,生成忽略列表——这招是从 DeepSeek 的开源项目里偷师的,不得不说,国产大模型在工程细节上越来越靠谱了。
坑三:事件传递的“失联”现场
Web Components 内部派发的事件,默认是 不会冒泡出 Shadow DOM 的!这意味着你在外面写:
<my-dialog @close="handleClose"></my-dialog>
在 React 或 Vue 里根本监听不到 close 事件。
正确做法:派发事件时设置 bubbles: true, composed: true:
// 在组件内部
this.dispatchEvent(new CustomEvent('close', {
bubbles: true,
composed: true, // 关键!允许事件穿透 Shadow DOM
detail: { reason: 'user-clicked-cancel' }
}));
这个坑我踩了整整一个周末。周五晚上加班到三点,第二天醒来第一件事就是改这行代码。搞定那一刻,感觉比中彩票还爽。
实战:一个可复用的搜索框组件
下面是我最近做的 <smart-search> 组件核心代码,带防抖、loading 状态、支持受控/非受控模式,注释里都是血泪经验:
class SmartSearch extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// 内部状态
this.value = this.getAttribute('value') || '';
this.debounceTimer = null;
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
render() {
// 注意:这里用 template + clone 更高效,但为简洁直接 innerHTML
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: var(--font-family, sans-serif);
}
.search-box {
border: 1px solid var(--border-color, #ddd);
border-radius: var(--border-radius, 4px);
padding: 8px 12px;
width: 100%;
box-sizing: border-box;
}
.search-box:focus {
outline: none;
border-color: var(--primary-color, #1890ff);
}
.loading {
opacity: 0.6;
pointer-events: none;
}
</style>
<input
class="search-box"
type="text"
placeholder="${this.getAttribute('placeholder') || 'Search...'}"
value="${this.value}"
/>
`;
}
setupEventListeners() {
const input = this.shadowRoot.querySelector('.search-box');
input.addEventListener('input', (e) => {
const newValue = e.target.value;
this.value = newValue;
// 派发 input 事件,供外部框架双向绑定
this.dispatchEvent(new CustomEvent('input', {
bubbles: true,
composed: true,
detail: { value: newValue }
}));
// 防抖搜索
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.dispatchEvent(new CustomEvent('search', {
bubbles: true,
composed: true,
detail: { query: newValue }
}));
}, 300);
});
}
// 外部可通过 setAttribute 或 .value 更新
set value(val) {
this._value = val;
if (this.shadowRoot?.querySelector('.search-box')) {
this.shadowRoot.querySelector('.search-box').value = val;
}
}
get value() {
return this._value;
}
}
customElements.define('smart-search', SmartSearch);
用起来超简单:
<!-- 纯 HTML -->
<smart-search placeholder="找商品..." @search="handleSearch"></smart-search>
<!-- Vue -->
<smart-search
:value="searchQuery"
@input="searchQuery = $event.detail.value"
@search="onSearch"
></smart-search>
<!-- React -->
<smart-search
value={searchQuery}
onInput={(e) => setSearchQuery(e.detail.value)}
onSearch={handleSearch}
/>
性能与兼容性:别被“原生”二字骗了
很多人以为 Web Components 是“原生”的就一定快,其实不然。Shadow DOM 的创建是有开销的,尤其在低端安卓机上。我们做过 A/B 测试:
| 方案 | 首屏加载 (ms) | 内存占用 (MB) | 低端机 FPS |
|---|---|---|---|
| React 组件 | 120 | 45 | 52 |
| Web Components | 95 | 38 | 48 |
| 纯静态 HTML | 60 | 25 | 60 |
可以看到,Web Components 比框架轻,但比纯静态重。适合做中低频交互的通用组件(如按钮、卡片、表单控件),不适合做高频更新的列表或动画。
浏览器兼容性方面,现在连 iOS Safari 都全支持了(iOS 15+)。我们用 webcomponents/polyfills 打了个包,IE11 用户占比不到 0.3%,领导直接拍板放弃支持。
为什么现在才火?GPT-4o 和 DeepSeek 功不可没
说实话,Web Components 早在 2014 年就提出来了,但一直不温不火。为什么最近又热起来了?
我觉得有两个原因:
- 微前端架构普及:越来越多公司需要跨技术栈复用 UI,Web Components 成了天然胶水。
- AI 编程助手成熟:像 GPT-4o 和 DeepSeek 能快速生成标准 Web Components 代码,连生命周期、事件派发、属性监听这些细节都能写对,大大降低了学习成本。
上周我让 DeepSeek 帮我生成一个带 loading 和错误状态的图片上传组件,它居然自动加了 composed: true!要知道三个月前我还在 Stack Overflow 上翻这个问题的答案。
最后几句掏心窝的话
35 岁还在一线写代码,有时候挺累的。但每次看到自己写的组件被十几个项目调用,线上零故障跑了几个月,那种成就感,比涨工资还实在。
Web Components 不是银弹,但它解决了我们最痛的“跨框架复用”问题。如果你也在维护多技术栈项目,不妨试试。别被“过时”“冷门”这些标签吓住——技术的价值,永远取决于它能不能解决你手头的问题。
对了,刚收到消息,下个版本要支持暗黑模式。看来,又得熬夜调 CSS Variables 了。不过没关系,反正我习惯深夜写代码,效率高嘛。
咖啡续上,继续干。

评论 0