项目:react-ui | 版本:v1.0 | 生成日期:2026-10-06 | 依据:源码分析
| 项 | 要求 | 来源 |
|---|---|---|
| Node.js | 22(本机走 nvm:export PATH="$HOME/.nvm/versions/node/v22.15.0/bin:$PATH") | CONTRIBUTING.md、CI setup-node node-version: 22 |
| 包管理器 | pnpm 9.12.3(packageManager 字段锁定到 sha512) | 根 package.json |
| 浏览器(e2e) | Chromium:pnpm exec playwright install chromium | CONTRIBUTING.md |
| npm 账号(发布) | @xiaoye-react scope 权限 + 2FA 交互登录:npm login --scope=@xiaoye-react --auth-type=interactive | 根 README 前置步骤 |
git clone https://github.com/zhangzhengyang27/react-ui.git
cd react-ui
pnpm install # workspace 全量安装(pnpm-workspace.yaml 三个 glob)
pnpm dev # 全 workspace 并行 dev:包 watch 构建 + 文档站
# 等价拆分:
pnpm dev:packages # 仅 packages/** 的 vite build --watch
pnpm dev:apps # 仅 apps/**(文档站 dumi dev)文档站单独启动时有前置钩子(apps/docs/package.json):
# predev/prebuild 自动执行:
node ../../scripts/ensure-ui-build.mjs # 确保 packages/ui 有 es/ 构建产物
pnpm docs:gen # tsx scripts/docgen.ts + generate-css-exports.ts(生成 API 表素材)
pnpm dev # = dumi dev(端口默认 8000 系)
pnpm build # = ensure-ui-build + docs:gen + typecheck(demos) + dumi buildpnpm test:packages # 全部 vitest 单测(ui 336 + hooks 30 等测试文件)
pnpm --filter @xiaoye-react/ui test:watch # 单包 watch 模式
pnpm typecheck # ui + hooks + docs demos 三套 tsconfig(见根 scripts)
pnpm e2e:interactions # 24 条交互用例:自动起 Vite harness(127.0.0.1:4175)
pnpm e2e:screenshots # 10 条截图基线:托管 apps/docs/dist(127.0.0.1:4173)
pnpm e2e:update-snapshots # 更新截图基线两个端口相关的坑(CONTRIBUTING.md 与配置注释明确记录):
e2e:interactions 的 globalSetup: './e2e/interactions/assert-harness.ts' 会先校验 4175 端口上是 harness 本尊(比对 <title>)——webServer.reuseExistingServer 只看端口有无 HTTP 响应、不认是谁,别的程序占了端口会对着错误站点跑出满屏 element(s) not found;expect.toHaveScreenshot.maxDiffPixelRatio: 0.02 允许轻微字体反锯齿差异,且 animations: 'disabled' 强制关动画防逐帧差异;截图基线跨平台不通用,所以暂不入 CI(CI 注释原话)。pnpm lint # = lint:es + lint:style + spellcheck
pnpm lint:es # eslint,NODE_OPTIONS=--max-old-space-size=8192,--max-warnings=57(棘轮)
pnpm lint:style # stylelint "{packages,apps}/**/*.{css,ts}"
pnpm spellcheck # cspell lint(缓存 + 建议模式,词典在 cspell.json)棘轮规则:--max-warnings=57 只允许调低不允许回涨;「故意不修」的豁免理由必须写在 eslint.config.mjs 注释里,不留注释的豁免会被清理(CONTRIBUTING「债务政策」)。exhaustive-deps 台账同规则管理。
pnpm build:packages # hooks → ui(ui 的 prebuild 先构建 hooks)
pnpm build # 递归全部包(含文档站 dumi build)
pnpm check:exports # 构建之后才能跑:校验 exports 指向真实文件| 产物 | 说明 |
|---|---|
es/index.js + 按模块的 *.js | ESM;preserveModules: true(preserveModulesRoot: 'src')保留模块边界,下游可摇树;每个模块顶部有 'use client' banner |
es/index.cjs + *.cjs | CJS(exports: 'named');不加 directive banner(避免挤掉 'use strict') |
es/index.d.ts + 分散声明 | vite-plugin-dts 发射;类型错误会让构建直接失败(afterDiagnostic 抛异常);beforeWriteFile 修复 __ 前缀标识符的加下划线 bug |
es/style.css(ui / pro) | 全量样式;minify 保持开启(配置注释实测:关闭会让 CSS 压缩链停摆、style.css 丢 8 处 -webkit/-moz 前缀) |
es/ 之外的东西 | 全部不发(files: ["es"]) |
按需加载支持:不在构建期拆 chunk,而是靠 preserveModules + 外部化(isExternal 按包名段匹配 dependencies/peer/react)把摇树权交给下游打包器。配置注释给出实测数字:单文件产物曾让 import { Button } 从 12KB gzip 涨到 327KB gzip 并拖进 rrule/react-dropzone。运行时依赖不打进产物(dayjs 的 locale/extend、@emotion/cache 插入点都是全局状态,双实例即 bug)。
发布前类型复核:ui 的 build 后追加 check:published-types——对 es/index.d.ts 以 --skipLibCheck false --module esnext --moduleResolution bundler 再编译,确保发出的声明文件独立可用。
package.json(当前 ui 3.0.1 / hooks 1.4.1 / pro 0.2.2),不用 changesets;pnpm install 确认 lockfile 是否需要同步提交(只改 version/peer 通常不动);apps/docs/CHANGELOG.zh-CN.md(中文);git 标签按 ui 的版本打 v<version> 附注标签。scripts/release.mjs,283 行,与 README 手工步骤一一对应)pnpm release:preflight # ① 本地预检:工作树干净、在 main、与 origin/main 对齐、
# 三包版本可解析、ui 的 peer 下限能被本次 hooks 版本满足
# (不联网、不构建)
pnpm release:check # ② 预检 + hooks→ui→pro 依次「测试 → dry-run」
# (测试显式跑:包级 prepublishOnly 只有 build 不含测试;
# dry-run 列出将上传清单,相当于排练一遍)
pnpm release:publish --otp=123456
# ③ 正式发布:每包先测试再发布 → 发一个立刻核对注册表版本
# → 消费者侧 ESM 复验(比手工多验 pro 具名导出)
# → 按 ui 版本打附注 tag 并推送(--no-tag 可跳过)
# 断点续跑:pnpm release:publish --from=@xiaoye-react/ui --otp=…(之前的包只核对不重发)顺序固定 hooks → ui → pro:脚本头注释说明理由——ui 的 peer 指向 hooks、pro 的 peer 指向 ui 与 hooks,反序会出现「peer 指向注册表上还不存在的版本」的窗口。satisfiesCaret 是脚本自带的手写 ^x.y.z 语义实现(不跨 major),用于核对 peer 下限;无法判断的形态(含 workspace: 协议)返回 null「交给人」。
在 /tmp/verify 全新目录 npm i @xiaoye-react/ui@latest @xiaoye-react/hooks@latest react@19 react-dom@19 后,用纯 Node ESM import { UIProvider, Button, useMatches } from '@xiaoye-react/ui' 验证具名导入。README 解释了为什么这步不可省:vitest、Vite harness、webpack 文档站都挑 module 字段那份构建,只有纯 Node ESM 走 main 的 CJS 构建——rrule 这类 CJS/ESM 双入口依赖的具名导出问题,只有这一步能暴露(产物侧的对策是「命名空间取值、取不到回退 default、default 用计算键访问」)。
workspace: 协议只有 pnpm publish 会重写成真实范围(pro 的 peer 是 workspace:^,裸 npm publish 直接坏);attachMediaListener 是 hooks 1.4.0 才有的导出);ERR_PNPM_GIT_UNCLEAN——连未跟踪文件都算不干净;npm deprecate。Button.test.tsx 风格),共享测试资产在内部包 @xiaoye-react/tests(core/dates 工具);ui 336 个、hooks 30 个测试文件(源码 find 统计);vitest.config.ts + src/test-setup.ts(jest-dom 等匹配器);merge-ui-theme.test.ts 可见一斑:测试编码行为语义(如「显式 undefined 的 primaryShade 按未配置处理」「不污染父主题」),CI 裁决原则即「测试显式编码的行为 > 审查者直觉」(CONTRIBUTING.md,档案在 archive/audit/)。| 维度 | playwright.interactions.config.ts | playwright.config.ts |
|---|---|---|
| 目标 | jsdom 盲区:pointer 命中、合成事件顺序、transitionend | 视觉回归截图(10 条基线) |
| 承载 | Vite harness(e2e/harness/,直连 ui/hooks 源码,4175 端口,strictPort) | 托管 apps/docs/dist(scripts/serve-docs-dist.mjs,4173 端口) |
| 用例 | 24 条:app-shell/collapse/inputs/modal/rating/slider/virtual-table/docs-demos 八个 spec | e2e/screenshots.spec.ts,maxDiffPixelRatio: 0.02,animations: 'disabled' |
| 守卫 | assert-harness.ts globalSetup 比对 <title> 防端口劫持 | reuseExistingServer: true |
| 入 CI | 独立 e2e job(ubuntu-latest,20 分钟超时) | 暂不入 CI(基线跨平台不通用,README/CI 注释注明) |
harness 目录有四个宿主页(index.html/interactions.html/demos.html/app-shell-fixed.html + 对应 tsx 挂载点),用例直接操作真实 DOM;docs-demos.spec.ts 配 docs-demos-known.json 对文档站 demo 做交互扫描。
pnpm docs:lint # = lint-docs-imports.mjs + lint-demo-controls.mjs + lint-docs-api-tables.mjs三个脚本(apps/docs/scripts/)分别核对:文档 import 可解析、demo 控制项引用的 prop 名真实存在、API 表逐行与类型定义一致。跨包继承的成员解析依赖 hooks 构建产物类型——所以 docs:lint 与 check:exports 都必须排在 build:packages 之后(CI 注释解释了这是一次真实的历史教训:「本地 es/ 常驻所以从没暴露,干净克隆直接 TS2307」)。
| 命令 | 作用 | 顺序约束 |
|---|---|---|
pnpm lint | eslint 棘轮 + stylelint + cspell | 随时可跑 |
pnpm typecheck | ui(产物 tsconfig.typecheck.json)+ hooks + docs demos | 随时可跑 |
pnpm build:packages | 三包产物 | 是后面三项的前置 |
pnpm check:exports | 导出面校验 | 必须在 build 之后 |
pnpm docs:lint | 文档门禁 ×3 | 必须在 build 之后 |
pnpm test:packages | 全部单测 | 单测经 workspace 协议解析 hooks 的 es/ 产物,放构建后 |
pnpm e2e:interactions | 24 条浏览器交互 | 需 chromium |
.github/workflows/ci.yml)workflow_dispatch);concurrency 按 ref 取消过期运行;pnpm install --frozen-lockfile → lint → typecheck → build:packages → docs:lint → check:exports → test:packages;commitlint.config.cjs,config-conventional)+ cz-git 交互生成:pnpm commit;git status --porcelain 必须为空且与 origin/main 对齐——这同时是发布预检的前提(两处复用同一约束)。# 新增组件(完整动作清单见 CONTRIBUTING.md)
# 1) packages/ui/src/components/<Name>/:组件 + module.css + factory/StylesApi(对齐 Button)
# 2) 导出进 components/index.ts → pnpm check:exports
# 3) 同目录 *.test.tsx;jsdom 盲区补 e2e/interactions 用例
# 4) apps/docs/components/<name>/index.zh-CN.md + demo/ → pnpm docs:lint
# 本地验证文档站构建产物(截图 e2e 同款服务)
node scripts/serve-docs-dist.mjs
# 发布演练(不联网)
pnpm release:check
# 发布中断后从 ui 续跑
pnpm release:publish --from=@xiaoye-react/ui --otp=123456把门禁清单再看一遍会发现一个反直觉的事实:这个仓库的「测试」概念远大于单测。文档 API 表逐行核对(docs:lint)本质上是把文档变成了类型系统的投影——类型改了文档没改,CI 直接红;demo 控制项核对把示例代码变成了 props 的投影;check:exports 把 package.json 的 exports 变成了磁盘文件的投影;消费者侧 ESM 复验把「Node 纯 ESM 视角」这个所有内部工具都照不到的盲区单独补上。三个投影加一个盲区补光,构成「接口不漂移」的完整闭环。e2e 的分层同样有讲究:单测(jsdom)覆盖逻辑与 DOM 语义,交互 e2e(真实 chromium)只接 jsdom 的三个已知盲区(pointer 命中、合成事件顺序、transitionend),截图 e2e 只管视觉回归——每一层都有明确的「为什么存在」,没有为覆盖率数字而堆的用例。理解了这套哲学,就明白为什么 CONTRIBUTING 敢写「与 CI 完全一致,本地跑过的 CI 一定过」:本地命令与 CI 步骤一字不差,没有任何只在 CI 存在的隐藏步骤。
CONTRIBUTING 与 README 都强调本机 node 走 nvm,每次新开 shell 需要先 export PATH="$HOME/.nvm/versions/node/v22.15.0/bin:$PATH"。CI 侧则直接 setup-node node-version: 22 并开启 pnpm 缓存(cache: pnpm),安装一律 --frozen-lockfile——本地复现 CI 环境的最快方式是确认 pnpm --version 输出 9.12.3(packageManager 字段会在版本不符时报错拦截,这是 pnpm 的内建保护)。
文档站(dumi 2.4.47,打了本地 patch patches/dumi@2.4.47.patch)的启动有两个易忽略的前置:scripts/ensure-ui-build.mjs 会确保 packages/ui/es/ 存在,docs:gen 会用 scripts/docgen.ts 与 generate-css-exports.ts 生成文档素材。如果「文档站启动了但组件文档 API 表为空」,先检查这两步是否执行(pnpm dev:apps 会经 predev 自动跑,直接 dumi dev 则不会)。此外根 tsconfig.json 的 paths 与 .dumirc.ts 的 alias 都必须把 workspace 包指回 src——README 警告过:漏一个的包只在「本地恰好留着旧 es/ 产物」时看起来是绿的,干净克隆里 typecheck/build 直接 TS2307 级失败。
| 症状 | 原因 | 处置 |
|---|---|---|
pnpm publish dry-run 报 ERR_PNPM_GIT_UNCLEAN | pnpm 把未跟踪文件也算不干净 | 提交或 git status --porcelain 核对后清理 |
e2e 全屏 element(s) not found | 4175 端口被别的程序占用,reuseExistingServer 误连 | 杀掉占口进程;assert-harness.ts 会先比对 <title> 拦一道 |
| 截图基线大面积差异 | 字体渲染跨平台差异 | 基线按平台生成;这也是截图回归暂不入 CI 的原因 |
docs:lint 的 api-tables 报缺类型 | hooks 等依赖包未构建 | 先 pnpm build:packages |
check:exports 报导出缺失 | exports 指向的文件未生成或路径写错 | build 之后重跑;核对 files: ["es"] 与产物布局 |
| ESM 消费报「缺具名导出」 | 依赖包 CJS/ESM 双入口(如 rrule)的互操作回归 | 走「命名空间取值→回退 default(计算键)」写法;跑消费者侧复验 |
pnpm dev 后文档站 API 表为空 | ensure-ui-build / docs:gen 未执行 | 用 pnpm dev:apps 或先 pnpm build:packages |
column/:专栏文章素材目录,非运行代码,不参与构建;archive/:历史归档——两轮全量审计(archive/audit/)与 12 条逐文件审查清单 CHECKLIST.md,是理解「为什么这样定规矩」的第一手材料;日常开发不需要进入;patches/:dumi@2.4.47.patch,经 pnpm.patchedDependencies 自动应用;@types/:仓库级环境类型声明,vite-plugin-dts 的 include 显式包含它(../../@types/**/*.d.ts);apps/nas-console/:示例控制台应用,当前检出版本只含 dist 构建产物(源码未随仓库检出 ⚠️ 待确认其源码管理位置);test-results/:Playwright 运行产物,属生成物。以「给 DataTable 增加一个列宽拖拽能力」为例走一遍全流程:先在 packages/ui/src/components/DataTable/ 改 DataTable.tsx 与 DataTable.module.css(若需要 hooks 级拖拽原语,先看 @xiaoye-react/hooks 是否已有——use-splitter/use-drag/use-move 可复用);补同目录单测(受控/非受控、边界宽度);涉及 pointer 命中的交互补 e2e/interactions 用例(可挂进现有 virtual-table.spec.ts 或新建 spec);跑 pnpm lint 确认棘轮没被打穿;pnpm typecheck 三套配置过;pnpm build:packages 后跑 check:exports 与 docs:lint;文档站补 index.zh-CN.md 的 API 表行与 demo(API 表每行必须能在类型里找到);最后 pnpm commit(cz-git 生成 conventional 提交,subject 中文讲清为什么)并直接推 main。发布时手动升 packages/ui 版本,走 release:preflight → release:check → release:publish 三档。
对首次接手发布的人,建议把第一次发布安排成「演练」:先跑 pnpm release:check(它会真实执行三包测试与 dry-run 构建,但不联网上传),把输出与 README 第 1 步的清单逐项对照,确认 files: ["es"] 列出的文件符合预期——dry-run 阶段 pnpm publish 会先执行包级 prepublishOnly(即重新 build,实测会刷新 es/index.js 的 mtime),所以列出的清单就是将来上传的清单。正式发布(release:publish)中途最可能的意外是注册表核对超时:此时包可能实际已入库(pnpm 已打印 + 包名@版本),不要盲目重发,先 npm view <包名> version 核对,确认后用 --from=<包名> 断点续跑。真需要回滚时记住 72 小时窗口:窗口内 npm unpublish 可彻底撤版,之后只能 npm deprecate <包>@<版本> --message "..." 打弃用标记并发布修复版。打标签(release:tag)读的是 packages/ui/package.json 的版本号——三包版本独立意味着 git tag 只锚定 ui 的版本线,这也是 CI 与 CHANGELOG 以 ui 版本为主线的结构性原因。
'use client',Next.js App Router 无需逐组件声明客户端;色彩方案用 ColorSchemeScript(packages/ui/src/core/UIProvider/ColorSchemeScript/)在 HTML 解析期写 data-ui-color-scheme 防 FOUC;@xiaoye-react/ui/style.css;主题经 CSS 变量注入 :root(或 cssVariablesSelector 指定的选择器),无运行时样式依赖(除非启用 emotion stylesTransform,届时按 emotion SSR 规范抽取关键样式,见 packages/ui/src/emotion/ssr.tsx);<style>(InlineStyles、emotion)都经 getStyleNonce 取 nonce;sideEffects: ["*.css"] 确保样式文件不被误摇;