1. 前言
在网约车业务早期阶段,产品需求迭代迅速,为了支持快速试错与灵活交付, 内部形成了多种技术栈并存的局面:历史项目基于 Vue2,新业务则转向 React。同时,由于早期各项目独立推进,尚未形成统一的设计规范和组件标准,不同项目在组件实现方式、样式规范与交互体验上存在较大差异。
这种多样化在短期内带来了灵活性,使团队能够快速响应业务需求,但随着项目规模和业务复杂度的增加,也逐渐演变成了技术挑战:
- 组件复用困难:相同功能组件需要在不同框架中重复实现。
- 维护成本增加:功能或样式的调整须在多套组件库中分别修改。
- 用户体验不一致:不同框架实现可能导致交互和视觉风格不统一。
为解决这些问题,我们移动端前端团队今年开始探索一种能够“一次开发,多处复用”的组件库方案。
2. 目标与场景
2.1. 核心目标
为了解决团队多框架并存、组件重复开发和体验不一致的痛点,我们确定了三大核心目标:
- 统一设计规范:建立标准化设计体系和组件规范,确保视觉风格与交互行为在各业务线、各技术栈中保持一致。
- 跨框架复用:构建框架无关的组件实现层,使同一组件可在 Vue、React、小程序等技术栈中复用。
- 提升交付一致性: 减少风格偏差,降低多端维护成本和迭代风险,快速响应业务需求。
2.2. 应用场景
组件库主要面向以下场景:
- 多技术栈并存: 组件库提供统一的组件标准和实现,降低重复开发。
- 多终端 / 多容器运行:支持 App WebView、小程序、移动浏览器等环境,保持一致体验。
- 多租户与模块化业务:可快速适配多租户 SaaS、主题定制及模块化功能,实现灵活复用。
2.3. 现状分析与开发优先级
在启动统一组件库建设前,我们对现有项目使用的组件库进行了调研,发现共有 10 个独立组件库分布在不同业务线和技术栈中,但高频组件主要集中在少数几类,如 Button、Exception、Loading 等。
基于这些数据,我们制定了“以高频、通用组件优先建设”的策略,为快速落地和跨业务复用提供依据。
高频组件统计(示例)
| 组件 | 使用频次 | 技术栈分布 | 开发优先级 |
|---|---|---|---|
| Exception | 138 | Vue、React | 高 |
| Toast | 123 | Vue、React | 高 |
| Button | 82 | Vue、React | 高 |
| Loading | 62 | Vue、React | 高 |
| Popup | 57 | Vue、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 统一了颜色、字体、间距、圆角、阴影等基础变量;同时也定义了组件的样式与交互规范,以保证不同业务线组件的视觉与行为一致。
- Token设计和实现
在工程实现上,我们采用双层 Token 架构,同时支持 Sass 编译期 Token 与 CSS 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 的样式隔离特性,使组件在多框架、多终端下均能继承统一的主题。
- 组件设计示例
- 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. 其他常见集成问题与注意事项
在做框架集成过程中还存在以下典型问题需要关注:
- 事件监听问题:React 无法直接监听到 Web Components 发出的自定义事件,需通过
addEventListener手动绑定。 - Ref 引用问题:不同框架对 Web Components 的 ref 获取方式不同(如 React 需用
forwardRef,Vue 需用ref+$el)。 - 复杂 Props 处理:传递对象或数组时,框架的响应式系统可能导致数据格式不一致,需手动序列化/反序列化。
实践证明,跨框架集成并不存在“一步到位”的方案,它更像是一场持续的打磨——需要依靠封装与适配把分散的坑一点点补平,才能实现一致、可维护的使用体验。
6. 未来展望
通过本次探索,我们验证了 Web Components 在多技术栈、多终端环境下的可行性和稳定性。借助封装与适配层,我们解决了框架差异、事件一致性以及样式隔离等关键问题,实现了可维护、可复用的组件体系。
未来,随着业务场景和组件数量的扩展,这套体系仍有优化空间:持续完善封装与适配策略,可以进一步降低跨框架使用门槛,使组件在不同技术栈间的行为更加一致。同时,团队可以积累更多最佳探索和使用经验,为多端开发和跨团队协作提供更稳固的技术支撑。