Skip to content

KUI 自研 React 组件库:从 Monorepo 到 Headless 组件和发布流水线

git: https://github.com/fengnovo/kui

最近整理了一个自研 React 组件库 @fengnovo/kui。它不是只写几个按钮、下拉框再发个 npm 包,而是把一套生产级组件库应该有的工程链路都跑了一遍:

  • Monorepo 包管理和构建编排。
  • Design Token 到 CSS Variables 的主题底座。
  • cva 管组件样式变体。
  • Headless Hook 沉淀复杂交互。
  • tsup 输出 ESM + CJS + 独立组件入口。
  • Vitest、axe、Playwright、Storybook 做质量门禁。
  • Changesets 和 GitHub Actions 走两段式发布。

这篇文章基于项目里的 README.mddocs/impl-guide.md 重新整理。实现指南更像施工手册,这里会更偏读者视角:先讲清楚为什么要这样拆,再讲每一层怎么落地。

先用大白话理解

很多人做组件库,第一反应是“建个 components 目录,把 Button、Input、Select 放进去”。这当然能跑,但还不算组件库。

真正麻烦的是后面这些事:

  • 颜色、圆角、间距怎么统一,不要每个组件自己写一套。
  • 复杂组件的键盘操作、焦点、受控/非受控、无障碍怎么稳定复用。
  • 组件怎么按需引入,避免引一个 Button 打进去半个库。
  • CSS 怎么发布,不能被 tree-shake 摇掉。
  • 类型在 ESM 和 CJS 下都要正确。
  • 每次改动怎么测试、发版、回滚。

所以 KUI 的核心不是“写 Button”,而是先把组件库的生产线搭出来。

令牌颜色、字号、圆角先变成可复用的设计变量。
样式组件 CSS 只消费变量,variant 和 size 由 cva 映射。
行为Select 这类复杂交互下沉到 Headless Hook。
渲染React 组件只把状态映射成 DOM、className 和 aria 属性。
交付测试、文档、产物体检、版本发布都进流水线。

大白话说:组件库不是“组件集合”,更像一条小型工厂流水线。Button 和 Select 是产品,令牌、构建、测试、发布才是让产品稳定批量生产的机器。

它解决什么问题

自研组件库最容易翻车的地方,通常不是第一个组件写不出来,而是第十个、第二十个组件开始失控:

问题常见后果KUI 的处理方式
样式没有统一源头颜色、间距、暗色主题到处手写DTCG Token -> Style Dictionary -> CSS Variables
交互逻辑散在组件里Select、Popover、Modal 各自处理键盘和焦点,bug 重复出现Headless Hook 把状态机、键盘、ARIA 独立出来
打包只顾本地可用子路径导出、CSS 副作用、CJS 类型解析出问题tsup + exports + sideEffects + publint + attw
质量只靠人工点改样式不知道影响了哪些状态Vitest + axe + Playwright 视觉回归
发版不可控push 后直接 publish,破坏性变更混进去Changesets 两段式 Version PR + api-extractor

这里有个关键判断:一个组件库的工程价值,主要体现在“边界”上。组件内部写得漂亮当然重要,但更重要的是它和主题、构建器、测试、文档、消费方应用之间的边界是不是稳定。

包布局

KUI 使用 pnpm + Turborepo 做 monorepo。npm scope 不能嵌套,所以用 @fengnovo 作为 scope,用 kui 作为包名前缀。

目录说明是否发布
@fengnovo/kuipackages/kui组件主包,包含 Headless、渲染层和样式公共 npm
@fengnovo/kui-tokenspackages/tokens设计令牌和 Style Dictionary 产物公共 npm
@fengnovo/kui-iconspackages/icons图标包,规划中,可独立升版本公共 npm
@fengnovo/kui-tsconfigpackages/tsconfig内部共享 TypeScript 配置private
@fengnovo/kui-eslint-configpackages/eslint-config内部共享 ESLint 配置private
docsapps/docsStorybook 文档站private
e2ee2ePlaywright E2E 和视觉回归private

