Web Components跨框架组件库探索

35 阅读13分钟

1. 前言

在网约车业务早期阶段,产品需求迭代迅速,为了支持快速试错与灵活交付, 内部形成了多种技术栈并存的局面:历史项目基于 Vue2,新业务则转向 React。同时,由于早期各项目独立推进,尚未形成统一的设计规范和组件标准,不同项目在组件实现方式、样式规范与交互体验上存在较大差异。

这种多样化在短期内带来了灵活性,使团队能够快速响应业务需求,但随着项目规模和业务复杂度的增加,也逐渐演变成了技术挑战:

  • 组件复用困难:相同功能组件需要在不同框架中重复实现。
  • 维护成本增加:功能或样式的调整须在多套组件库中分别修改。
  • 用户体验不一致:不同框架实现可能导致交互和视觉风格不统一。

为解决这些问题,我们移动端前端团队今年开始探索一种能够“一次开发,多处复用”的组件库方案。

2. 目标与场景

2.1. 核心目标

为了解决团队多框架并存、组件重复开发和体验不一致的痛点,我们确定了三大核心目标:

  • 统一设计规范:建立标准化设计体系和组件规范,确保视觉风格与交互行为在各业务线、各技术栈中保持一致。
  • 跨框架复用:构建框架无关的组件实现层,使同一组件可在 Vue、React、小程序等技术栈中复用。
  • 提升交付一致性: 减少风格偏差,降低多端维护成本和迭代风险,快速响应业务需求。

2.2. 应用场景

组件库主要面向以下场景:

  • 多技术栈并存: 组件库提供统一的组件标准和实现,降低重复开发。
  • 多终端 / 多容器运行:支持 App WebView、小程序、移动浏览器等环境,保持一致体验。
  • 多租户与模块化业务:可快速适配多租户 SaaS、主题定制及模块化功能,实现灵活复用。

2.3. 现状分析与开发优先级

在启动统一组件库建设前,我们对现有项目使用的组件库进行了调研,发现共有 10 个独立组件库分布在不同业务线和技术栈中,但高频组件主要集中在少数几类,如 Button、Exception、Loading 等。

基于这些数据,我们制定了“以高频、通用组件优先建设”的策略,为快速落地和跨业务复用提供依据。

高频组件统计(示例)

组件使用频次技术栈分布开发优先级
Exception138Vue、React
Toast123Vue、React
Button82Vue、React
Loading62Vue、React
Popup57Vue、React

注:实际组件库包含 20+ 组件,这里仅展示高频组件作为示例。

3. 业界方案调研与对比

为实现跨框架组件复用与统一设计规范,我们调研了多种业界主流方案,包括多套组件库维护、单一框架统一、F/A 分层设计及 Web Components 标准方案。

方案实现方式优点缺点代表方案
多套组件库针对不同框架分别维护简单快速上手成本高、复用性差-
单一框架统一全量迁移至单一框架(如 React)规范统一复用性好迁移成本、风险高Vant/Ant Design
F/A分层设计将组件拆分为基础逻辑层(Foundation)和框架适配层(Adapter)可复用核心逻辑、支持多技术栈扩展抽象复杂,学习门槛高Semi Design
Web Components基于浏览器原生标准实现一次开发、多框架复用工具链与兼容性需处理Shoelace/FAST/Taro/QuarkD

综合对比可见,多套组件库方案维护成本高;单一框架方案迁移成本与风险较大;F/A 分层设计抽象度高、开发复杂。而 Web Components 方案在复用性、标准化及跨框架兼容性上最具长期价值,已被多个成熟组件库(Shoelace、FAST、Quarkd、Taro)验证可行。因此,我们选择以 Web Components 为底层技术路径,结合工具链(如 StencilJS)实现跨框架适配与一致的设计规范落地。

4. 组件库架构与设计规范

在明确目标与应用场景后,我们基于 Web Components 构建了跨框架组件库。整体过程可以沿着组件库架构图的层级顺序理解,从底层的设计规范到组件实现,再到框架适配,最后落地到各业务线支持的技术平台。

4.1. 组件库架构概览

4.2. 设计规范统一

在组件开发前,我们首先与 UI 同学协作,共同梳理了统一的设计规范,并基于 Design Tokens 统一了颜色、字体、间距、圆角、阴影等基础变量;同时也定义了组件的样式与交互规范,以保证不同业务线组件的视觉与行为一致。

  1. Token设计和实现

