前端与设计系统
为 Symfony 应用构建 React 设计系统:设计令牌、CSS Modules 与 Bootstrap 共存
阅读需 9 分钟
为 Symfony 应用构建 React 设计系统:设计令牌、CSS Modules 与 Bootstrap 共存
我在 Osunasport 负责 MasMusculo 的 Web 平台,这是一个基于 Symfony 和 Twig 构建的健身房管理产品。随着时间推移,界面里积累了同一类元素的许多"略有不同"的版本:按钮、徽章、卡片、提示框。当我们决定认真做一套设计系统时,第一版清单列出了大约 80 项。其中大多数并不是真正不同的组件,只是同一个东西的不同变体。
这篇文章介绍让这个组件库能在服务端渲染的 Symfony 页面中使用的几项关键决定,以及不那么光鲜的部分:团队如何在日常工作中使用它,而不会弄坏别人的分支。
先合并:从约 80 项到约 35 个组件
最有价值的一步发生在写代码之前。清单里有"主色胶囊按钮""小号主按钮""危险操作按钮""返回按钮"之类的条目。如果把它们都当成独立组件,就意味着要维护 80 个文件。
我们改为用 props 表达变体。一个 Button 接收 variant、size 和可选的 href;如果传了 href,就渲染为链接而不是按钮。徽章、提示框、头像、日历日期等都采用同样的思路。最终得到大约 35 个真正的组件,按原子、分子和有机体三层组织。
所有共享的 props 类型集中在一个文件里,任何人都不在本地重复定义:
export type Size = 'sm' | 'md' | 'lg'
export type ButtonVariant = 'primary' | 'outline' | 'destructive' | 'ghost' | 'success'
export type AlertVariant = 'error' | 'success' | 'warning' | 'info'
几条简单的规则让 API 保持可预测:size 永远是 sm | md | lg;视觉外观永远用 variant(不用 type);语义状态用 state;disabled 是布尔值,绝不作为一种变体;内容一律通过 children 传入。
带专属前缀的设计令牌
每个设计令牌都是带有强制前缀 --ds- 的 CSS 自定义属性:
--ds-color-cta: #000000;
--ds-font-family: 'Example Sans', sans-serif;
--ds-radius-pill: 25px;
Bootstrap 使用 --bs- 前缀,因此两套变量永远不会互相覆盖。令牌按用途分组(颜色、字体、圆角、间距、阴影,以及用于给 SVG 图标着色的滤镜),主题通过 <body> 上的类名(dark 或 white)切换,每个主题为同一组令牌定义各自的取值。
CSS Modules,采用驼峰命名
组件样式使用由 Vite 编译的 CSS Modules,类名会带上哈希。这带来了真正的隔离:模块内部名为 badge 的类,永远不会与 Bootstrap 的全局 .badge 冲突。唯一的规则是:不要把 Bootstrap 的全局类名(btn、card、modal、row、col 等)直接作为元素的 className 使用。
类名采用驼峰命名(.ctaPill、.withPromo),因为在 TypeScript 中 styles.ctaPill 比 styles['cta-pill'] 更易读,也更不容易出错。每个模块结构一致:先是基础类 .root,然后是外观变体、尺寸变体和状态。
用封装组件包住第三方库
图表、二维码扫描、日期选择器都来自外部库,但它们从不直接出现在模板里。每个库都放在一个小的封装组件后面,只暴露我们场景需要的 props,并在内部应用设计令牌。例如一个场馆容量仪表,只接收一个百分比和两个阈值;显示绿色、琥珀色还是红色,由封装组件内部决定。体积小且仅此处使用的库放进 dependencies,体积大的放进 peerDependencies。
在 Twig 中挂载 React 组件
这些 Symfony 项目不是单页应用,所以组件只在模板需要的位置挂载。一个入口文件维护 COMPONENT_MAP,在所有使用该组件库的 Symfony 项目中保持完全一致。映射中的名称就是 TypeScript 组件名,也是 data-react-component 属性的值:
const COMPONENT_MAP = { Button, Badge, AlertBanner, NavCard /* ... */ }
document.querySelectorAll('[data-react-component]').forEach((el) => {
const Component = COMPONENT_MAP[el.dataset.reactComponent]
if (!Component) return console.warn('Component not found in COMPONENT_MAP')
const props = el.dataset.props ? JSON.parse(el.dataset.props) : {}
createRoot(el).render(<Component {...props} />)
})
在 Twig 中,props 以 JSON 形式传入。有个细节曾让我们花了不少时间:属性必须使用单引号,因为 Twig 会把双引号转义成 ",导致 JSON.parse 失败。
<div data-react-component="Button"
data-props='{{ {"variant": "primary", "href": path("home"), "children": "开始"} | json_encode | raw }}'></div>
使用语义化版本
组件库遵循语义化版本:CSS 修复或令牌修正用 patch;新增组件或向后兼容的 prop 用 minor;任何会破坏使用方的改动(如重命名组件、prop 或令牌)用 major。新建组件前有一份简短的检查清单:现有组件能否通过多加一个 prop 来覆盖?它是否从第一天起就需要 variant 或 state?是否已从统一出口文件导出,并加入 COMPONENT_MAP?
用 pnpm 引入:link 还是 git
组件库位于独立的仓库中,Symfony 应用可以通过两种方式引用它:
- 本地链接(
link:../design-system):指向放在应用旁边的一个克隆的软链接。改动立即生效,适合正在修改设计系统本身的人。Vite 以 watch 模式重新编译,Webpack Encore 通过软链接感知变化。 - Git(
github:example-org/design-system#develop):由 pnpm 下载。只开发应用本身的同事需要的是这种方式,分支提交时也必须处于这种模式,这样任何人克隆后直接pnpm install就能运行,无需同时克隆组件库。
两者无法共存:package.json 和锁文件只能反映一个来源。我们用两个脚本来切换:
pnpm run theme:link && pnpm install # 实时修改设计系统
pnpm run theme:git && pnpm install # 回到已提交的状态
为什么要提交编译好的 dist
pnpm 10 会阻止从 git 安装的依赖执行构建脚本。在安装时编译的 prepare 脚本,在不同 pnpm 版本之间表现得很脆弱。务实的做法是在组件库仓库中提交编译好的 dist,这样使用方在安装时完全不需要编译。代价也很明确:组件库的每次改动,都需要重新构建并提交 dist。
让本地链接无法被误提交
本地链接的风险显而易见:package.json 和锁文件会显示为已修改,迟早有人会把它们提交上去。我们用两层机制来防止:
- 隐藏。 切换到 link 模式时,脚本用
git update-index --skip-worktree标记这两个文件,使它们从git status中消失;切回 git 模式时取消标记。 - 安全网。 一个
pre-commit钩子会拒绝任何包含本地link:路径的提交。它通过prepare脚本设置core.hooksPath自动安装,因此对整个团队生效,无需手动配置。
skip-worktree 只对当前克隆生效,所以想使用 link 模式的开发者各自启用一次即可。如果 git pull 因这两个文件报错,切回 git 模式就能解除标记。
我会保留的做法
在任何类似项目中,我都会重复两点:在写组件之前先把变体合并为 props;把团队的开发流程(link 与 git、钩子、提交构建产物)视为设计系统本身的一部分。只有当团队能够顺畅地使用这些组件时,它们才真正有价值。