整体依赖关系可以画成这样:

公共发布包tokens → kui → icons@fengnovo/kui-tokens 提供 CSS Variables 和 TS 常量,@fengnovo/kui 消费它,图标包后续可独立发布。
内部工程包tsconfig / eslint-config共享 TypeScript 与 ESLint 规则,只在工作区内复用,不发布到 npm。
文档和质量Storybook → Playwrightapps/docs 消费本地组件包,e2e 再把 Storybook 当作截图和交互测试的渲染源。
tokens生成主题变量和类型常量。
kui实现 Button、Select 等组件。
docsStorybook 展示组件状态。
e2e基于 Storybook 跑交互和视觉回归。

这个图里最重要的是依赖方向:tokens 是底座,kui 消费它,文档和 E2E 消费 kui。不要让业务文档、测试工具、组件实现互相反向依赖,否则 monorepo 很快会变成一团线。

三层架构:令牌、行为、渲染

KUI 的组件分成三层:

DTCG Token设计令牌是颜色、间距、圆角的唯一事实源。
CSS VariablesStyle Dictionary 产出 vars.csstheme-dark.css
组件 CSS + cva样式只消费变量,变体只映射 className。
+
Headless Hook沉淀状态、键盘、ARIA 和受控逻辑。
React 组件把行为和样式拼成 Button、Select。

这套分层的好处是每层只管自己的事:

负责什么不负责什么
令牌层品牌色、语义色、暗色主题、多品牌变量不知道 Button 长什么样
样式层className、尺寸、变体、hover、disabled、focus不处理键盘和选中状态
行为层open、activeIndex、selected、键盘导航、受控/非受控、ARIA不关心颜色和 DOM 样式
渲染层把行为和样式拼成真正的 React 组件不重新实现状态机

说得更直白一点:颜色归颜色,交互归交互,DOM 归 DOM。复杂系统最怕所有东西揉在一个组件文件里,短期写得快,长期很难测,也很难改。

Design Token:主题先有地基

组件库如果想支持暗色、多品牌、统一换肤,就不能让组件 CSS 到处写 #3b82f6 这类具体色值。

KUI 的做法是三步:

基础令牌color.blue.500neutral.900 这类原材料。
语义令牌brand.primarybg.default 这类组件能理解的语言。
Style Dictionary把 JSON 编译成 CSS 和 TS 产物。
变量作用域:root 提供默认主题,[data-theme='dark'] 覆盖暗色。
组件消费组件 CSS 只写 var(--brand-primary)

基础令牌像原材料,比如蓝色 500、灰色 900。语义令牌像业务语言,比如主品牌色、默认背景色、正文颜色。组件只吃语义令牌,不直接吃原材料。

这样做有两个好处:

  1. 暗色主题只改语义层,组件代码不用动。
  2. 多品牌只切变量作用域,不需要重新打包组件。

组件 CSS 里类似这样:

css
.kui-btn--solid {
  background: var(--brand-primary);
  color: var(--text-on-brand);
}

.kui-btn--outline {
  border-color: var(--brand-primary);
  color: var(--brand-primary);
}

这也是为什么项目里会配 stylelint:组件 CSS 不允许随手写裸色值。规范只靠口头约定很容易失效,必须让工具帮忙守住。

样式变体:cva 只做映射

Button 这类组件通常会有 variantsizeloadingdisabled 等状态。KUI 用 class-variance-authority 处理变体映射:

ts
export const buttonVariants = cva('kui-btn', {
  variants: {
    variant: {
      solid: 'kui-btn--solid',
      outline: 'kui-btn--outline',
      ghost: 'kui-btn--ghost',
    },
    size: {
      sm: 'kui-btn--sm',
      md: 'kui-btn--md',
      lg: 'kui-btn--lg',
    },
  },
  defaultVariants: { variant: 'solid', size: 'md' },
})

