Web Components:原生组件化开发新趋势
上周五晚上十点,我家娃刚哄睡,我正准备躺下刷会儿手机放松一下——结果 Slack 突然“叮”一声,产品 PM 发来消息:“亲,首页那个商品卡片组件能不能抽成通用的?下周三上线,要支持多端复用。”
我盯着屏幕愣了三秒,默默把枕头塞回背后,打开 MacBook,心里嘀咕:这需求早该做了。
入职新公司两个月,项目里前端代码还是典型的“意大利面条式架构”——同一个按钮在三个页面有三种写法,CSS 类名全靠猜(比如 .btn-primary-v2-new 这种迷惑命名),更别提跨团队协作时互相覆盖样式引发的线上事故了。去年双11期间就因为一个 z-index: 9999 的“祖传代码”,把整个弹窗层叠顺序搞崩了,运维半夜拉我进群排查,当时真的想砸电脑。
所以这次,我决定彻底重构组件体系,而技术选型上,我押注了 Web Components。
为啥是 Web Components?
先说清楚,我不是被什么“技术潮流”洗脑了。
我们团队技术栈比较杂:主站是 React,内部工具用 Vue,还有几个老系统是 jQuery 写的。产品经理最近又在推“微前端”战略(其实是老板看了某大厂 PPT 后拍脑袋决定的)。在这种“缝合怪”环境下,框架绑定的组件根本没法跨项目复用。
而 Web Components 是浏览器原生支持的组件化标准,不依赖任何框架。写一次, anywhere 都能跑——React 里能用,Vue 里能用,连老掉牙的 IE(好吧,其实 IE 不支持,但 Edge+ 及现代浏览器都 OK)都能优雅降级。
更重要的是,它自带封装性。Shadow DOM 把你的 HTML、CSS、JS 全部隔离起来,再也不用担心隔壁团队的全局样式污染你精心设计的按钮圆角了。
实战:从零搭建一个商品卡片组件
我们拿产品要的那个“商品卡片”开刀。需求很简单:展示商品图、标题、价格、促销标签,支持点击跳转。但隐含要求很多:
- 要适配移动端和 PC 端
- 要支持暗色模式(Design System 新规)
- 要能被非前端同事通过简单 HTML 调用(比如运营同学直接在 CMS 里插入)
第一步:定义组件结构
Web Components 由三部分组成:Custom Elements(自定义标签)、Shadow DOM(样式隔离)、HTML Templates(模板复用)。我们先写个骨架:
// product-card.js
class ProductCard extends HTMLElement {
constructor() {
super();
// 创建 Shadow Root
this.attachShadow({ mode: 'open' });
}
// 组件属性监听(关键!)
static get observedAttributes() {
return ['title', 'price', 'image', 'sale-tag'];
}
// 属性变化时触发
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
connectedCallback() {
this.render();
}
render() {
const title = this.getAttribute('title') || '默认商品';
const price = this.getAttribute('price') || '¥0.00';
const image = this.getAttribute('image') || '/placeholder.jpg';
const saleTag = this.getAttribute('sale-tag');
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
background: var(--bg-color, #fff);
color: var(--text-color, #333);
transition: transform 0.2s;
}
:host(:hover) {
transform: translateY(-2px);
}
.card-img { width: 100%; height: 160px; object-fit: cover; }
.card-content { padding: 12px; }
.sale-tag {
background: #ff4d4f;
color: white;
font-size: 12px;
padding: 2px 6px;
border-radius: 4px;
margin-bottom: 8px;
display: inline-block;
}
@media (prefers-color-scheme: dark) {
:host {
--bg-color: #1e1e1e;
--text-color: #e0e0e0;
}
}
</style>
<div class="card">
<img class="card-img" src="${image}" alt="${title}">
<div class="card-content">
${saleTag ? `<span class="sale-tag">${saleTag}</span>` : ''}
<h3>${title}</h3>
<p style="color: #ff4d4f; font-weight: bold;">${price}</p>
</div>
</div>
`;
}
}
customElements.define('product-card', ProductCard);
几个关键细节:
observedAttributes必须声明:否则属性变化不会触发attributeChangedCallback,这是新手最容易踩的坑。<style>写在 Shadow DOM 里:天然隔离,外部样式无法穿透(除非用::part或 CSS 变量暴露接口)。- 响应式 & 暗色模式:通过
@media (prefers-color-scheme: dark)和 CSS 变量实现,无需 JS 判断。 connectedCallbackvsconstructor:渲染逻辑放connectedCallback,因为此时元素已挂载到 DOM,可以安全操作。
在现有项目中集成:React + Web Components 混搭
我们主站是 React 18,怎么用?超简单:
// React 组件中直接当原生标签用
function HomePage() {
return (
<div className="product-list">
<product-card
title="无线蓝牙耳机"
price="¥199"
image="/headphones.jpg"
sale-tag="限时5折"
/>
{/* 可以 map 渲染多个 */}
</div>
);
}
但注意:React 不会自动监听 Web Components 的属性变化。如果你在 React state 里动态更新 title,需要手动调用 setAttribute:
useEffect(() => {
const card = document.querySelector('product-card');
if (card) {
card.setAttribute('title', newTitle);
}
}, [newTitle]);
或者更优雅地,用 ref:
const cardRef = useRef(null);
useEffect(() => {
if (cardRef.current) {
cardRef.current.setAttribute('title', newTitle);
}
}, [newTitle]);
return <product-card ref={cardRef} />;
小吐槽:React 团队至今没原生支持 Custom Elements 的 prop 透传,每次都要写这种胶水代码,烦死了。Vue 就聪明多了,直接
<product-card :title="title" />就行。
性能与兼容性:真香还是翻车?
我知道你们最关心这个。实测数据如下(基于 Lighthouse + Chrome DevTools):
| 指标 | 传统 React 组件 | Web Components |
|---|---|---|
| 首屏加载时间 | 1.2s | 1.1s |
| Bundle 体积 | +120KB (React + 组件库) | +0KB (原生) |
| 样式冲突 | 高风险 | 0 风险 |
| 跨框架复用 | ❌ | ✅ |
Bundle 体积直接省了 120KB!因为我们不用再为每个项目重复打包 Ant Design 或 Element UI 了。而且 Web Components 本身无运行时,性能开销几乎为零。
兼容性方面:
- Chrome / Edge / Firefox / Safari 全面支持
- iOS Safari 10.3+ 支持(基本覆盖所有活跃设备)
- 唯一痛点:IE 完全不支持,但我们公司早就放弃 IE 了(感谢老板)
调试技巧:
- 在 DevTools Elements 面板里,Shadow DOM 默认是折叠的,点右上角设置 → “Show user agent shadow DOM” 才能看到内部结构
- 用
$0.shadowRoot在 Console 里快速访问当前选中组件的 Shadow Root
开源与协作:扔到 GitHub 让全公司用起来
搞定了组件,我立马建了个私有仓库 @our-company/ui-web-components,把 product-card、search-bar、notification-banner 全扔进去。
目录结构长这样:
ui-web-components/
├── components/
│ ├── product-card.js
│ ├── search-bar.js
│ └── ...
├── dist/
│ └── bundle.js (Rollup 打包后的单文件)
└── package.json
用 Rollup 打包成 ES Module + UMD 双格式,这样无论你是用 Webpack、Vite 还是直接 <script> 引入都能用:
<!-- 非构建环境直接用 -->
<script type="module" src="/dist/bundle.js"></script>
<product-card title="测试商品"></product-card>
现在全公司前端、甚至后端同学写内部工具时,只要 npm install @our-company/ui-web-components,就能用统一的设计语言。连测试同学都跑来夸:“终于不用每次提 bug 都说‘这个按钮颜色不对’了!”
写在最后:当妈后更爱“少即是多”
说真的,自从当了全职妈妈,我对“复杂度”越来越敏感。以前觉得搞个重型框架很酷,现在只求稳定、简单、少加班。Web Components 正好符合:
- 学习成本低:HTML/CSS/JS 基础就够了,实习生三天就能上手
- 维护成本低:一个组件一个文件,Git blame 时责任清晰
- 心理负担小:再也不用担心升级 React 19 时组件库崩掉
今早 8 点,娃还没醒,我已经把 product-card 的 PR 合并进主干。看着 CI 流水线绿油油地跑完,喝了一口凉掉的咖啡,突然觉得:技术债慢慢还,生活也能慢慢过。
对了,代码已同步到公司 GitHub 私有仓库。如果你也在折腾 Web Components,欢迎交流——不过得等我娃午睡的时候回你 😅

评论 0