工具线做到中期,一个绕不开的问题冒出来了:13 个工具的 UI 组件得统一。
理财计算工具里的金额输入框、退休规划工具里的年份选择器、税务工具里的税率展示卡片……这些东西如果每个工具各写一套,视觉不一致是小事,行为不一致才是大事。金融场景里,一个金额输入框的格式化逻辑必须 13 个工具完全一致。
所以我们做了一个内部组件库,10+ 个组件,一套源码,同时输出两种产物:UMD 包和 npm 包。这篇讲怎么做到的。
为什么是两种产物
先说清楚为什么不能只选一种。
只有 npm 包不行——因为有几个工具是早期遗留项目,构建配置改不动,没法直接 import。它们需要的是往 HTML 里丢一个 <script> 标签就能用的 UMD 产物。
只有 UMD 不行——因为新工具走的是 monorepo 里的 ES 模块体系,UMD 的 tree-shaking 效果差,包体积会膨胀。
一套源码,两种产物,看起来是”都要”,其实是被现实逼的。
组件库的目录结构
组件库本身也是 monorepo 里的一个 package:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| packages/ui-components/ ├── src/ │ ├── components/ │ │ ├── AmountInput/ # 金额输入(金融专用) │ │ ├── YearPicker/ # 年份选择 │ │ ├── RateCard/ # 比率展示卡片 │ │ ├── ResultTable/ # 结果表格 │ │ └── ... │ ├── styles/ │ │ ├── tokens.scss # 设计 token(颜色、间距) │ │ └── theme.scss # 主题变量 │ ├── utils/ │ │ └── format.ts # 格式化工具(金额、日期等) │ └── index.ts # 统一出口 ├── vite.config.ts # 构建配置(关键!) ├── package.json └── tsconfig.json
|
每个组件一个目录,内含 index.vue(组件实现)和 index.test.ts(单元测试)。这种结构方便单独导出——后面讲。
Vite Library Mode:一次构建两种产物
配置重心在 vite.config.ts。Vite 的 library mode 支持同时输出多种格式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import dts from 'vite-plugin-dts'; import { resolve } from 'path';
export default defineConfig({ plugins: [ vue(), dts({ insertTypesEntry: true }), ], build: { lib: { entry: resolve(__dirname, 'src/index.ts'), name: 'SuiteUI', formats: ['es', 'umd'], fileName: (format) => `suite-ui.${format}.js`, }, rollupOptions: { external: ['vue'], output: { globals: { vue: 'Vue' }, assetFileNames: 'suite-ui.[ext]', }, }, }, });
|
关键是 formats: ['es', 'umd'] 这一行——一次构建,同时生成:
suite-ui.es.js:给 monorepo 内部工具 import 的 ES 模块
suite-ui.umd.js:给遗留项目 <script> 标签引入的 UMD 包
suite-ui.css:样式文件(UMD 模式必须单独导出 CSS)
package.json 的 exports 字段
package.json 里用 exports 字段让两种消费方式自动选对产物:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| { "name": "@suite/ui-components", "version": "1.4.0", "main": "./dist/suite-ui.umd.js", "module": "./dist/suite-ui.es.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/suite-ui.es.js", "require": "./dist/suite-ui.umd.js", "types": "./dist/index.d.ts" }, "./styles.css": "./dist/suite-ui.css", "./package.json": "./package.json" }, "sideEffects": ["*.css", "*.scss"], "files": ["dist/"] }
|
sideEffects 这个字段必须配,否则消费者的 tree-shaking 会把 CSS 当副作用代码全部删掉。
monorepo 里的工具这样引用:
1 2 3 4 5
| { "dependencies": { "@suite/ui-components": "workspace:*" } }
|
1 2 3
| import { AmountInput, RateCard } from '@suite/ui-components'; import '@suite/ui-components/styles.css';
|
遗留项目这样用:
1 2 3 4 5 6 7 8 9
| <script src="/vendor/vue.runtime.js"></script> <script src="/vendor/suite-ui.umd.js"></script> <link rel="stylesheet" href="/vendor/suite-ui.css" />
<script> const { AmountInput } = window.SuiteUI; </script>
|
按需引入:子路径导出
到后面组件多了(15+ 个),全量引入太重。我们做了子路径导出,让消费者只引需要的组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| { "exports": { ".": { "import": "./dist/suite-ui.es.js", "require": "./dist/suite-ui.umd.js" }, "./AmountInput": { "import": "./dist/components/AmountInput/index.es.js", "require": "./dist/components/AmountInput/index.umd.js" }, "./RateCard": { "import": "./dist/components/RateCard/index.es.js", "require": "./dist/components/RateCard/index.umd.js" } } }
|
1 2
| import AmountInput from '@suite/ui-components/AmountInput';
|
这意味着构建配置要做多入口——Vite 配置改一下:
1 2 3 4 5 6 7 8 9 10 11 12
| build: { lib: { entry: { index: resolve(__dirname, 'src/index.ts'), 'components/AmountInput/index': resolve(__dirname, 'src/components/AmountInput/index.ts'), 'components/RateCard/index': resolve(__dirname, 'src/components/RateCard/index.ts'), }, formats: ['es', 'umd'], fileName: (format, entryName) => `${entryName}.${format}.js`, }, }
|
设计 Token:样式一致性的底子
13 个工具的视觉不能飘。我们在 tokens.scss 里定义了一套设计 token:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| $color-primary: #1677ff; $color-success: #52c41a; $color-warning: #faad14; $color-danger: #ff4d4f;
$color-amount-positive: #52c41a; $color-amount-negative: #ff4d4f;
$spacing-unit: 4px; $spacing-sm: $spacing-unit * 2; $spacing-md: $spacing-unit * 4; $spacing-lg: $spacing-unit * 6;
$radius-sm: 2px; $radius-md: 4px; $radius-lg: 8px;
|
这些 token 编译进 CSS 变量,消费者可以在运行时覆盖主题:
1 2 3 4 5
| :root { --suite-color-primary: #{$color-primary}; --suite-color-amount-positive: #{$color-amount-positive}; }
|
1 2 3 4
| :root { --suite-color-primary: #722ed1; }
|
踩过的坑
坑一:UMD 模式下 CSS 没自动引入。ES 模块那边 import '@suite/ui-components/styles.css' 就行了,但 UMD 那边用户经常忘了引 CSS,页面全白。后来我们在文档里加了一句铁律:UMD 引入必须同时引 CSS,否则布局必崩。
坑二:Vue 版本不一致导致运行时报错。组件库 external 掉了 Vue,但如果消费者用的 Vue 版本和库开发时的不一致,运行时可能炸。我们在 peerDependencies 里锁死了范围:
1 2 3 4 5
| { "peerDependencies": { "vue": "^2.7.0" } }
|
坑三:按需引入的产物体积没降反升。早期多入口构建时,每个组件都重复打包了 utils 和 styles 的公共部分。后来用 Vite 的 manualChunks 把公共依赖抽出来,体积才真正降下去。
小结
一套源码同时出 UMD 和 npm 包,核心就三件事:
- Vite library mode 多格式输出(
formats: ['es', 'umd'])
- package.json exports 精准路由(让消费者自动选对产物)
- 设计 token 统一视觉(CSS 变量支持主题覆盖)
组件库不是”写组件”的问题,是”工程化交付”的问题。下一篇讲质量基线,怎么把测试覆盖率拉到 90%。