写代码就像是在盖房子,而组件库就是那块块预制好的砖瓦。很多开发者——包括我自己——在刚接触“组件库”这三个字时,脑子里蹦出来的往往是那种臃肿、老旧、文档难找、更新靠吼的项目。但今天我们要聊的,不是那种东西。我们要聊的是如何构建一个现代、高效、可维护、甚至能让你在面试时闪闪发光的企业级组件库体系。
这不仅仅是一次技术分享,更像是一场关于工程化思维的深度对话。我会带你一步步拆解,从目录结构的规划,到核心工具链的选择,再到最后那令人兴奋的自动化发布流程。别担心,虽然涉及的内容很多,但我会用最直白的大白话,配合真实的代码示例,把这些复杂的概念揉碎了喂给你。
为什么我们需要 Monorepo?先聊聊“分家”与“合住”的故事
在深入代码之前,我们先解决一个哲学问题:为什么要把 Vue 和 React 的组件放在同一个仓库里?
想象一下,如果你有一个电商项目,里面既有 Vue 做的后台管理系统,又有 React 做的前端营销活动页。如果这两个项目各自维护一套基础 UI 组件(比如 Button、Input、Modal),你会发生什么?
- 重复造轮子:两个团队写了几乎一样的
Button组件,样式稍微有点差异,修 Bug 的时候得改两处。 - 版本不一致:当设计稿改了主色调,React 组更新了组件,Vue 组可能还在用旧版,导致页面风格割裂。
- 协作成本高:每次发版,两边都要测试,还要处理依赖冲突。
这就是为什么要引入 Monorepo(单仓模式)。它就像是一个大家庭,大家住在一个屋檐下(同一个 Git 仓库),共用厨房(共享依赖和工具链),但每个人有自己的房间(独立的包)。
对于企业级应用来说,这意味着我们可以定义一套统一的 Design Token(设计变量),一套统一的构建规范,甚至一套统一的发布策略。无论是 Vue 还是 React,底层的核心逻辑(比如事件总线、状态管理封装、工具函数)都可以复用。
第一步:骨架搭建 —— 选择正确的工具链
市面上有很多 Monorepo 工具,比如 Lerna、Nx、Turborepo、pnpm workspaces。
- Lerna:老牌劲旅,但现在更多是作为版本管理工具存在,构建能力较弱。
- Nx:功能极其强大,适合超大型单体应用,但配置复杂,学习曲线陡峭。
- Turborepo:Vercel 出品,速度快,缓存机制优秀,但生态相对较新。
- pnpm workspaces:强烈推荐。对于大多数团队来说,pnpm 是目前最平衡的选择。它速度快、节省磁盘空间(硬链接机制),而且对 TypeScript 的支持非常友好。
我们将基于 pnpm + Turborepo 的组合来构建这个体系。为什么选 Turborepo?因为它的任务缓存机制能让你的 CI/CD 跑得飞快,这在自动化发布环节至关重要。
初始化项目
首先,在你的终端里执行:
mkdir enterprise-ui-kit
cd enterprise-ui-kit
pnpm init
接下来,创建 turbo.json 配置文件,这是 Turborepo 的大脑:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"lint": {},
"test": {
"dependsOn": ["build"]
},
"dev": {
"cache": false
}
}
}
这里的 dependsOn: ["^build"] 意思是:如果 A 包依赖 B 包,那么构建 A 之前必须先构建 B。这是保证组件库稳定性的关键。
然后,创建根目录的 package.json,声明 workspace:
{
"name": "enterprise-ui-monorepo",
"private": true,
"scripts": {
"build": "turbo run build",
"lint": "turbo run lint",
"test": "turbo run test",
"clean": "turbo run clean",
"publish:all": "turbo run build lint test && changeset version && changeset publish"
},
"devDependencies": {
"turbo": "^1.10.0",
"@changesets/cli": "^2.26.0"
},
"packageManager": "pnpm@8.6.0"
}
注意,我们引入了 @changesets/cli。这是解决 Monorepo 版本管理的“神器”。它会自动检测哪些包发生了变化,并生成对应的变更日志和版本号,彻底告别手动修改 package.json 版本号的手动痛苦。
第二步:目录结构 —— 逻辑清晰是美的开始
一个优秀的 Monorepo 目录结构应该像一本字典,让你一眼就能找到需要的东西。以下是我推荐的经典结构:
enterprise-ui-kit/
├── .changeset/ # Changeset 配置文件
├── packages/ # 所有包的根目录
│ ├── core/ # 核心包:工具函数、常量、类型定义、Design Tokens
│ ├── vue-components/ # Vue 组件库包
│ ├── react-components/# React 组件库包
│ └── docs/ # 文档站点 (Storybook 或 VitePress)
├── scripts/ # 自定义脚本(如构建脚本、发布脚本)
├── turbo.json # Turborepo 配置
├── package.json # 根 package.json
├── pnpm-workspace.yaml # pnpm 工作区配置
└── tsconfig.json # 根 TypeScript 配置
为什么要有 core 包?
这是很多新手容易忽略的地方。core 包是你的“地基”。
- 共享类型:Vue 和 React 组件都需要定义 Props 的类型。如果在各自包里定义,一旦需求变更(比如按钮尺寸从
small/medium/large变成xs/s/m/l),你需要改两个地方。放在core里,引用一次即可。 - 共享样式变量:颜色、间距、圆角等 Design Token 必须统一。
- 共享工具函数:比如
classNames合并、debounce防抖等。
让我们看看 packages/core/package.json 的样子:
{
"name": "@enterprise-ui/core",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"clean": "rm -rf dist"
},
"devDependencies": {
"tsup": "^7.0.0"
}
}
这里使用了 tsup 作为构建工具。它比 Webpack 轻快得多,专门用于打包库文件,支持同时输出 CommonJS 和 ES Modules,这对兼容性至关重要。
第三步:核心实现 —— 打造通用的 Design Token
假设我们要定义一套企业级的蓝色系主题。在 packages/core/src/tokens.ts 中:
// packages/core/src/tokens.ts
export const colors = {
primary: {
50: '#e6f7ff',
100: '#bae7ff',
// ... 其他色阶
500: '#1890ff', // 主色
600: '#096dd9',
},
success: '#52c41a',
warning: '#faad14',
error: '#f5222d',
} as const;
export const spacing = {
sm: '8px',
md: '16px',
lg: '24px',
} as const;
export const borderRadius = {
small: '4px',
medium: '8px',
large: '12px',
} as const;
// 导出所有类型
export type ColorToken = typeof colors;
export type SpacingToken = typeof spacing;
现在,无论是 Vue 还是 React 组件,都可以直接导入这些常量,确保全站风格统一。
第四步:构建 Vue 组件 —— 以 Button 为例
在 packages/vue-components 下,我们创建一个简单的 Button 组件。
首先,配置 tsup.config.ts,确保它能正确处理 Vue 的单文件组件(SFC)或者将其转换为 JS 模块。为了简化演示,我们假设使用 Vite 来构建 Vue 组件库(这也是目前最主流的做法,因为 Vite 原生支持 Vue SFC)。
packages/vue-components/vite.config.ts:
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import dts from 'vite-plugin-dts';
import path from 'path';
export default defineConfig({
plugins: [
vue(),
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'EnterpriseVueUI',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`,
},
rollupOptions: {
external: ['vue'], // 将 Vue 设为外部依赖,不打包进库中
output: {
globals: {
vue: 'Vue',
},
},
},
},
});
接着,编写核心的 Button 组件 packages/vue-components/src/components/Button.vue:
<template>
<button
class="eu-button"
:class="[
`eu-button--${type}`,
`eu-button--${size}`,
{ 'eu-button--disabled': disabled },
'eu-button--block': block
]"
:disabled="disabled"
@click="$emit('click', $event)"
>
<slot></slot>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { colors, spacing, borderRadius } from '@enterprise-ui/core';
interface Props {
type?: 'primary' | 'default' | 'success' | 'warning' | 'error';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
block?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
size: 'md',
disabled: false,
block: false,
});
// 这里可以动态绑定 CSS 变量,实现主题切换
// 实际项目中通常会注入 CSS 变量到 root
</script>
<style scoped>
.eu-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: v-bind(borderRadius.medium); /* 使用 Vite 的动态 CSS 变量 */
cursor: pointer;
transition: all 0.3s;
font-weight: 500;
}
/* 类型变体 */
.eu-button--primary {
background-color: v-bind(colors.primary[500]);
color: white;
}
.eu-button--success {
background-color: v-bind(colors.success);
color: white;
}
/* 禁用状态 */
.eu-button--disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
关键点解析:
- TypeScript 支持:通过
<script setup lang="ts">获得完整的类型检查。 - 核心依赖:直接从
@enterprise-ui/core导入样式变量,实现了组件与样式的解耦。 - 动态 CSS:利用 Vite 的
v-bind()语法,可以在 JS 中动态改变 CSS 值,这是构建高度可配置组件库的高级技巧。
最后,在 packages/vue-components/src/index.ts 中统一导出:
export { default as Button } from './components/Button.vue';
// 导出类型
export type { ButtonProps } from './components/Button.vue';
第五步:构建 React 组件 —— 同样的逻辑,不同的语法
在 packages/react-components 下,结构类似,但构建工具通常使用 Rollup 或 Vite。为了保持一致性,我们也用 Vite。
packages/react-components/vite.config.ts:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import path from 'path';
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
}),
],
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.ts'),
name: 'EnterpriseReactUI',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format}.js`,
},
rollupOptions: {
external: ['react', 'react-dom'], // 排除 React 依赖
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
},
});
React 的 Button 组件 packages/react-components/src/components/Button.tsx:
import React, { ButtonHTMLAttributes, forwardRef } from 'react';
import { colors, spacing, borderRadius } from '@enterprise-ui/core';
import './Button.css'; // 假设样式单独提取
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'default' | 'success';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
}
// 使用 forwardRef 以保持 React 最佳实践
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'default', size = 'md', fullWidth = false, children, className, ...props }, ref) => {
return (
<button
ref={ref}
className={`eu-button eu-button--${variant} eu-button--${size} ${fullWidth ? 'eu-button--full' : ''} ${className || ''}`}
{...props}
>
{children}
</button>
);
}
);
Button.displayName = 'Button';
export default Button;
对应的 CSS packages/react-components/src/components/Button.css:
.eu-button {
padding: 0 16px;
height: 32px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.eu-button--primary {
background-color: #1890ff;
color: white;
}
/* 其他变体... */
你会发现,虽然语法不同(Vue SFC vs React TSX + CSS),但核心思想是一致的:共享设计令牌,独立实现视图。
第六步:文档与预览 —— Storybook 是标配
光有代码不行,还得有文档。在企业级开发中,Storybook 是组件库的标配。它可以让你在一个隔离的环境中预览每一个组件的状态(正常、悬停、禁用、错误等)。
在根目录下创建一个 apps/storybook 或者直接复用 packages/docs。这里我们推荐将 Storybook 作为一个独立的 App 放在 apps 目录下(如果采用 Turborepo 的 apps/packages 结构),或者简单起见,直接在 packages/docs 中配置。
由于篇幅限制,我不展开讲 Storybook 的详细配置,但要点如下:
安装依赖:
pnpm add -D storybook @storybook/addon-essentials @storybook/builder-vite。配置 Vite 构建器:在
.storybook/main.ts中指定使用 Vite。编写 Stories:
// packages/vue-components/.storybook/Button.stories.ts import { Meta, StoryObj } from '@storybook/vue3'; import Button from '../src/components/Button.vue'; const meta: Meta<typeof Button> = { title: 'Components/Button', component: Button, tags: ['autodocs'], }; export default meta; type Story = StoryObj<typeof meta>; export const Primary: Story = { args: { type: 'primary', children: 'Primary Button', }, }; export const Disabled: Story = { args: { disabled: true, children: 'Disabled', }, };
这样,当你运行 pnpm dev 时,就能看到精美的组件文档界面。这对于团队协作和新成员上手至关重要。
第七步:自动化发布 —— 告别手动 npm publish
这是整个流程中最具“魔法”色彩的部分。我们使用 Changesets 来实现版本管理和自动发布。
1. 初始化 Changesets
在项目根目录运行:
pnpm changeset init
它会生成 .changeset/config.json,你可以配置包名前缀、访问级别(public/private)等。
2. 日常开发中的版本管理
每次你修改了组件代码,在提交之前,运行:
pnpm changeset
这会进入一个交互式界面,问你:
- 你想修改哪个包?(选择
@enterprise-ui/vue-components或react) - 是什么类型的更改?(patch/minor/major)
- 描述这次更改(例如:修复 Button 组件在禁用状态下颜色不正确的问题)
Changeset 会在 .changeset 目录下生成一个描述文件,例如 fix-button-color.md。
3. 版本升级
当你准备好发布新版本时,运行:
pnpm changeset version
Changeset 会自动:
- 读取所有的 changeset 文件。
- 更新受影响包的
package.json版本号。 - 生成
CHANGELOG.md。 - 删除已处理的 changeset 文件。
4. 构建与发布
在 CI/CD 流水线(如 GitHub Actions)中,我们需要自动化这个过程。
.github/workflows/release.yml 示例:
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v3
with:
# 获取所有 git 历史,以便 changeset 正确计算版本
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
with:
# 自动创建 PR 或发布
title: "Version Packages"
commit: "chore: version packages"
# 发布命令
publish: pnpm run publish:all
env:
# 需要配置 npm token
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
publish:all 脚本我们在根 package.json 中定义过:
"publish:all": "turbo run build lint test && changeset version && changeset publish"
这套流程的逻辑是:
- Turbo Run:并行构建所有包,运行 lint 和 test。如果任何一步失败,停止发布。
- Changeset Version:根据之前的
pnpm changeset记录,计算新的版本号。 - Changeset Publish:将包发布到 npm registry。
第八步:给小朋友也能听懂的总结 —— 为什么这么做?
好了,说了这么多技术细节,如果我们把这个过程讲给一个刚学编程的小朋友听,大概可以这样比喻:
想象你要开一家超级大的乐高玩具公司。
- Monorepo(单仓模式):就像是你把所有乐高积木的模具都放在同一个大仓库里,而不是分散在十个不同的房子里。这样,如果你想改进一种红色的积木,你知道去哪里找图纸,也不会漏掉任何一个地方。
- Core(核心包):这是你的“标准色卡”和“通用连接件”。不管你是做城堡还是做飞船,红色都是一样的红,连接方式都是一样的。这样,不同团队做出来的产品看起来才像一个家族的产品。
- Vue/React 组件:这是两个不同的“组装车间”。一个车间擅长用 Vue 的方式拼装,另一个擅长用 React 的方式拼装。但它们都使用 Core 里的标准零件。
- Storybook(文档):这是你的“展示橱窗”。客户(其他程序员)不需要拆开盒子,直接在橱窗里就能看到积木拼好后的样子,还有各种玩法的说明书。
- Changeset & CI/CD(自动化发布):这是你的“自动发货机器人”。每当车间里有了新的改进,机器人就会自动检查质量(测试),贴上新的标签(版本号),然后一键打包寄给客户(npm publish)。你只需要告诉机器人“今天有新货”,剩下的它全包了。
这样做的好处是什么?
- 快:机器人干活比人快,还不会累。
- 稳:标准零件保证了质量一致,不会出现“A公司的红色比B公司的深一点”这种尴尬。
- 爽:开发者不需要手动去改几十个文件的版本号,只要写好“我改了啥”,剩下的交给机器。
进阶挑战:TypeScript 类型推导与 Tree Shaking
在企业级应用中,性能优化是必须的。
1. 完美的 TypeScript 类型
在上面的例子中,我们导出了 ButtonProps。但在实际使用中,我们希望 IDE 能提供智能提示。确保你的 tsconfig.json 配置正确,并且在导出时使用 export type。
对于 Vue 组件,可以使用 defineComponent 或者 <script setup> 自动推断 Props 类型。对于 React,确保使用 forwardRef 和正确的泛型。
2. Tree Shaking(摇树优化)
为了让最终打包体积最小,必须确保未使用的组件不会被打包进去。
- ESM 优先:确保构建输出 ES Module 格式(
.js),因为 Tree Shaking 对 ESM 的支持最好。 - 避免副作用:在
package.json中标记"sideEffects": false,或者明确指出哪些文件有副作用(如全局样式注入)。 - 按需加载:在
index.ts中,只导出必要的组件。不要在入口文件中引入所有组件的代码,除非它们是顶层导出。
例如,在 packages/vue-components/src/index.ts 中:
// 正确做法:显式导出
export { default as Button } from './components/Button.vue';
export { default as Input } from './components/Input.vue';
// 错误做法:通配符导入可能导致无法 Tree Shake
// export * from './components';
结语:这是一条持续演进的路
搭建组件库不是一蹴而就的工程,而是一个持续迭代的过程。
你可能会遇到这样的问题:
- 如何支持暗黑模式?(通过 CSS 变量或 Tailwind 的 dark 类)
- 如何支持 SSR(服务端渲染)?(Vue 和 React 都有专门的 hydration 机制,需要在构建配置中处理)
- 如何测试?(Jest + Testing Library,为每个组件编写单元测试)
但请记住,今天的架构为你打下了坚实的基础。通过 Monorepo 统一管理,通过 Changesets 自动化版本,通过 Turborepo 加速构建,你已经拥有了一个媲美大型互联网公司内部组件库的工程化体系。
下次当你需要在新项目中引入一套 UI 时,不再是从零开始复制粘贴,而是简单地 pnpm add @enterprise-ui/vue-components,然后享受 TypeScipt 的智能提示和一致的组件体验。
这就是工程化的魅力:把复杂留给自己,把简单留给用户。
希望这篇指南能帮助你建立起自己的组件库帝国。如果有具体的代码问题,随时回来查阅这些片段,或者在评论区讨论。毕竟,代码是写出来的,也是聊出来的。加油!
