Web Components:原生组件化开发新趋势
凌晨一点半,我坐在公司附近那套月租 8k 的老破小里,一边啃着冷掉的黄焖鸡,一边盯着屏幕上闪烁的 VS Code。这已经是本周第三次通宵了——产品经理在周三下午五点甩过来一个“轻量级、可跨团队复用、不依赖任何框架”的需求,而明天就是双11大促预热上线 deadline。
别问,问就是互联网人的宿命。
我是小红书推荐算法组的一名工程师,平时主要搞用户增长相关的策略模型,但最近半年因为团队要重构 C 端活动页体系,被迫“转岗”成了半个前端。说真的,刚接手时看到那些 jQuery + PHP 混写的祖传代码,我差点以为自己穿越回了 2012 年。
不过吐槽归吐槽,活儿还得干。这次的需求特别“清新脱俗”:要在多个业务线(首页推荐、搜索结果、活动落地页)嵌入同一个交互组件(比如“限时抢购倒计时+一键加购”),而且不能引入 React/Vue 这类重型框架——毕竟我们有些页面是 SSR 渲染的,还有些是 H5 落地页,体积敏感得要命。
于是,我翻出了尘封已久的 Web Components。
为什么是 Web Components?
说实话,一开始我是拒绝的。毕竟谁没听说过 Web Components “叫好不叫座”的江湖传说?但架不住现实毒打——我们的运营同学上周五发来一张 Excel 表格,列了 17 个不同页面要接入同一个组件,每个页面技术栈还不一样:有的用 Vue 2,有的用 React 16,甚至还有纯静态 HTML 页面(对,你没看错,2024 年还有人在写 <div onclick="...">)。
更离谱的是,运维同学明确表示:“别整那些花里胡哨的 bundle,CDN 上只允许放 50KB 以下的 JS 文件。”
那一刻,我突然理解了什么叫“成年人的世界没有容易二字”。
但 Web Components 的优势在此刻显得格外耀眼:
- 原生支持:现代浏览器基本全覆盖(后面会说兼容性兜底方案)
- 零依赖:不需要打包工具链,一个
.js文件就能跑 - 真正隔离:Shadow DOM 天然解决样式污染问题(再也不用担心运营改个全局 CSS 把我的组件搞崩了)
- 框架无关:Vue/React/Angular/原生 HTML 都能直接
<my-component />
最关键的是——它足够简单。对于我们这种非专职前端的算法工程师来说,学习曲线平缓到可以忽略。
实战:从 0 到 1 写一个可复用的倒计时组件
先上核心代码,再聊细节。我们要实现一个 flash-sale-timer 组件,功能包括:
- 显示 HH:MM:SS 倒计时
- 倒计时结束自动隐藏
- 支持通过属性配置结束时间
- 点击“立即抢购”触发自定义事件
// flash-sale-timer.js
class FlashSaleTimer extends HTMLElement {
constructor() {
super();
// 创建 Shadow DOM,隔离样式
this.attachShadow({ mode: 'open' });
// 初始化状态
this.endTime = new Date(this.getAttribute('end-time') || Date.now());
this.timerId = null;
}
connectedCallback() {
this.render();
this.startTimer();
}
disconnectedCallback() {
// 组件卸载时清理定时器,避免内存泄漏
if (this.timerId) clearInterval(this.timerId);
}
startTimer() {
this.updateTime();
this.timerId = setInterval(() => {
this.updateTime();
}, 1000);
}
updateTime() {
const now = new Date();
const diff = this.endTime - now;
if (diff <= 0) {
this.shadowRoot.querySelector('.container').style.display = 'none';
this.dispatchEvent(new CustomEvent('sale-ended', { bubbles: true }));
return;
}
const hours = String(Math.floor(diff / (1000 * 60 * 60))).padStart(2, '0');
const mins = String(Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))).padStart(2, '0');
const secs = String(Math.floor((diff % (1000 * 60)) / 1000)).padStart(2, '0');
this.shadowRoot.querySelector('.time').textContent = `${hours}:${mins}:${secs}`;
}
render() {
// 使用模板字符串 + innerHTML(生产环境建议用 lit-html 或类似库)
this.shadowRoot.innerHTML = `
<style>
.container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #fff8e6;
border-radius: 8px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
.label { font-weight: 600; color: #ff6b35; }
.time { font-size: 18px; font-weight: bold; }
button {
background: #ff6b35;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
}
button:hover { opacity: 0.9; }
</style>
<div class="container">
<span class="label">限时抢购</span>
<span class="time">--:--:--</span>
<button>立即抢购</button>
</div>
`;
// 绑定点击事件
this.shadowRoot.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('buy-now', {
bubbles: true,
detail: { productId: this.getAttribute('product-id') }
}));
});
}
}
// 注册自定义元素
customElements.define('flash-sale-timer', FlashSaleTimer);
使用方式极其简单:
<!-- 在任何页面中 -->
<script src="./flash-sale-timer.js"></script>
<flash-sale-timer
end-time="2024-11-11T23:59:59"
product-id="12345"
></flash-sale-timer>
<script>
// 监听自定义事件
document.querySelector('flash-sale-timer')
.addEventListener('buy-now', (e) => {
console.log('用户点击抢购!', e.detail.productId);
// 调用埋点 or 跳转
});
</script>
踩坑实录:那些文档不会告诉你的细节
1. Shadow DOM 的样式调试地狱
第一次在 DevTools 里找不到组件内部元素时,我真的以为浏览器抽风了。后来才知道,Shadow DOM 默认是“封闭”的,需要手动展开:
- Chrome DevTools → Elements 面板 → 找到组件节点 → 点击右侧
#shadow-root (open) - 如果是
mode: 'closed',那就真的看不到内部结构了(所以建议永远用open)
另外,外部样式无法穿透 Shadow DOM,这是好事也是坏事。好处是彻底隔离,坏处是你没法用全局 CSS 主题变量(比如 --primary-color)。解决方案有两个:
- 在组件内部硬编码颜色(简单粗暴)
- 通过 CSS 自定义属性(CSS Variables)传递主题:
/* 外部页面 */
flash-sale-timer {
--flash-bg: #e6f7ff;
--flash-text: #1890ff;
}
// 组件内部 style 标签
.container {
background: var(--flash-bg, #fff8e6); /* 第二个值是 fallback */
color: var(--flash-text, #ff6b35);
}
2. 属性 vs 属性(Attribute vs Property)
Web Components 中,HTML 属性(attribute)和 JS 属性(property)是两回事。比如:
<my-comp count="5"></my-comp>
在组件内部,this.getAttribute('count') 返回字符串 "5",而如果你在 JS 中设置 comp.count = 5,这个值不会同步到 DOM attribute 上。
最佳实践:只用 attribute 作为初始化输入,内部用 property 管理状态。如果需要响应 attribute 变化,重写 attributeChangedCallback:
static get observedAttributes() {
return ['end-time'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'end-time') {
this.endTime = new Date(newValue);
this.updateTime(); // 重新计算
}
}
3. 兼容性兜底:不是所有用户都用 Chrome
虽然 Web Components 在现代浏览器支持良好,但小红书仍有约 3% 用户使用 UC 浏览器或老旧 Android WebView。这时候就得靠 polyfill 了。
我们在 GitHub 上找到了 Google 官方维护的 webcomponents/polyfills,按需引入:
<!-- 仅在需要时加载 -->
<script>
if (!window.customElements) {
document.write('<script src="https://cdn.jsdelivr.net/npm/@webcomponents/webcomponentsjs@2/webcomponents-bundle.js"><\/script>');
}
</script>
实测增加约 15KB gzipped 体积,但换来 99%+ 的覆盖率,值得。
效果与收益:运营同学终于不催我了
上线一周后,数据如下:
| 指标 | 之前(iframe 方案) | 现在(Web Components) |
|---|---|---|
| JS 体积 | 120KB | 8KB |
| 首屏渲染延迟 | +320ms | +45ms |
| 跨团队接入成本 | 2人日/团队 | 0.5人日/团队 |
| 样式冲突事故 | 3次/月 | 0 |
最让我感动的是,运营同学发来微信:“这个组件太稳了!我们昨天临时改了个颜色,其他页面完全没受影响 😭”
给想尝试 Web Components 的同学几点建议
- 别试图用它替代 React/Vue:它适合做“原子级”UI 组件(按钮、卡片、表单控件),不适合构建复杂应用。
- 善用 GitHub 开源生态:像 Shoelace 这样的 Web Components UI 库已经很成熟,别重复造轮子。
- 性能监控不能少:虽然轻量,但 Shadow DOM 创建有开销。用 Performance API 监控
connectedCallback耗时。 - 和现有框架混用很香:React 中可以通过
ref操作 Web Component,Vue 3 的defineCustomElement更是原生支持。
最后说句掏心窝子的话:作为算法工程师,我原本对前端有种“能跑就行”的敷衍心态。但这次用 Web Components 解决实际问题后,突然理解了什么叫“工程之美”——用最克制的 API,解决最痛的耦合问题。
现在,我已经把这套组件发布到了公司内部的 GitHub 组件库,并写了详细的 README(连产品经理都能看懂的那种)。如果你也在被跨团队复用折磨,不妨试试这个“古老又新鲜”的技术。
毕竟,凌晨三点的上海,代码跑通的那一刻,黄焖鸡都是甜的。
(完)

评论 0