小程序设计系统与组件库工程化

系统讲解小程序设计系统与组件库工程化:Design Token 体系、组件库目录结构与分层、npm 包发布与 miniprogram_npm 构建、按需注入与 usingComponents、主题与暗黑模式、文档站建设、多端一致性,以及版本管理与 breaking change 策略。

一、Design Token 体系设计

1.1 为什么先做 Token 再做组件

组件库最常见的失败模式是「组件写完了,样式改不动」:颜色硬编码在每个 wxss 里,品牌换色要改几百个文件。所以工程化的顺序必须是 Token 先行、组件后置。Design Token 是设计决策的最小可命名单元,把颜色、字号、间距、圆角、阴影、动效时长从组件实现里抽出来,成为一层可被替换的变量。

Token 类别示例变更频率
基础色板--blue-500: #1989fa极低
语义色 / 文本色--color-primary、--text-secondary低
间距 / 字号--space-4、--font-size-md极低
圆角 / 阴影--radius-md、--shadow-card低
动效--duration-fast: 150ms中

1.2 三层命名结构

推荐「基础层、语义层、组件层」三层结构,组件只允许引用语义层与组件层,禁止直接引用基础色板:

/* tokens/base.wxss 基础层:只描述颜色本身 */
page {
  --blue-500: #1989fa;
  --blue-600: #0570db;
  --red-500: #ee0a24;
  --gray-100: #f7f8fa;
  --gray-300: #ebedf0;
  --gray-600: #969799;
  --gray-900: #323233;
}

/* tokens/semantic.wxss 语义层:描述用途 */
page {
  --color-primary: var(--blue-500);
  --color-primary-active: var(--blue-600);
  --color-danger: var(--red-500);
  --color-bg-page: var(--gray-100);
  --color-border: var(--gray-300);
  --text-primary: var(--gray-900);
  --text-secondary: var(--gray-600);
}

/* tokens/component.wxss 组件层:描述具体组件 */
page {
  --button-height-md: 88rpx;
  --button-radius: var(--radius-md);
}

组件内只允许写 color: var(--text-primary) 这类引用,一旦出现 #323233 就应该在 CI 检查里报错。

1.3 Token 的工程化产出

Token 不应该手写多份,而应该由单一数据源生成多份产物:

design-tokens.json   单一数据源(设计与研发共同维护)
      │  build 脚本
      ├──> tokens.wxss   小程序使用
      ├──> tokens.scss   Web 端 / Taro 使用
      ├──> tokens.js     运行时常量(如 canvas 绘制)
      └──> tokens.d.ts   TypeScript 类型提示
// design-tokens.json 片段
{
  "color": {
    "blue": { "500": { "value": "#1989fa" } },
    "red": { "500": { "value": "#ee0a24" } }
  },
  "space": {
    "1": { "value": "8rpx" },
    "2": { "value": "16rpx" },
    "4": { "value": "32rpx" }
  }
}

