用原生的力量重构组件——Web Components 实践之路

吴志华
2025-06-30 08:34
阅读 358

引言:为什么选在项目中试水 Web Components?

引言:为什么选在项目中试水 Web Components?

我所在的公司是一家偏向于 B2B 领域的互联网企业,前端技术栈以 React 为主。然而,在去年的一个多系统协同项目中,我们面临了一个很现实的问题:

我们需要为多个独立部署的产品(有些甚至使用了 Vue 和 Angular)提供统一 UI 组件库 + 主题配置能力,同时又不希望引入额外的依赖。

这听起来像是一个“跨框架共享 UI 库”的经典需求。当时我们的第一反应还是继续沿用 Storybook + Monorepo 的方式,封装 React 组件并通过构建工具打包成 JS 模块供其他团队接入。

但是当真正落地的时候,我们发现很多问题:

  • 非 React 项目的集成成本太高;
  • 各个项目使用的 React 版本不一致导致样式/行为异常;
  • 样式污染严重(CSS in JS 或 CSS Modules 在不同架构下的兼容性差异明显);
  • 升级和维护时牵一发而动全身。

面对这些问题,我和团队中的几个同学开始调研 Web Components 是否可以作为替代方案来解耦我们的组件体系。

简要介绍一下什么是 Web Components

简单来说,Web Components 是一组浏览器原生支持的标准 API,主要包括三个部分:

  1. Custom Elements:允许我们定义自己的 HTML 标签;
  2. Shadow DOM:为组件创建一个隔离的 DOM 子树,样式与外界互不影响;
  3. HTML Templates:通过 <template><slot> 定义可复用的内容片段。

它最大的优势在于 不需要依赖任何框架,也不需要编译器或者打包工具,直接用浏览器原生机制运行,天生具备跨框架、低耦合、可重用、易维护等特性。


项目背景:一次组件复用的尝试

项目背景:一次组件复用的尝试

为了验证 Web Components 的可行性,我们决定先从一个小模块入手。

我们负责开发的是一个数据展示仪表盘功能,其中有一个核心组件叫 data-card,它的作用是渲染带标题、统计数值、趋势箭头和颜色状态的小卡片。

这个组件被广泛使用在多个产品线中,包括:

  • 一个基于 React 的管理后台;
  • 一个正在迁移到 Vue 的数据平台;
  • 一个完全静态的营销页面(纯 HTML + Vanilla JS)。

也就是说,它是一个典型的需要跨技术栈、高度可复用的通用 UI 组件。而且我们还希望能够让它支持动态主题切换、国际化文案,以及自定义内容插槽等功能。

于是,我们决定用 Web Component 的方式把这个组件重新实现一遍,并在内部推动大家进行小范围试点。


挑战:真实场景下的坑比想象中多得多

JavaScript框架对比-1

理想很丰满,但实际做起来之后才发现并不轻松。

✅ Shadow DOM 带来的样式隔离确实好用,但也带来了调试困难

原本我们认为 Shadow DOM 可以自动解决样式污染问题,但实际情况是:

  • Chrome DevTools 中默认隐藏 shadowRoot 内容,查看调试比较麻烦;
  • 不能直接通过 document.querySelectorAll 来获取 shadow root 内部元素,这对测试或自动化脚本不太友好;
  • 某些 CSS 属性无法穿透 Shadow Root,例如 font-family 必须显式声明,否则继承失败;
  • 动态计算的主题变量如何传入组件内部也是一个挑战。

为此,我们最后不得不在组件外部设置字体、颜色变量,并通过 CSS Custom Properties 透传进组件内。

✅ 跨框架通信和 DOM 交互不如框架里优雅

比如在 React 中使用 Web Components,虽然能够正常渲染,但在事件处理上遇到了一些障碍:

<DataCardComponent
  value={totalOrders}
  label="订单总数"
  trend="up"
  color="#50a3ba"
/>

这段代码看起来没问题,但我们发现 Web Component 并不会自动响应属性变化。我们需要手动监听 attribute 的变更:

static get observedAttributes() {
  return ['value', 'label', 'trend', 'color'];
}

attributeChangedCallback(name, oldVal, newVal) {
  if (name === 'value') {
    this._renderValue(newVal);
  }
}

相比之下,React 自己的状态绑定更丝滑。

