当你听到“基于单仓库生态系统的可扩展设计系统”这个概念时,它可能会让你觉得是一堆专业术语拼凑在一起。让我们把它简化一下:
-
设计系统:构成你产品的基本元素(按钮、输入框、样式、标记符、模式等)。
-
单仓库:一个包含多个模块的大型仓库,这些模块共同使用相同的工具和工作流程。
关键在于,将这两者结合在一起,就能实现模块化、一致性,并显著加快开发速度。对于那些需要同时处理网页、移动端及其他平台的开发团队来说,这简直就是完美的解决方案。
在本文中,你将学习如何使用React和Turborepo来构建一个模块化、可扩展的设计系统——正是微软、IBM和Shopify所采用的方法。
目录
先决条件
在开始学习之前,你需要确保具备以下条件:
-
掌握React和TypeScript的基本知识:你应该能够熟练创建组件并阅读基本的类型注释。
-
熟悉命令行操作:在整个学习过程中,你将经常使用
npx、npm等命令。 -
已安装Node.js(版本18或更高):通过
node -v来验证是否已经安装。如果没有,请从nodejs.org下载并安装。 -
需要一个包管理工具:本指南使用
npm,但pnpm或yarn也可以通过稍作调整后使用。 -
选择一款代码编辑器(对于TypeScript开发来说,VS Code非常受欢迎)。
你不需要具备关于单仓库或Turborepo的任何先验经验。我们会从零开始为您配置一切。
已经有人在使用这种方法了吗?
事实上,一些你听说过的知名设计系统都是基于单仓库架构来运行的:
-
Microsoft Fluent UI:它使用一个多包单仓库来存储React组件、Web Components以及各种设计元素。
-
IBM Carbon:像
@carbon/ibm-products这样的多个包都是从他们的Carbon单仓库中发布的。 -
:明确将自己定义为基于单仓库的系统,其中包含了React组件、文档,甚至还有VS Code扩展程序。
-
Atlassian Atlaskit:他们公开的
@atlaskit/*包都是从一个大型内部单仓库中发布的。 -
MUI(Material UI):通过单仓库来管理React组件、开发工具以及相关文档。
-
:所有代码和资源都是从一个单一的仓库中开发和发布的,同时也有关于如何使用单仓库进行版本控制的讨论。
为什么这种方法有效
当你将设计系统的所有组件都放在一个仓库中时,就会获得一些在分散式仓库架构中难以实现的优势。这些优势会相互增强,因此采用这种模式的团队很少会改变他们的做法。
以下是使得这种方法有效的几个原因:
-
一致性:设计元素、样式以及基础组件都只定义一次,然后在整个系统中得到统一应用。
-
更快的迭代速度
:在修复Button组件中的某个错误后,相关更新会立即同步到移动端、桌面版本以及文档中。
-
共享的开发工具
:代码检查工具、测试脚本、持续集成流程以及发布工作流程都只需要配置一次,然后就可以应用于所有相关的包中。
-
版本控制
:借助Changesets或Lerna等工具,你可以独立地发布各个包,同时确保它们之间的版本协调一致。
-
跨平台的灵活性
:相同的组件和代码结构可以用于开发React Web应用、React Native应用、Electron应用程序、SDK以及文档网站。
可以把它想象成一把梯子 🪜
将单仓库设计系统形象化地理解,就是将其看作是一层层堆叠起来的结构。每一层都是建立在前一层的基础之上,而且每一层都有明确的职责。
新加入的开发者能够更快地熟悉这个系统,因为各个组件之间的关系是非常清晰的:设计元素会逐步组合成基础组件,这些基础组件再构成页面布局,最终形成完整的用户界面。
下图以可视化的方式展示了这一架构结构:

在最底层,是基础设计元素(如设计符号、样式规则)。
在其上方的是插件(辅助工具模块)。
接着是布局文件,它们是由插件和基础设计元素组合而成的。
然后是界面组件,它们是基于布局文件构建的。
最后,导航系统>将各个界面组件连接在一起。
在整个架构的最顶端,你的应用程序只需导入一个包,这样一来,用户界面就具备了跨平台的兼容性。
处处相同的设计系统
这种架构模式的真正价值在于:你只需要构建一次这个系统,之后就可以在所有发布的平台上重复使用它。
你在基础设计元素包中定义的按钮,无论是在网页应用、React Native移动应用、Electron桌面应用中,还是文档网站上使用,都无需为每个环境重新编写代码。
下图展示了同一个设计系统如何应用于三种不同的应用程序类型:所有这些应用都仅依赖同一个包进行导入,因此能够保证样式、行为和可访问性等方面保持一致:

无论是网页应用、桌面应用还是移动应用,这种设计系统都适用于所有这些场景。
你应该选择使用单仓库架构吗?
并不是每个团队都需要使用单仓库架构。但如果你正在构建一个需要为多款应用程序服务、能在不同平台上保持一致性,并且需要支持大量开发者的设计系统,那么单仓库架构就不仅仅是一个时髦的概念,而是一种能够提升开发效率的实用工具。
在什么情况下单仓库架构并不适合
首先需要澄清一点:单仓库架构有时会与另一个讨论话题被混淆。“单仓库架构与多仓库架构”的问题,并不等同于“单体应用与微服务”的问题。实际上,你完全可以在单仓库架构中实现微服务架构(谷歌和Facebook就是在这种架构模式下大规模运作的)。
这两种选择属于不同的范畴:单仓库与多仓库的区分在于代码的存储方式,而单体应用与微服务的区别则在于运行时的架构设计。
明确了这一点之后,以下是一些说明,表明单仓库可能并不适合你的实际情况:
-
如果你们是一个只开发一个产品的小型团队,单仓库所带来的工具管理开销(如工作区配置、构建流程、包的分割等)可能会带来更多的麻烦,其带来的好处可能远不如这些开销。对于一个没有共享库的简单React应用来说,单仓库可能并不是必要的。
-
如果你的各个项目包的发布频率或相关方需求差异很大,使用单独的仓库会更有帮助。因为不同团队可能需要完全不同的部署流程、管理机制或安全策略,将它们分开存储可以有效减少冲突。
-
如果你没有足够的时间和资源来投入单仓库相关的工具开发,那么这些工具可能会成为你的瓶颈。像Turborepo、Nx这样的工具虽然能提升效率,但学习和使用成本都不低。如果你的团队无法专门投入时间来配置和维护这些工具,使用单仓库可能会带来很多麻烦。
-
如果你使用的编程语言或运行时环境之间不兼容,单仓库的优势就会大打折扣。当大多数包都使用相同的工具链时,单仓库才能发挥最大的作用。但如果在一个仓库中同时使用Node.js、Go、Rust和Python等不同语言,构建流程会变得非常复杂。
对于大多数正在构建复杂设计系统的团队来说,以上这些因素都不是决定性因素。但在最终决定之前,仔细评估自己的实际情况还是很有必要的。
让我们一起来构建我们的设计系统吧
创建你的Turborepo项目
首先,创建一个新的Turborepo项目。这将为你搭建一个可扩展的单仓库基础。
# 创建一个新的Turborepo项目
npx create-turbo@latest my-design-system
# 进入项目目录
cd my-design-system
# 安装依赖项
npm install
Turborepo会创建一个包含apps/和packages/文件夹的工作区,同时提供共享的工具配置和优化的构建流程。
设计你的包结构
接下来,为你的设计系统包创建一个合理的层次结构。可以将其想象成梯子,每一层都是建立在下一层的基础之上的。
my-design-system/
├── packages/
│ ├── tokens/ # 设计元素样式(颜色、间距、字体)
│ ├── primitives/ # 基础组件(按钮、输入框、卡片)
│ ├── layouts/ # 布局组件(网格布局、堆叠布局、容器)
├── apps/
│ ├── web/ # 示例Web应用
│ └── docs/ // 文档网站
└── turbo.json # Turborepo配置文件
详细的文件结构
my-design-system/
├── packages/
│ ├── tokens/
│ │ ├── src/
│ │ │ ├── colors.ts
│ │ │ ├── spacing.ts
│ │ │ ├── typography.ts
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── primitives/
│ │ ├── src/
│ │ │ ├── Button/
│ │ │ │ └── Button.tsx
│ │ │ ├── Input/
│ │ │ │ └── Input.tsx
│ │ │ └── index.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── layouts/
│ │ ├── src/
│ │ │ ├── Grid/
│ │ │ ├── Stack/
│ │ │ └── index.ts
│ │ └── package.json
├── apps/
│ ├── web/
│ │ ├── src/
│ │ │ ├── App.tsx
│ │ │ └── main.tsx
│ │ ├── index.html
│ │ └── package.json
│ └── docs/
│ ├── src/
│ └── package.json
├── turbo.json
├── package.json
└── README.md
构建你的设计令牌包
首先从基础开始:设计令牌。令牌是设计系统中最小且最可复用的单位,比如颜色值、间距大小、字体尺寸或边框半径。与其在所有地方都硬编码 padding: 16px 或 color: #3b82f6,不如使用像 spacing.md 或 colors.primary[500] 这样的令牌。
这样做的好处显而易见:
-
只需在一个地方修改数值即可:只需更新一次令牌,所有使用该令牌的组件都会自动更新。
-
主题设置变得非常简单:想要切换到深色模式?只需更改对应令牌所代表的数值即可。
-
跨平台一致性:相同的令牌名称在网页CSS、原生样式甚至Figma中都能使用。
令牌就是你的设计系统的“DNA”。让我们开始构建它们吧。
# 创建令牌包
mkdir -p packages/tokens/src
cd packages/tokens
在 packages/tokens/package.json 文件中更新这些配置。该文件用于指定包的名称、版本、构建脚本以及编译令牌源文件所需的开发依赖项:
{
"name": "@yourds/tokens",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"dev": "tsup src/index.ts --format cjs,esm --dts --watch"
},
"devDependencies": {
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
}
在 packages/tokens/src/colors.ts 文件中更新这些配置。该文件定义了颜色令牌:按用途(主色系、灰色系)和明暗度(50表示最浅,900表示最深)分类的命名颜色值集合。组件可以通过名称来引用这些颜色值,而无需硬编码十六进制代码:
export const colors = {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#3b82f6',
600: '#2563eb',
900: '#1e3a8a'
},
gray: {
50: '#f9fafb',
100: '#f3f4f6',
500: '#6b7280',
900: '#111827'
}
} as const;
在 packages/tokens/src/spacing.ts 文件中更新这些配置。该文件定义了间距标准:一组用于指定组件中内边距、外边距和间隙大小的标准数值。使用这样的固定比例(如xs、sm、md、lg等),可以让整个用户界面中的间距保持一致:
export const spacing = {
xs: '0.25rem', // 4px
sm: '0.5rem', // 8px
md: '1rem', // 16px
lg: '1.5rem', // 24px
xl: '2rem', // 32px
'2xl': '3rem' // 48px
} as const;
在 packages/tokens/src/typography.ts 文件中更新这些配置。该文件定义了排版令牌:组件用于文本显示的字体大小和粗细。与间距设置类似,这些也是以命名形式来表示的,而不是随意指定的像素数值。
export const typography = {
fontSizes: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem'
},
fontWeights: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700
}
} as const;
请在您的packages/tokens/src/index.ts文件中更新这些内容。这个文件是该包的公共入口点:它重新导出了三个token文件中的所有内容,因此其他代码可以通过一行代码来导入这些内容,例如:import { colors, spacing, typography } from "@yourds/tokens":
export * from './colors';
export * from './spacing';
export * from './typography';
创建基础组件
构建那些使用这些设计token的基础组件:
# 创建 primitives 包
mkdir -p packages/primitives/src
cd packages/primitives
# 安装依赖项
npm install react react-dom
请在您的packages/primitives/package.json文件中更新这些内容:
{
"name": "@yourds/primitives",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --external react",
"dev": "tsup src/index.ts --format cjs,esm --dts --external react --watch"
},
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
}
}
请在您的packages/primitives/src/Button Button.tsx文件中更新这些内容:
import React from 'react';
import { colors, spacing } from '@yourds/tokens';
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
children,
disabled = false,
...props
}) => {
const baseStyles = {
border: 'none',
borderRadius: '0.5rem',
cursor: disabled ? 'not-allowed' : 'pointer',
fontWeight: 500,
transition: 'all 0.2s ease',
opacity: disabled ? 0.6 : 1
};
const variants = {
primary: {
backgroundColor: colors.primary[500],
color: 'white',
':hover': { backgroundColor: colors(primary[600] }
},
secondary: {
backgroundColor: colorsgray[100],
color: colors.gray[900],
':hover': { backgroundColor: colors-gray[200] }
},
outline: {
backgroundColor: 'transparent',
color: colors.primary[500],
border: `1px solid ${colors(primary[500]}`,
':hover': { backgroundColor: colorsprimary[50] }
}
};
const sizes = {
sm: { padding: `\({spacing.xs} \){spacing.sm}`, fontSize: '0.875rem' },
md: { padding: `\({spacing.sm} \){spacing.md}`, fontSize: '1rem' },
lg: { padding: `\({spacing.md} \){spacing.lg}`, fontSize: '1.125rem' }
};
const buttonStyle = {
...baseStyles,
...variants[variant],
...sizes[size]
};
return (
请在您的 `packages/primitives/src/index.ts` 文件中更新这些内容:
export { Button } from './Button/Button';
export type { ButtonProps } from './Button/Button';
配置 Turborepo 流程
现在,请在 `turbo.json` 文件中设置构建流程,以确保包能够按正确的顺序进行编译。
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"type-check": {
"dependsOn": ["^build"]
}
}
}
构建 @yourds 包
在定义了 tokens 和 primitives 包之后,下一步就是将它们编译成可供应用程序使用的形式。
在项目根目录下运行 `npm install` 命令,可以解决所有依赖关系问题,包括 `@yourds/tokens` 与 `@yourds/primitives` 之间的内部关联。接着运行 `npm run build`,Turborepo 会按正确的顺序执行每个包的编译任务:由于 primitives 包依赖于 tokens 包,因此 tokens 会先被编译。最后再次运行 `npm install`,这样编译完成的包就会被注册到系统中,从而可以让 `apps/web` 应用程序通过名称来导入这些包:
# 进入单体仓库的根目录
npm install
# 按正确的顺序编译每个包
npm run build
# 注册编译完成的包供应用程序使用
npm install @yourds/tokens @yourds/primitives
如果所有操作都成功完成,您应该会在 `packages/tokens` 和 `packages/primitives` 目录下看到一个 `dist/` 文件夹,其中包含了编译后的 JavaScript 代码文件以及 TypeScript 声明文件。
在应用程序中使用您的设计系统
现在您可以在任何 React 应用程序中使用这个设计系统了。
下面的示例会替换您 `apps/web/src/App.tsx` 文件中的默认内容,生成一个简单的首页,该页面同时展示了两点:一是从 `@yourds/primitives` 中导入 primitives 类型(例如 `Button` 组件);二是直接从 `@yourds/tokens` 中导入 tokens 类型(如 `colors`、`spacing`),用于为标准的 HTML 元素(比如 `
`)设置样式。
最终得到的页面能够完整地运用您的设计系统,且没有任何硬编码的颜色或间距值:
import { Button } from "@yourds/primitives";
import { colors, spacing } from "@yourds/tokens";
export default function Home() {
return (
使用设计系统的我的应用
);
}
保存文件后,以开发模式运行该应用程序:
npx turbo dev --filter=web
你应该会看到首页被渲染出来:其中包含用primary[500]颜色显示的蓝色标题,该标题两侧使用了spacing.lg进行填充,同时还有两个遵循你的设计系统规范制作的按钮。无论你对任何配置项进行什么修改(比如更改主题色),下次重新构建应用程序时,这些变化都会自动应用到这个页面上。
总结
虽然使用单仓库结构并不能让你的设计系统变得完美无缺,但它确实能为你带来以下好处:
-
一个所有组件都能相互关联的共享平台
-
能够独立发布各个组件的灵活性
-
使设计成果能够在不同团队和平台上得到统一应用的能力
难怪世界上那些最知名的设计系统都在采用这种架构。