它的定位很克制:只把 props 映射成 className,不接管主题,也不把样式塞进 JS。这样组件仍然是 CSS Variables 驱动,SSR 和 RSC 场景也更稳。

构建产物:别只看 dist 里有没有文件

组件库打包最容易“本地能用,上 npm 后出事”。KUI 的构建目标不是简单生成 dist,而是同时满足:

  • ESM 主格式,给现代打包器做静态分析和 tree-shaking。
  • CJS 兼容,照顾仍然用 require 的场景。
  • 独立组件入口,例如 @fengnovo/kui/button
  • CSS 独立产出,不内联到 JS。
  • CSS 标记 side effects,避免被构建器误删。
  • ESM 和 CJS 下类型解析都正确。
入口src/index.tssrc/button/index.tssrc/select/index.ts
tsup统一打包 JS、类型和 CSS。
dist 产物ESM、CJS、.d.ts.d.cts、独立 CSS。
体检publint 检包导出,attw --pack 检类型解析。
发布Changesets 接管版本号和 changelog。

这里有一个真实落地时的细节:实现指南最初写的是扁平 types,但项目最终改成了 condition-specific types。

简化后大概是:

json
{
  "exports": {
    "./button": {
      "import": {
        "types": "./dist/button/index.d.ts",
        "default": "./dist/button/index.js"
      },
      "require": {
        "types": "./dist/button/index.d.cts",
        "default": "./dist/button/index.cjs"
      }
    }
  }
}

为什么要这么麻烦?因为 CJS 消费者如果拿到 ESM 风味的 .d.tsattw 会报类型解析问题。既然指南里把 publintattw 全绿列为 Done 标准,那真实工程就应该以校验门槛为准,而不是死守示例写法。

大白话说:package.jsonexports 就像包裹清单。ESM 用户来拿 ESM 的货和类型,CJS 用户来拿 CJS 的货和类型,不能大家都从同一个窗口乱拿。

Button:先打通一条完整流水线

KUI 先做 Button,不是因为 Button 最难,而是因为它最适合验证整条工程链路。

组件代码forwardRef、原生 props、loading 边界。
样式CSS Variables + cva 变体。
测试Vitest、Testing Library、axe。
文档Storybook autodocs 和交互态。
产物独立入口、CSS、Changeset。

Button 里有几个小但关键的边界:

  • loading 时要自动禁用,避免重复提交。
  • aria-busy 只在 loading 时出现。
  • disabled || loading 要在组件内部合并,不能被 {...rest} 覆盖。
  • spinner 用 currentColor,这样不同 variant 下不用单独配色。
  • ref、原生 button props、className 合并都要正常工作。

这些点看起来琐碎,但组件库的稳定性就是靠这些琐碎边界堆出来的。Button 跑通后,后续组件不是重新摸索,而是沿着同一条轨道继续铺。

Select:组件库真正的分水岭

Button 更多考验样式和基础 API,Select 才开始考验组件库有没有“行为内核”。

一个可用的 Select 至少要处理:

  • 打开和关闭。
  • 当前选中值。
  • 键盘上下移动。
  • Home / End 跳到首尾。
  • Enter / Space 提交。
  • Escape 关闭。
  • 禁用项跳过。
  • 受控和非受控。
  • role="combobox"listboxoptionaria-activedescendant

如果这些逻辑全写在 JSX 里,测试会很痛苦。KUI 的做法是把行为抽成 useSelect,渲染层只拿 prop getters:

输入参数optionsvaluedefaultValueonChange
内部状态openselectedactiveIndex
键盘处理Arrow、Home、End、Enter、Space、Escape。
prop getters生成 trigger、list、option 需要的属性。
渲染层button + ul + li 负责 DOM 和 className。

状态流转可以更直观看:

