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

线上问题观察员
2025-12-13 14:26
阅读 498

上周五晚上十点,我家娃刚哄睡,我正准备躺下刷会儿手机放松一下——结果 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);

几个关键细节:

  1. observedAttributes 必须声明:否则属性变化不会触发 attributeChangedCallback,这是新手最容易踩的坑。
  2. <style> 写在 Shadow DOM 里:天然隔离,外部样式无法穿透(除非用 ::part 或 CSS 变量暴露接口)。
  3. 响应式 & 暗色模式:通过 @media (prefers-color-scheme: dark) 和 CSS 变量实现,无需 JS 判断。
  4. connectedCallback vs constructor:渲染逻辑放 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-cardsearch-barnotification-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

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