Web Components:原生组件化开发新趋势?我在小红书踩过的坑和收获
大家好,我是成都某厂(对,就是你想的那个红色图标App)的推荐算法工程师,入行快两年了。虽然主业是搞召回、排序、AB实验那一套,但因为团队人少活多,前端活儿也经常被拉去“支援”——尤其是用户增长相关的落地页、活动弹窗这些轻交互模块。
上周五晚上,我又一次被产品拉进紧急会议:“双12大促马上来了,我们需要一个通用的‘商品推荐卡片’组件,能嵌入任何H5页面,不管你是React、Vue还是纯静态页,都得跑起来!”
我内心OS:又来?上次双11那个弹窗还在生产环境偶尔白屏呢……
但这次我学聪明了——与其在React/Vue生态里打补丁,不如试试 Web Components。这玩意儿最近在技术圈吹得挺凶,说是“原生组件化”的未来。正好我也想跳槽面试前充充电,顺便把“面试题挑战”里的“如何实现跨框架组件复用”给啃下来。
被逼上梁山:为什么选 Web Components?
我们团队的现状很典型:
- 主App用 React + TypeScript
- H5活动页有的用 Vue 2,有的直接 jQuery 写
- 运维那边还塞进来几个老CMS系统,连 ES6都不支持
之前的做法?写三套一模一样的推荐卡片,改个UI要同步改三次,测试同学都快把我微信拉黑了。有一次改了个按钮颜色,Vue 版漏了,上线后用户投诉“按钮看不见”,运维半夜打电话把我从火锅局叫回来回滚……那场面,真的想砸电脑。
所以这次我咬牙拍板:用 Web Components,一次编写,到处运行。
Web Components 是一套浏览器原生支持的组件化标准,包含三个核心 API:
Custom Elements:自定义 HTML 标签Shadow DOM:样式和 DOM 隔离HTML Templates:声明式模板
不需要打包、不依赖框架,现代浏览器基本都支持(IE 除外,但谁还在乎 IE 呢?)。
动手干:从零写一个 <x-rec-card>
目标很明确:一个能展示商品图、标题、价格、点击埋点的卡片,支持动态传参。
第一步:定义 Custom Element
// rec-card.js
class RecCard extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM,隔离样式
this.attachShadow({ mode: 'open' });
}
// 属性变化时触发(用于响应式更新)
static get observedAttributes() {
return ['title', 'price', 'img-url'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
connectedCallback() {
// 元素插入 DOM 时初始化
this.render();
this.setupEventListeners();
}
render() {
const title = this.getAttribute('title') || '默认标题';
const price = this.getAttribute('price') || '¥0.00';
const imgUrl = this.getAttribute('img-url') || '/default.jpg';
this.shadowRoot.innerHTML = `
<style>
:host { display: block; font-family: -apple-system, sans-serif; }
.card { border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.img { width: 100%; height: 160px; object-fit: cover; }
.info { padding: 12px; }
.title { font-size: 16px; font-weight: 600; margin: 0; }
.price { color: #e64340; font-size: 18px; margin: 8px 0 0; }
</style>
<div class="card">
<img class="img" src="${imgUrl}" alt="${title}">
<div class="info">
<h3 class="title">${title}</h3>
<p class="price">${price}</p>
</div>
</div>
`;
}
setupEventListeners() {
this.shadowRoot.querySelector('.card').addEventListener('click', () => {
// 埋点上报(简化版)
console.log('RecCard clicked:', this.getAttribute('title'));
// 实际项目中会调用我们的数据上报 SDK
});
}
}
// 注册自定义元素
customElements.define('x-rec-card', RecCard);
第二步:在任意页面使用
<!-- 纯 HTML 页面 -->
<script type="module" src="./rec-card.js"></script>
<x-rec-card
title="【双12爆款】智能空气炸锅"
price="¥299"
img-url="https://example.com/fryer.jpg">
</x-rec-card>
甚至在 React 里也能直接用(别忘了加 type="module"):
// React 组件中
import './rec-card.js'; // 注意:需确保只加载一次
export default function HomePage() {
return (
<div>
<h1>猜你喜欢</h1>
<x-rec-card
title="无线蓝牙耳机"
price="¥199"
img-url="/headphones.jpg" />
</div>
);
}
踩坑实录:别信“开箱即用”
理想很丰满,现实嘛……全是坑。
坑1:属性传参 vs 对象传参
Web Components 只支持字符串属性。想传复杂对象?得自己序列化。
// ❌ 不能这么写
// <x-rec-card product="{ title: 'xxx', price: 299 }"></x-rec-card>
// ✅ 正确姿势:JSON 字符串 + 解析
this.product = JSON.parse(this.getAttribute('product') || '{}');
但这样性能差、易出错。后来我们封装了一个工具函数,自动监听属性变化并反序列化。
坑2:Shadow DOM 里的事件冒泡被阻断
我在 .card 上绑了 click 事件,但在父组件里监听不到!查文档才知道:Shadow DOM 默认阻止事件冒泡到外部。
解决方案:手动派发自定义事件。
setupEventListeners() {
this.shadowRoot.querySelector('.card').addEventListener('click', () => {
// 派发可冒泡的自定义事件
this.dispatchEvent(new CustomEvent('rec-click', {
detail: { title: this.getAttribute('title') },
bubbles: true,
composed: true // 关键!允许穿越 Shadow DOM 边界
}));
});
}
现在父级可以正常监听了:
document.querySelector('x-rec-card').addEventListener('rec-click', (e) => {
console.log('用户点击了:', e.detail.title);
});
坑3:样式定制太痛苦
产品经理说:“这个卡片在首页要圆角,在活动页要方角!”
我:???
Shadow DOM 的样式默认是封闭的。解决方案有几种:
- 用 CSS 变量暴露可配置项
- 通过
::part()选择器(需浏览器支持) - 或者……干脆不用 Shadow DOM(
mode: 'closed'改成不 attach)
我们最终采用 CSS 变量:
/* 组件内部 */
.card {
border-radius: var(--rec-card-radius, 8px); /* 默认8px,可外部覆盖 */
}
<!-- 外部使用时 -->
<style>
x-rec-card {
--rec-card-radius: 0px;
}
</style>
性能 & 兼容性:真能上生产?
我们做了简单压测:在低端安卓机(Redmi 9A)上渲染 50 个 <x-rec-card>,FPS 稳定在 55+,内存占用比 React 版低 30%。毕竟没有虚拟 DOM diff,原生操作就是快。
兼容性方面,Can I Use 数据如下:
| 浏览器 | 支持情况 |
|---|---|
| Chrome | ✅ 54+ |
| Firefox | ✅ 63+ |
| Safari | ✅ 10.1+ |
| Edge | ✅ 79+ |
| iOS WebView | ✅ 10.3+ |
国内主流 App 内置 WebView(微信、QQ、抖音)基本都基于 Chromium 70+,问题不大。实在要兼容老机型?上 webcomponents/polyfills,但体积会增加 ~15KB。
面试题挑战:Web Components vs 框架组件
最近面试被问到:“你们为什么不用微前端或者 iframe?”
我的回答是:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Web Components | 原生、轻量、跨框架 | 生态弱、调试工具少 |
| 微前端(qiankun) | 功能强、隔离彻底 | 重、通信复杂、SEO 差 |
| iframe | 完全隔离 | 性能差、通信难、体验割裂 |
对我们这种“轻量、高频、多端嵌入”的场景,Web Components 是甜点区。
总结:值不值得投入?
上线两周,0 故障。产品夸我“这次终于没出 bug”,测试同学给我点了奶茶(感动哭)。
Web Components 不是银弹,但在特定场景下,它真的香:
- 跨技术栈复用 UI 组件
- 第三方嵌入式 widget(比如客服浮窗、分享按钮)
- 渐进式重构老项目
当然,复杂交互、状态管理还是交给 React/Vue 更靠谱。但作为一个推荐工程师,能把一个组件做到“一次开发,全平台跑”,省下的时间够我多调两个模型参数了(笑)。
最后,如果你也在被“多端一致”折磨,不妨试试 Web Components。代码可读性高、维护成本低,还能在面试时秀一把“原生 API 掌握度”——毕竟,综合能力才是涨薪的关键嘛!
本文代码已整理到 GitHub,欢迎 star:
github.com/yourname/x-rec-card
成都的冬天又湿又冷,写完这篇我去煮碗面,大家保重!

评论 0