Closed初始关闭态,点击、ArrowDown、ArrowUp 或 Space 都可以打开。
Open打开后用 Arrow / Home / End 移动 activeIndex,并自动跳过 disabled 选项。
CommitEnter 或 Space 提交当前选项,受控模式只触发 onChange,非受控模式同步内部 selected。
CloseEscape、外部点击或提交完成后关闭列表,焦点仍留在 trigger 上。

这套设计里最重要的不是 useSelect 的代码本身,而是边界划分:

部分放在哪里原因
open、selected、activeIndexuseSelect可单独测,不依赖 DOM 样式
键盘移动和 disabled 跳过useSelect属于行为规则,所有渲染形态都要一致
aria-* 属性prop getters行为层统一生成,渲染层直接拼装
className 和 DOM 结构select.tsx让 UI 可以变化,不污染行为内核
外部点击关闭渲染层 effect依赖真实 DOM,放到内核会变脏

大白话说:useSelect 像变速箱,Select.tsx 像车壳。车壳可以换,但变速箱里的换挡规则不能每台车重新写一遍。

测试体系:不是只测能不能点

组件库的测试要覆盖五类风险:

Headless 单测验证状态机、键盘移动、禁用项跳过。
组件集成用 Testing Library 从用户视角触发事件。
axe自动检查严重无障碍问题。
Storybook沉淀组件状态和人工验收入口。
Playwright跑浏览器交互和视觉截图,进入 CI。

每一层解决的问题不一样:

测试主要盯什么例子
Headless 单测纯行为逻辑ArrowDown 是否跳过 disabled 选项
Testing Library用户视角交互loading Button 是否禁用
axe无障碍违规Button、Select 是否有严重 a11y 问题
Storybook文档和人工验收所有 variant、size、受控示例
Playwright浏览器真实行为和视觉变化Select 打开态截图是否异常

项目里还有一个很真实的 CI 细节:视觉基线带操作系统差异。本地 macOS 会生成 -darwin.png,GitHub Linux runner 需要 -linux.png。所以仓库里加了 visual-update.yml,专门在 Linux runner 上手动生成视觉基线并提交。

这类细节很小,但生产项目经常就是卡在这里。视觉回归不是“加个截图断言”就结束了,还要考虑基线在哪台机器生成、什么时候更新、谁来 review。

Storybook:不只是展示页面

KUI 里 Storybook 有三层作用:

  1. 给使用方看组件状态和 Props。
  2. 给开发者做交互验收。
  3. 给 Playwright 当稳定渲染源。
Storiespackages/kui/src/**/*.stories.tsx 描述组件的主要状态。
Autodocs从 TypeScript Props 自动生成文档表。
addon-a11y在文档站里直接看交互态的无障碍问题。
Playwright打开指定 story,执行 E2E 和截图断言。

把 Storybook 当视觉回归源有个好处:每个组件状态都是可复现的独立页面。测试不用绕过业务页面的登录、路由、接口数据,直接打开对应 story 就能截图。

发布流水线:先 Version PR,再 publish

KUI 的发版不是 push 到 main 就直接发 npm,而是 Changesets 的两段式流程:

功能 PR开发者提交代码和 changeset,说明这次应该 patch、minor 还是 major。
CI 门禁PR 先跑 lint、typecheck、build、test、E2E、视觉回归和 api check。
合并 main通过 review 后进入主分支,release workflow 被触发。
Version Packages PRchangesets/action 自动升版本号并生成 changelog,等待人工 review。
正式 publishVersion PR 合并后再次触发 release,执行 changeset publish 并带 provenance。

这套流程的意义是:真正发版前,会先有一个可 review 的 Version PR。里面包含版本号提升和 changelog 更新。你可以在 publish 前确认这次到底发了哪些包、升了什么版本、说明是否准确。

同时项目里还有两个门禁:

  • api-extractor 生成公共 API 报告,公共 API 变化要能被 review。
  • canary.yml 支持手动发布 canary tag,先给真实项目试用,再走正式版。

