当你听到“基于单仓库生态系统的可扩展设计系统”这个概念时,它可能会让你觉得是一堆专业术语拼凑在一起。让我们把它简化一下:

  • 设计系统:构成你产品的基本元素(按钮、输入框、样式、标记符、模式等)。

  • 单仓库:一个包含多个模块的大型仓库,这些模块共同使用相同的工具和工作流程。

关键在于,将这两者结合在一起,就能实现模块化、一致性,并显著加快开发速度。对于那些需要同时处理网页、移动端及其他平台的开发团队来说,这简直就是完美的解决方案。

在本文中,你将学习如何使用React和Turborepo来构建一个模块化、可扩展的设计系统——正是微软、IBM和Shopify所采用的方法。

目录

先决条件

在开始学习之前,你需要确保具备以下条件:

  • 掌握React和TypeScript的基本知识:你应该能够熟练创建组件并阅读基本的类型注释。

  • 熟悉命令行操作:在整个学习过程中,你将经常使用npxnpm等命令。

  • 已安装Node.js(版本18或更高):通过node -v来验证是否已经安装。如果没有,请从nodejs.org下载并安装。

  • 需要一个包管理工具:本指南使用npm,但pnpmyarn也可以通过稍作调整后使用。

  • 选择一款代码编辑器(对于TypeScript开发来说,VS Code非常受欢迎)。

你不需要具备关于单仓库或Turborepo的任何先验经验。我们会从零开始为您配置一切。

已经有人在使用这种方法了吗?

事实上,一些你听说过的知名设计系统都是基于单仓库架构来运行的:

  1. Microsoft Fluent UI:它使用一个多包单仓库来存储React组件、Web Components以及各种设计元素。

  2. IBM Carbon:像@carbon/ibm-products这样的多个包都是从他们的Carbon单仓库中发布的。

  3. Shopify Polaris

    :明确将自己定义为基于单仓库的系统,其中包含了React组件、文档,甚至还有VS Code扩展程序。

  4. Atlassian Atlaskit:他们公开的@atlaskit/*包都是从一个大型内部单仓库中发布的。

  5. MUI(Material UI):通过单仓库来管理React组件、开发工具以及相关文档。

  6. Elastic EUI

    :所有代码和资源都是从一个单一的仓库中开发和发布的,同时也有关于如何使用单仓库进行版本控制的讨论。

为什么这种方法有效

当你将设计系统的所有组件都放在一个仓库中时,就会获得一些在分散式仓库架构中难以实现的优势。这些优势会相互增强,因此采用这种模式的团队很少会改变他们的做法。

以下是使得这种方法有效的几个原因:

  • 一致性:设计元素、样式以及基础组件都只定义一次,然后在整个系统中得到统一应用。

  • 更快的迭代速度

    :在修复Button组件中的某个错误后,相关更新会立即同步到移动端、桌面版本以及文档中。

  • 共享的开发工具

    :代码检查工具、测试脚本、持续集成流程以及发布工作流程都只需要配置一次,然后就可以应用于所有相关的包中。

  • 版本控制

    :借助Changesets或Lerna等工具,你可以独立地发布各个包,同时确保它们之间的版本协调一致。

  • 跨平台的灵活性

    :相同的组件和代码结构可以用于开发React Web应用、React Native应用、Electron应用程序、SDK以及文档网站。

可以把它想象成一把梯子 🪜

将单仓库设计系统形象化地理解,就是将其看作是一层层堆叠起来的结构。每一层都是建立在前一层的基础之上,而且每一层都有明确的职责。

新加入的开发者能够更快地熟悉这个系统,因为各个组件之间的关系是非常清晰的:设计元素会逐步组合成基础组件,这些基础组件再构成页面布局,最终形成完整的用户界面。

下图以可视化的方式展示了这一架构结构:

单仓库设计系统的分层架构:最底层是设计元素,接下来是插件(辅助工具),然后是布局文件,接着是界面组件,最顶层是导航系统;整个应用程序仅依赖一个包来整合所有这些层

在最底层,是基础设计元素(如设计符号、样式规则)。

在其上方的是插件(辅助工具模块)。

接着是布局文件,它们是由插件和基础设计元素组合而成的。

然后是界面组件,它们是基于布局文件构建的。

最后,导航系统将各个界面组件连接在一起。

在整个架构的最顶端,你的应用程序只需导入一个包,这样一来,用户界面就具备了跨平台的兼容性。

处处相同的设计系统

这种架构模式的真正价值在于:你只需要构建一次这个系统,之后就可以在所有发布的平台上重复使用它。

你在基础设计元素包中定义的按钮,无论是在网页应用、React Native移动应用、Electron桌面应用中,还是文档网站上使用,都无需为每个环境重新编写代码。

下图展示了同一个设计系统如何应用于三种不同的应用程序类型:所有这些应用都仅依赖同一个包进行导入,因此能够保证样式、行为和可访问性等方面保持一致:

同一个设计系统被应用于三种不同的应用中:浏览器中的网页应用、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: 16pxcolor: #3b82f6,不如使用像 spacing.mdcolors.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 (

); }

保存文件后,以开发模式运行该应用程序:

npx turbo dev --filter=web

你应该会看到首页被渲染出来:其中包含用primary[500]颜色显示的蓝色标题,该标题两侧使用了spacing.lg进行填充,同时还有两个遵循你的设计系统规范制作的按钮。无论你对任何配置项进行什么修改(比如更改主题色),下次重新构建应用程序时,这些变化都会自动应用到这个页面上。

总结

虽然使用单仓库结构并不能让你的设计系统变得完美无缺,但它确实能为你带来以下好处:

  • 一个所有组件都能相互关联的共享平台

  • 能够独立发布各个组件的灵活性

  • 使设计成果能够在不同团队和平台上得到统一应用的能力

难怪世界上那些最知名的设计系统都在采用这种架构。

Comments are closed.