Web Components:原生组件化开发新趋势

周末写代码
2025-12-14 16:19
阅读 782

凌晨一点半,我坐在公司附近那套月租 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 的同学几点建议

  1. 别试图用它替代 React/Vue:它适合做“原子级”UI 组件(按钮、卡片、表单控件),不适合构建复杂应用。
  2. 善用 GitHub 开源生态:像 Shoelace 这样的 Web Components UI 库已经很成熟,别重复造轮子。
  3. 性能监控不能少:虽然轻量,但 Shadow DOM 创建有开销。用 Performance API 监控 connectedCallback 耗时。
  4. 和现有框架混用很香:React 中可以通过 ref 操作 Web Component,Vue 3 的 defineCustomElement 更是原生支持。

最后说句掏心窝子的话:作为算法工程师,我原本对前端有种“能跑就行”的敷衍心态。但这次用 Web Components 解决实际问题后,突然理解了什么叫“工程之美”——用最克制的 API,解决最痛的耦合问题

现在,我已经把这套组件发布到了公司内部的 GitHub 组件库,并写了详细的 README(连产品经理都能看懂的那种)。如果你也在被跨团队复用折磨,不妨试试这个“古老又新鲜”的技术。

毕竟,凌晨三点的上海,代码跑通的那一刻,黄焖鸡都是甜的。

(完)

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