大白话说:正式发版要像过闸,不要像从楼上往下扔包。每道闸都知道自己拦什么:CI 拦坏代码,API 报告拦破坏性变更,Version PR 拦不透明发版,canary 拦上线风险。

当前落地进度

这套组件库目前已经把从 Step 1 到 Step 9 的主链路跑通:

Step内容状态
Step 1Monorepo 骨架,pnpm + Turborepo + 共享 tsconfig/eslint已落地
Step 2tsup 双格式打包,独立入口,publint / attw 校验已落地
Step 3DTCG Token + Style Dictionary 4已落地
Step 4CSS Variables + cva 样式体系已落地
Step 5Button 垂直切片,打通代码、样式、测试、文档、产物已落地
Step 6Select Headless 内核,键盘、ARIA、受控/非受控已落地
Step 7Vitest、Testing Library、axe、Playwright、视觉回归已落地
Step 8Storybook 8 文档站已落地
Step 9Changesets + GitHub Actions 发布流水线配置就绪

一个容易误解的点是:当前组件数量还不多,但这不是问题。对自研组件库来说,早期最重要的不是一口气铺 50 个组件,而是先证明“每个新组件都能沿着同一套标准交付”。

组件开发 DOD

后续每新增一个组件,至少要满足这些 Done 标准:

  • 有独立入口,例如 src/<name>/index.ts
  • 样式只消费语义令牌变量,不写裸色值。
  • 复杂交互下沉到 Headless Hook。
  • 覆盖受控/非受控、键盘、禁用态等关键测试。
  • 至少有一条 axe 无障碍断言。
  • Storybook 覆盖主要变体和交互状态。
  • 有视觉基线,关键状态能被 Playwright 截图。
  • 公共 API 类型清楚,autodocs 能生成 Props 表。
  • 有 changeset 说明这次变更。

这个清单看起来多,但它会把“组件能用”和“组件可长期维护”分开。业务项目里能跑的代码,不一定适合进入组件库;组件库里的代码,要默认被很多项目、很多版本、很多构建器消费。

几个踩坑点

第一,CSS 产物一定要标记副作用。

json
{
  "sideEffects": ["**/*.css"]
}

否则消费方构建时可能认为 CSS 没有被 JS 使用,然后把样式摇掉。组件能渲染但没样式,这种问题排查起来很烦。

第二,子路径导出要真的测。

@fengnovo/kui@fengnovo/kui/button 都能导入,不代表它们都能正确 tree-shake,也不代表 CJS 类型没问题。publintattw --pack 必须进发布前校验。

第三,受控组件不要覆盖外部值。

判断受控要用 value !== undefined,受控模式下内部 state 不能偷偷改 selected。否则业务一接表单库,很快就会出现状态不同步。

第四,视觉回归要把环境差异设计进去。

字体、系统、浏览器渲染都会影响像素。不要等 CI 第一次失败才想“为什么我本地明明过了”。基线生成和更新流程要提前设计。

第五,Storybook 必须引用 workspace 本地包。

文档站是当前组件的验收入口,应该消费 workspace:*,不是消费上一个已发布版本。否则你在 PR 里改了组件,Storybook 看的却还是旧代码,视觉回归就失去意义了。

最后总结

KUI 这套实现最值得复用的不是某个 Button 或 Select 的具体代码,而是这条工程路径:

text
先锁技术栈
  -> 建 monorepo
  -> 打通构建产物
  -> 建令牌和样式规范
  -> 用 Button 验证最小闭环
  -> 用 Select 验证复杂交互内核
  -> 补齐测试、文档、视觉回归
  -> 接入 Changesets 和 CI 发版

自研组件库真正难的是“每个组件都能被一致地生产、验证和发布”。只要这条线稳定了,后面铺 Input、Modal、Tabs、Table、Form 就不再是重新开荒,而是沿着既有轨道持续扩展。