此外,在某些复杂交互中,我们还需要让组件抛出事件给外层框架处理:

this.dispatchEvent(new CustomEvent('select', {
  detail: { item: selectedItem },
  bubbles: true,
  composed: true
}));

但如果外部监听者没有注册这些自定义事件,很容易造成交互失败。

✅ 构建流程与 CI 系统集成不够顺畅

我们之前所有的 JS 包都是通过 webpack/babel 处理的。但 Web Component 的构建相对“轻量”,我们一开始想当然地以为可以直接通过 ES Module 原生加载。

结果发现:

  • 浏览器兼容性是个大问题(特别是对旧版本 Safari 支持不好);
  • 没有 Tree Shaking,体积优化受限;
  • 与现有的打包流程割裂,无法统一发布到 NPM。

最终我们选择借助 Rollup 打包 Web Component,将组件输出为 UMD 模块,并增加 Polyfill 兼容性支持。


实现思路与关键技术细节

✅ 结构设计和组件生命周期

我们采用 Class + CustomElementRegistry 的方式定义组件类:

class DataCard extends HTMLElement {
  constructor() {
    super();
    
    // 创建 Shadow DOM
    const shadow = this.attachShadow({ mode: 'open' });
    
    // 使用 template 插入结构
    const template = document.getElementById('data-card-template');
    const content = template.content.cloneNode(true);
    
    // 初始属性绑定
    this._label = this.getAttribute('label') || '默认标签';
    this._value = this.getAttribute('value') || 0;
    
    // 插入模板内容
    shadow.appendChild(content);
    
    // 获取关键节点引用
    this.$labelEl = this.shadowRoot.querySelector('.card-label');
    this.$valueEl = this.shadowRoot.querySelector('.card-value');
  }

  connectedCallback() {
    // 当组件插入文档时执行初始化逻辑
    this.render();
  }

  attributeChangedCallback(attrName, oldVal, newVal) {
    if (!oldVal || oldVal === newVal) return;

    switch (attrName) {
      case 'label':
        this._label = newVal;
        break;
      case 'value':
        this._value = Number(newVal);
        break;
    }
    this.render();
  }
  
  static get observedAttributes() {
    return ['label', 'value'];
  }
  
  render() {
    if (this.$labelEl) this.$labelEl.textContent = this._label;
    if (this.$valueEl) this.$valueEl.textContent = this._value;
  }
}

customElements.define('data-card', DataCard);

这种方式让整个组件结构非常清晰,也可以很好地结合 Shadow DOM 进行样式的封闭性处理。

✅ 用 Shadow DOM 控制样式隔离

我们在 shadowRoot 中添加了如下样式:

<template id="data-card-template">
  <style>
    .card-container {
      background-color: #f8f8f8;
      border-radius: 8px;
      padding: 16px;
      font-family: inherit; /* 保持与主文档一致 */
    }
    .card-label {
      color: #666;
    }
    .card-value {
      font-size: 24px;
      font-weight: bold;
      margin-top: 4px;
    }
  </style>
  <div class="card-container">
    <div class="card-label">Label</div>
    <div class="card-value">0</div>
  </div>
</template>

响应式布局概念图-2

通过这种方式,即使父应用中有同名类名也不会覆盖组件内部样式。

如果你希望让用户自定义某些主题色,可以暴露一些 CSS Variables:

.card-container {
  --card-bg: #f8f8f8;
  background-color: var(--card-bg);
}

这样就可以在外部控制组件风格:

<style>
  data-card {
    --card-bg: #e8f4ff;
  }
</style>
<data-card label="销售额" value="12345"></data-card>

✅ 插槽机制提升扩展性

为了让用户可以在组件中间插入任意内容,我们加入了插槽支持:

<template id="data-card-template">
  <div class="card-container">
    <div class="card-label"><slot name="label">默认标签</slot></div>
    <div class="card-content"><slot>默认内容</slot></div>
  </div>
</template>

使用时:

<data-card>
  <span slot="label">总收益</span>
  <div style="font-size: 20px;">¥12345.67</div>
</data-card>

这种机制非常适合组件中某些灵活区域的设计。


一些踩过的坑和解决方法

⚠️ 浏览器兼容性问题

Web Components 不是所有浏览器都支持良好。尤其是对于 IE11 或旧版 Safari,我们做了如下处理:

  • 使用 polyfills
  • 检测浏览器是否支持 Web Components 并降级回退到普通组件:
if (window.customElements) {
  customElements.define('data-card', DataCard);
} else {
  console.warn('当前浏览器不支持 Web Components,已使用备用组件');
}

⚠️ 性能问题:频繁重绘优化

早期我们没有优化 attributeChangedCallback 中的 render 方法,导致组件频繁更新时性能下降明显。

解决方案是我们加了个简单的 diff 机制:

_updateProp(name, newVal) {
  if (this[`_${name}`] !== newVal) {
    this[`_${name}`] = newVal;
    requestAnimationFrame(() => {
      this.render();
    });
  }
}

借助 requestAnimationFrame 缓解高频更新带来的渲染压力。

⚠️ 国际化支持难题

为了让 Web Component 支持国际化文案,最初我们打算内置翻译对象:

const translations = {
  'zh-CN': {
    defaultLabel: '默认标签'
  },
  'en-US': {
    defaultLabel: 'Default Label'
  }
};

但这显然不够灵活。后来我们将语言判断交给外部传入一个 translation 对象:

<data-card :i18n="{
  defaultLabel: '订单总量'
}" />

然后通过 setAttribute 设置 JSON 字符串传递,组件内部解析后做动态替换。


最终成果和收获

经过大约两个月时间的重构和推广,我们成功地在两个新项目上线了 Web Components 版本的核心 UI 组件库,并逐步迁移老项目。

最终效果如下:

  • 组件跨框架兼容性显著提升:Vue/React/Static HTML 页面都能轻松接入;
  • 样式污染基本杜绝:得益于 Shadow DOM 的隔离能力;
  • 发布流程简化:不再依赖特定框架的构建环境;
  • 维护成本降低:修改一处组件即可全平台同步生效;
  • 可拓展性强:支持插槽、国际化、主题定制等高级能力。

更重要的是,我们终于有了一个 “干净、无侵入、可演进” 的组件系统。这对我们这类业务复杂、技术多样化的团队来说,是一个重大突破。


分享几点经验给准备入坑的你

1. 从小处着手,不要贪大求全

刚开始别想着一口吃成胖子,先从一个小型组件入手,把 Web Components 的基础用法跑通,再逐步扩大覆盖面。

建议挑选以下几个类型之一作为练手:

  • 表单项(Input / Select)
  • 展示型组件(Badge / Card)
  • 导航栏 / Tab 标签页

这些组件通常不依赖太多框架特性,适合 Web Components 的发挥。

2. 考虑构建流程与团队协作方式

Web Component 没有框架帮你打包优化。所以在推进过程中一定要考虑:

  • 如何用 Rollup/Webpack 构建组件;
  • 是否要输出 ESM/UMD/umd/umd.min.js 等多种格式;
  • 是否要发布到 NPM;
  • 如何写文档(推荐使用 Playground + 示例演示);

这些都是影响最终落地的关键因素。

3. 建立统一命名规范和设计体系

如果多个团队共用 Web Component 库,必须提前约定命名规则,比如:

<company-header-bar title="主页"></company-header-bar>

组件名应具备语义性,避免冲突。

同时建议搭配一个统一的设计系统文档站点,说明每个组件的属性、事件、插槽等信息。

4. 注意性能监控与用户体验

尽管 Web Components 的渲染效率不错,但在大规模组件嵌套时也需要注意性能瓶颈。

建议:

  • 开启 Lighthouse 性能检测;
  • 使用 PerformanceObserver 记录组件加载耗时;
  • 对懒加载策略做优化(如 defer 注册组件);
  • 尽量减少不必要的 DOM 重排重绘。

总结:Web Components 是未来的标准吗?

从我个人实践来看,Web Components 并不是银弹,但它提供了一种更加稳定、可控、可持续的组件化方案

尤其适用于以下几种场景:

  • 跨技术栈的组件共享;
  • 渴望摆脱框架依赖的微前端架构;
  • 提供第三方 SDK 或小工具组件;
  • 长期维护的大型项目架构标准化。

如果你的项目也有类似的需求,不妨试试看 Web Components,说不定会打开另一扇新的大门。


如果你也在探索一种真正“通用”的组件方案,欢迎留言交流经验或者遇到的坑。咱们一起把这件事做得更好 😎

文章作者:一名在一线搬砖的前端开发者 🛠️

评论 0

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