在工程实现上,我们采用双层 Token 架构,同时支持 Sass 编译期 TokenCSS Variables 运行时 Token,编译期 Token 负责系统通用变量的静态管理和派生计算,运行时 Token 则支持多品牌定制与主题动态切换,以兼顾体系化管理与动态扩展的灵活性。

例如,以下示例定义了一组中性色阶,用于统一全局灰度体系:

// 透明黑衍生的中性灰(从浅到深)
$grays: (
  100: rgba(0, 0, 0, 0.03),
  500: rgba(0, 0, 0, 0.2),
  ...
  900: rgba(0, 0, 0, 0.9),
);

// 便捷函数
@function gray($level) {
  @return map-get($grays, $level);
}

这些系统通用 Token (如间距、圆角、阴影等)通常不会被业务直接修改,但品牌色或主题相关的变量是支持覆盖的。例如 $brand-color 或 CSS Variable --brand-color 可以在不同品牌主题文件中自定义,并通过函数式工具,快速生成不同状态的颜色,实现差异化:

$brand-color: #409EFF;

// 透明度
@function brand-alpha($opacity) {
  @return rgba($brand-color, $opacity);
}

// 亮度调整
@function brand-lightness($delta) {
  @return adjust-color($brand-color, $lightness: $delta);
}

// 饱和度调整
@function brand-saturation($delta) {
  @return adjust-color($brand-color, $saturation: $delta);
}

// 色相调整
@function brand-hue($delta) {
  @return adjust-color($brand-color, $hue: $delta);
}

在构建阶段,我们会将 Sass Token 输出为 CSS Variables,确保运行时可覆盖与动态切换:

:root {
  --color-brand: #{$brand-color};
  --color-brand-text: #{brand-lightness(-30)};
  --gray-100: #{gray(100)};
  --gray-500: #{gray(500)};
  --gray-900: #{gray(900)};
}

通过这种方式,我们实现了 编译期 Token 的结构化管理 + 运行时变量覆盖 的双重能力,既保证了系统通用 Token 的一致性,又允许针对品牌和业务场景进行灵活扩展,同时兼顾了 Web Components Shadow DOM 的样式隔离特性,使组件在多框架、多终端下均能继承统一的主题。

  1. 组件设计示例

  1. Icon 设计示例

为提升图标的可维护性和跨终端一致性,我们将图标统一为 SVG 字体进行管理。

4.3. 组件实现

在组件实现的技术选型上,我们选择 Stencil.js 作为核心开发框架。它能够在保留 Web Components 原生特性的同时,提供类型推导、属性声明、状态管理等工程化能力,极大简化了组件开发与跨框架适配的复杂度。关于Stencil 本身的原理与生态网上资料已经较为丰富,这里就不再展开,更多可以参考官方文档或社区教程。在我们的实践中,Stencil 的关键价值主要体现在:组件的跨框架输出、类型一致性和样式隔离 三个方面。

接下来,我们以 Button 组件为例,展示具体实现思路与核心设计特性

import { Component, Host, h, Prop, Listen } from '@stencil/core';
import { pxToVw } from '../../utils/utils';

@Component({
  tag: 'blm-button',
  styleUrl: './blm-button.css',
  shadow: true,
})
export class BlmButton {
  /** 按钮类型 */
  @Prop() type: 'primary' | 'success' | 'danger' | 'warning' = 'primary';
  
  /** 按钮尺寸 */
  @Prop() size: 'small' | 'normal' | 'big' | 'large' = 'normal';

  /** 按钮图标 */
  @Prop() icon?: string;

  /** 按钮形状 */
  @Prop() shape: 'round' | 'square' = 'round';

  /** 禁用状态 */
  @Prop() disabled = false;

  /** 加载状态 */
  @Prop() loading = false;

  /** 加载类型 */
  @Prop() loadtype: 'circular' | 'spinner' = 'spinner';

  /** 加载颜色 */
  @Prop() loadingcolor: string = 'currentColor';

  /** 加载大小 */
  @Prop() loadingSize: number = 20;

  renderIcon = () => {
    if (this.icon) {
      return <blm-icon name={this.icon} class="blm-button-icon" />;
    }
    if (this.loading) {
      return (
        <blm-loading
          class="blm-button-loading"
          color={this.loadingcolor}
          size={pxToVw(this.loadingSize)}
          type={this.loadtype}
        />
      );
    }
    return null;
  };