构建脚本的核心逻辑只有三步:递归展平 design-tokens.json 得到 "blue-500": "#1989fa" 这样的扁平映射,拼成 page { --blue-500: #1989fa; } 形式的 wxss 字符串,再写入 src/styles/tokens.wxss。整个过程在 CI 里跑,产物不入库。

二、组件库目录结构与分层

2.1 分层原则

组件库不是一堆组件的平铺,而是按「依赖方向单向」分层:

packages/
├── tokens/     第 0 层:设计变量,无依赖
├── icons/      第 1 层:图标字体 / SVG 组件
├── base/       第 2 层:button / icon / cell / divider
├── form/       第 3 层:input / checkbox / picker / uploader
├── feedback/   第 3 层:toast / dialog / action-sheet
├── display/    第 3 层:card / tag / badge / steps
└── business/   第 4 层:order-card / address-picker

依赖规则:上层可以依赖下层,下层永远不能依赖上层。业务组件放独立包,避免污染通用库。

2.2 单个组件的目录结构

每个组件目录保持固定形状,工具链才能自动化:index.js(逻辑)、index.json(usingComponents 声明)、index.wxml(模板)、index.wxss(只引用 token 与自身变量)、index.d.ts(类型声明,可选)、README.md(文档)与 __tests__/index.test.js(单测)。

// button/index.js
Component({
  options: { multipleSlots: true, styleIsolation: 'apply-shared' },
  properties: {
    type: { type: String, value: 'default' },   // default | primary | danger
    size: { type: String, value: 'medium' },    // small | medium | large
    block: { type: Boolean, value: false },
    disabled: { type: Boolean, value: false },
    loading: { type: Boolean, value: false }
  },
  methods: {
    onTap(e) {
      if (this.data.disabled || this.data.loading) return;
      this.triggerEvent('click', { detail: e.detail });
    }
  }
});
<view
  class="ui-button ui-button--{{type}} ui-button--{{size}} {{block ? 'ui-button--block' : ''}} {{disabled ? 'is-disabled' : ''}}"
  hover-class="{{disabled ? '' : 'ui-button--hover'}}"
  hover-stay-time="60"
  bindtap="onTap"
>
  <ui-loading wx:if="{{loading}}" size="32rpx" color="currentColor" />
  <slot wx:else />
</view>
.ui-button {
  display: flex;
  align-items: center;
  justify-content: center;
  height: var(--button-height-md);
  padding: 0 var(--space-4);
  border-radius: var(--button-radius);
  font-size: var(--font-size-md);
  line-height: 1;
}

.ui-button--primary { background-color: var(--color-primary); color: #fff; }
.ui-button--block { width: 100%; }
.ui-button.is-disabled { opacity: 0.5; }

2.3 组件通信规范

父传子用 properties(类型与默认值必须声明),子传父用 triggerEvent('click', detail)(事件名小写无前缀),跨层传递用 relations 或 provide/inject,全局配置走 Behavior 或全局 store。完整用法可对照小程序组件化架构 ,组件库应当只暴露这两套机制,不引入私有通信方式。

三、npm 包发布与 miniprogram_npm 构建

3.1 发布到 npm

小程序支持从 npm 安装依赖,但要求包内是未编译的源码或已构建的小程序产物,不能用 Webpack 打成 bundle。

{
  "name": "@yourorg/miniprogram-ui",
  "version": "2.3.0",
  "miniprogram": "dist",
  "files": ["dist", "README.md"]
}

miniprogram 字段指向构建产物目录,开发者工具执行「构建 npm」时会读取该字段。产物目录里每个组件保持 index.js / index.json / index.wxml / index.wxss 四件套,发布前用 npm pack --dry-run 确认文件齐全。

# 发布流程
npm run build          # 编译 src -> dist
npm version patch      # 或 minor / major
npm publish --access public

3.2 项目侧构建 npm

// 项目 package.json
{
  "dependencies": {
    "@yourorg/miniprogram-ui": "^2.3.0"
  }
}
{
  "setting": {
    "packNpmManually": true,
    "packNpmRelationList": [
      { "packageJsonPath": "./package.json", "miniprogramNpmDistDir": "./miniprogram/" }
    ]
  }
}

操作步骤是:项目根目录执行 npm install,在开发者工具里点击「工具 -> 构建 npm」,生成 miniprogram_npm/@yourorg/miniprogram-ui/ 目录后即可在 usingComponents 中引用。

当项目使用分包时,packNpmManually 必须为 true,并为每个分包声明依赖关系,否则构建出的 miniprogram_npm 会全部落在主包,导致主包体积超标。

3.3 构建产物的坑

坑现象规避
引入了 node_modules 里的第三方包构建报错找不到模块组件库零运行时依赖,或把依赖打进 dist
用了 ES Module 的 import低版本基础库报错产物统一 CommonJS,用 require
wxss 里 @import 了包外文件构建后路径失效只 @import dist 内相对路径
版本号用了 ^线上版本漂移组件库锁精确版本,用 lock 文件固化

四、按需注入与 usingComponents

4.1 按需注入

从基础库 2.11.1 起支持 lazyCodeLoading,能显著降低启动耗时:

// app.json
{
  "lazyCodeLoading": "requiredComponents"
}

开启后小程序只注入当前页面真正用到的自定义组件代码。这对组件库尤其重要:包含 60 个组件的库如果全量注入,启动时会白白执行几十个组件文件的顶层代码。

4.2 声明方式

页面与组件都必须显式声明 usingComponents,路径指向构建后的 miniprogram_npm:

{
  "usingComponents": {
    "ui-button": "@yourorg/miniprogram-ui/button/index",
    "ui-cell": "@yourorg/miniprogram-ui/cell/index"
  }
}
风格写法特点
全路径@yourorg/miniprogram-ui/button/index可静态分析、按需注入友好
聚合入口@yourorg/miniprogram-ui写法短,但可能引入整包,不推荐

4.3 全局注册与体积权衡

app.json 的 usingComponents 是全局注册,任何页面都能用,但会让所有页面都注入这些组件:

// app.json —— 只放真正高频的基础组件
{
  "usingComponents": {
    "ui-icon": "@yourorg/miniprogram-ui/icon/index",
    "ui-button": "@yourorg/miniprogram-ui/button/index"
  }
}

原则:全局只放 3 到 5 个最高频的基础组件,其余全部页面级声明。取舍要结合小程序性能优化 中的主包瘦身手段做判断。

五、主题与暗黑模式

5.1 用 CSS 变量实现主题切换

因为 Token 全部走 CSS 变量,主题切换只需覆盖变量值:

/* themes/light.wxss */
page, .theme-light {
  --color-bg-page: #f7f8fa;
  --color-bg-card: #ffffff;
  --text-primary: #323233;
  --color-border: #ebedf0;
}

/* themes/dark.wxss */
page.theme-dark, .theme-dark {
  --color-bg-page: #1c1c1e;
  --color-bg-card: #2c2c2e;
  --text-primary: #f2f2f7;
  --color-border: #3a3a3c;
}

组件内部只写 background: var(--color-bg-card),不写任何具体颜色,暗黑模式就自动生效。

5.2 跟随系统

// app.js
App({
  globalData: { theme: 'light' },
  onLaunch() {
    this.globalData.theme = wx.getAppBaseInfo().theme || 'light';
    this.applyTheme(this.globalData.theme);
    wx.onThemeChange((res) => {
      this.globalData.theme = res.theme;
      this.applyTheme(res.theme);
    });
  },
  applyTheme(theme) {
    getCurrentPages().forEach((page) => {
      if (typeof page.applyTheme === 'function') page.applyTheme(theme);
    });
  }
});
// app.json 声明支持暗黑模式
{
  "darkmode": true,
  "themeLocation": "theme.json"
}
// theme.json:让导航栏、tabBar 等原生 UI 跟随主题
{
  "light": {
    "navBgColor": "#ffffff", "navTxtStyle": "black", "bgColor": "#f7f8fa",
    "tabbarColor": "#7a7e83", "tabbarSelectedColor": "#1989fa", "tabbarBgColor": "#ffffff"
  },
  "dark": {
    "navBgColor": "#1c1c1e", "navTxtStyle": "white", "bgColor": "#1c1c1e",
    "tabbarColor": "#8e8e93", "tabbarSelectedColor": "#1989fa", "tabbarBgColor": "#2c2c2e"
  }
}

5.3 主题实现的三条铁律

组件内禁止硬编码颜色(包括 #fff、rgba(0,0,0,0.5)),全部走语义变量;图片资源准备两套,或改用 mask 加背景色方案让图标跟随主题色;主题切换后要验证 canvas 绘制,因为 wx.createCanvasContext 读不到 CSS 变量,需要用 tokens.js 里的运行时常量。

六、文档站与多端一致性

6.1 组件文档站

组件库没有文档就等于没有组件库。最小可用方案是「README 即文档 + 自动聚合」:

文档站结构为 docs/index.md(概览与安装)、docs/token.md(Token 清单,脚本生成)、docs/components/(由 packages/*/README.md 聚合)与 docs/changelog.md。每个组件的 README 遵循固定骨架(何时使用、代码演示、API 表、事件表),便于脚本解析。CI 里再检查「组件目录数等于文档文件数」,防止新增组件忘写文档。

6.2 多端一致性

同一套设计系统往往要落到小程序、H5、甚至 App,一致性靠三件事保障:Token 走单一数据源构建出 wxss / scss / js 三份产物;组件 API 的属性名、事件名、默认值三端对齐,禁止各端私自改名;关键组件维护视觉基准图,改动后逐端截图比对。

如果项目本身是多端框架,可以参考小程序多端框架对比 中关于样式隔离与组件适配的结论,再决定「一套组件三端复用」还是「按端维护薄封装层」。

七、版本管理与 breaking change 策略

7.1 语义化版本

组件库必须严格执行 SemVer,并明确「什么算 breaking」:

变更类型版本位例子
修复 bug、样式微调patch修复 button 加载态未禁用点击
新增组件、新增可选属性minor新增 size="large"
删属性、改类型、改事件名、改默认值、Token 改名majorsize 默认值由 large 改为 medium

改默认值也算 breaking,这是最容易被忽略的一条:业务方依赖了旧默认值,升级后视觉就会变。

7.2 废弃流程

不要直接删 API,走三步废弃:

// 第一步:保留旧属性,打印警告,内部映射到新属性
Component({
  properties: {
    type: { type: String, value: '' },
    kind: { type: String, value: 'default' }  // 新属性
  },
  observers: {
    type(val) {
      if (val) {
        console.warn('[ui-button] 属性 type 已废弃,请改用 kind');
        this.setData({ kind: val });
      }
    }
  }
});

第二步:文档与 CHANGELOG 标注 deprecated,给出替换示例与计划移除版本。第三步:跨一个 major 版本后移除,并在 CHANGELOG 的 Breaking Changes 段落写明。

7.3 发布与升级流程

# 组件库侧
npm run lint && npm run test && npm run build
npm version minor -m "feat(button): 新增 large 尺寸"
npm publish --access public

# 业务侧:升级 -> 开发者工具「构建 npm」-> 视觉基准图比对与关键页面走查

CHANGELOG 建议按 Added / Changed / Fixed / Deprecated / Breaking Changes 五段组织,并强制要求 major 版本必须写迁移指引。业务项目里再配一条 CI 检查:如果组件库版本跨了 major,必须人工确认后才能合入。

八、总结

小程序设计系统的工程化,本质是把「设计决策」变成「可构建、可发布、可回归的代码资产」:Token 是单一数据源,构建出多端产物;组件按依赖方向分层,每个组件目录形状固定;通过 npm 的 miniprogram 字段发布,业务侧用「构建 npm」产出 miniprogram_npm;用 lazyCodeLoading: requiredComponents 与页面级 usingComponents 控制注入体积;主题靠 CSS 变量与 theme.json 实现,组件内零硬编码颜色。

最容易被低估的是版本管理:删属性、改默认值、改 Token 名都是 breaking change,必须走「保留旧 API 打印警告、文档标注 deprecated、跨 major 再移除」的流程,否则每次升级都会变成一次全量视觉回归。把 Token 生成、文档聚合、变更检查这三条放进 CI,设计系统才能持续演进而不是逐渐腐化。组件通信与跨层状态的具体取舍可对照小程序组件化架构 与小程序状态管理 ,而组件体积与首屏耗时的关系则要在小程序性能优化 的框架下统一衡量。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniprogram」更多文章

  1. 小程序第三方 SDK 集成与治理
  2. 小程序架构演进与遗留重构
  3. 小程序无障碍与适老化改造