  @Listen("click", { capture: true })
  onClick(e: Event) {
    if (this.disabled || this.loading) {
      e.stopPropagation();
    }
  }

  render() {
    const { type, size, shape, disabled } = this;
    return (
      <Host
        class="blm-button"
        type={type}
        size={size}
        shape={shape}
        disabled={disabled}
      >
        {this.renderIcon()}
        <slot></slot>
      </Host>
    );
  }
}

:host {
  position: relative;
  display: inline-block;
  box-sizing: border-box;
  line-height: var(--button-height, 24px);
  text-align: center;
  border-radius: var(--button-border-radius, 8px);
  padding-left: var(--button-hspacing, 12px);
  padding-right: var(--button-hspacing, 12px);
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

:host([type="primary"]) {
  background-color: var(--color-brand);

  &:disabled {
    background-color: var(--gray-500);
    cursor: not-allowed;
  }
}

在这段代码中,我们通过 Prop 默认值和渲染逻辑处理按钮状态(如加载、禁用、图标),确保组件行为一致。通过 Shadow DOM 样式被隔离,同时结合 Design Token 管理颜色、尺寸等变量,实现跨业务、跨框架一致的视觉体验。框架适配在核心组件开发完成后,我们进入跨框架适配阶段。此阶段主要依赖 Stencil 的多框架输出能力,确保业务同学能够在熟悉的框架中直接使用组件,同时保持组件逻辑和样式的一致性。为此,我们使用了诸如 @stencil/react-output-target@stencil/vue-output-target 和 stencil-vue2-output-target 等插件,分别生成 React、Vue3、Vue2 版本的组件库。

// stencil.config.ts
import { Config } from '@stencil/core';
import { vueOutputTarget } from "@stencil/vue-output-target";
import { vueOutputTarget as vue2OutputTarget } from "stencil-vue2-output-target";
import { reactOutputTarget } from "@stencil/react-output-target";

export const config: Config = {
  ..., // 省略其他配置
  outputTargets: [
    {
      type: "dist-custom-elements",
      ...
    },
    // React 输出
    reactOutputTarget({
      componentCorePackage: '@leopard-h5/components',
      proxiesFile: '../react/src/components.ts',
      ...
    }),
    // Vue3 输出
    vueOutputTarget({ ... }),
    // Vue2 输出
    vue2OutputTarget({ ... }),
  ],
};

5. 框架适配与问题解决

到目前为止,一切都很顺利:我们使用 Stencil 编写出标准的 Web Components,并成功在 Vue 和 React中集成使用。理论上,这样的组件可以在框架中直接使用,但实际使用过程中,我们发现一些细节问题如:组件类名无法动态更新、事件绑定存在跨框架差异,以及低版本浏览器兼容等问题。

5.1. 宿主类名覆盖导致组件“消失”问题

在 Stencil 中,我们通常使用 Host 组件为宿主元素(host element)添加默认类名,例如:

import { Component, Host, h } from '@stencil/core'

@Component({
  tag: 'blm-button',
})
export class BlmButton {
  render() {
    return (
      <Host class="blm-button">
        <slot></slot>
      </Host>
    )
  }
}

渲染后,宿主元素 <blm-button> 会包含两个类名:

<blm-button class="blm-button hydrated">...</blm-button>

其中hydrated为 Stencil 初始化完成后自动添加,用于控制可见性(加载前为 visibility: hidden

在业务层中,当我们给组件动态绑定类名,会发现组件在运行时突然“变白”或“消失”。例如:

// React
<BlmButton className={dynamicCls}>Click</BlmButton> 

// Vue
<blm-button :class="dynamicCls">Click</blm-button>   

造成这种现象的原因是因为Stencil 的 hydrated 类名在组件可见性中起关键作用,而 React 与 Vue 在更新类名时会整体替换宿主元素的 class 属性,从而移除了 hydrated 和原始的 blm-button,导致组件保持隐藏状态。视觉上表现为组件“消失”或“变白”,但实际上 DOM 仍然存在,只是不可见。

解决此问题的核心思路是:动态类名更新时保留内置类名,可从两种方向解决:

  • 框架层适配:在 React 中通过高阶组件 + ref 合并类名;在 Vue 中通过自定义包装组件或渲染函数,确保每次更新时保留 hydrated 与原始类。
  • 组件层适配:提供专用属性(如 cssClass),由组件内部合并到宿主元素上,从源头避免覆盖。

本质上,这是框架与 Web Components 在类名、样式、事件等宿主绑定策略上的差异问题。提前在封装层处理这些差异,可显著提升跨框架稳定性。

5.2. 事件捕获与跨框架一致性问题

在 Stencil 中,我们通常通过 @Listen('click') 来监听点击事件,例如:

@Listen('click')
onClick(e: Event) {
  if (this.disabled || this.loading) {
    e.stopPropagation();
  }
}

// Vue
<blm-button disabled @click="handleClick">默认按钮</blm-button>

// React
<BlmButton disabled onClick={handleClick}>默认按钮</BlmButton>

在实际使用中,我们发现行为在不同框架下存在差异:

在 Vue 中,即便按钮设置了 disabled,点击事件仍然会触发绑定的 handleClick;而在 React 中,则不会触发。

这种差异的原因在于 React 使用了合成事件系统,默认在冒泡阶段会禁用 disabled 元素的事件,而 Vue 监听的是原生事件,冒泡阶段仍会触发 click

为了解决这个问题,可以将事件监听放在捕获阶段:

@Listen('click', { capture: true })

这样事件会先被拦截,从而保证在 Vue 和 React 中行为一致。这也反映出了不同框架在事件捕获和阻止机制上的差异,需要在组件封装层进行跨框架的适配。

5.3. 低版本浏览器兼容与 Polyfill 策略

在我们的业务场景中,需要兼容到部分仍在使用 Android 5 和 iOS 10的用户,必须支持较老版本(如Chrome50)的 WebView。然而 Stencil 从 v3.0.0 开始,已不再支持 IE 11、Edge ≤ 18 和 Safari 10。虽然 v3.0.0 仍可通过一些配置和 polyfill 继续支持这些低版本,但Stencil 最新版本(4.X)中这些功能已经被移除。因此,在实践中,我们最多只能使用 Stencil v3 进行开发。

我们可以使用 Stencil 2 和 3,配置 Stencil 构建支持 ES5 并开启必要的 extras,并引入 Web Components polyfill,保证自定义元素、Shadow DOM、CSS 变量、事件等特性在 Android 5 等低版本浏览器中能正常工作,从而避免组件渲染失败或报错。

{
  buildEs5: true,             // 为低版本浏览器兼容引入的配置
  extras: {
    cssVarsShim: true,        // 支持 CSS 变量
    dynamicImportShim: true,  // 支持动态 import
    shadowDomShim: true,      // 支持 Shadow DOM
    safari10: true,            // 修复 Safari 10 特性问题
    scriptDataOpts: true,      // 兼容低版本浏览器 script.dataset
    appendChildSlotFix: false, // 可选修复 slot appendChild 问题
    cloneNodeFix: false,       // 可选修复 cloneNode 问题
    slotChildNodesFix: true,   // 修复 slot.childNodes 在低版本浏览器获取异常
  },
}

5.4. 其他常见集成问题与注意事项

在做框架集成过程中还存在以下典型问题需要关注:

  1. 事件监听问题:React 无法直接监听到 Web Components 发出的自定义事件,需通过 addEventListener手动绑定。
  2. Ref 引用问题:不同框架对 Web Components 的 ref 获取方式不同(如 React 需用 forwardRef,Vue 需用 ref+ $el)。
  3. 复杂 Props 处理:传递对象或数组时,框架的响应式系统可能导致数据格式不一致,需手动序列化/反序列化。

实践证明,跨框架集成并不存在“一步到位”的方案,它更像是一场持续的打磨——需要依靠封装与适配把分散的坑一点点补平,才能实现一致、可维护的使用体验。

6. 未来展望

通过本次探索,我们验证了 Web Components 在多技术栈、多终端环境下的可行性和稳定性。借助封装与适配层,我们解决了框架差异、事件一致性以及样式隔离等关键问题,实现了可维护、可复用的组件体系。

未来,随着业务场景和组件数量的扩展,这套体系仍有优化空间:持续完善封装与适配策略,可以进一步降低跨框架使用门槛,使组件在不同技术栈间的行为更加一致。同时,团队可以积累更多最佳探索和使用经验,为多端开发和跨团队协作提供更稳固的技术支撑。