首页
/ Storybook docgen-harness 深度解析:用快照比较器与性能基准守住多框架 Docgen 的正确性边界

Storybook docgen-harness 深度解析:用快照比较器与性能基准守住多框架 Docgen 的正确性边界

2026-09-06 17:59:51作者:毕习沙Eudora

@storybook/docgen-harness 是 Storybook 仓库内部的一个私有测试工具包,位于 code/lib/docgen-harness。它围绕 "docgen beyond React"(把文档提取能力扩展到 React 之外的框架)这一工程目标工作:把各框架遗留 docgen 管线今天真实产出的 argTypes 与代码片段记录下来作为基线快照,并用一个"current or better"(不低于现状)的比较器约束未来的 OSA 引擎,同时配套一套 docgen 性能与内存基准。读完本文,你将理解这套基线录制-比较机制的每一条规则、如何为 Vue3/Angular 添加 fixture 与捕获 compodoc 输入,以及如何运行、解读它的性能门禁。

它是什么、不发布什么

根据 READMEpackage.json:

  • 这是一个私有测试 harness("private": true),记录遗留 docgen 管线当前产出的 argTypes 和生成代码片段为经过 review 的快照,并以此把即将落地的 OSA 引擎约束在"持平或更好"的水平;
  • 目录中的一切不会发布到 npm;
  • 从源码结构看,它通过 src/index.ts 向外只暴露比较器的公共面:compareArgTypesexpectCurrentOrBetterparseArgTypesSnapshotcompareSnippetFramework/Violation 等类型,性能基准(src/perf/)是独立 CLI,不在导出 API 中。

运行方式与三类测试文件

从仓库根目录运行:

yarn test code/lib/docgen-harness      # 跑全部比较与基准自测
yarn test code/lib/docgen-harness -u   # 有意变更之后重录,然后 review diff

每个框架有三类测试文件:

  • *-baselines.test.ts:按 fixture 逐例记录 argTypes 与片段,并通过比较器自比每一个已提交的基线;
  • *-legacy-gaps.test.ts:把已知遗留缺陷钉成 test.fails 红色标记——一旦 baseline-path.ts'legacy' 翻转为 'osa',它们就变成硬性要求。源码中当前值就是 export const BASELINE_PATH: BaselinePath = 'legacy',注释明确了翻转即"硬化"所有红色标记;
  • *-render.test.ts:冒烟挂载 fixture,保证组件真的能渲染。

Vue3 有第二个 recorder:vue3-component-meta-baselines.test.ts。因为 Vue 有两个生产 docgen 引擎,它针对同样的 fixture 驱动 opt-in 的 vue-component-meta 路径(vue3-vite 中的 docgen: 'vue-component-meta'),写出 cm- 前缀的快照。

目录布局

README 给出的结构(与仓库实际一致,src/perf/ 下三个子目录各自独立):

src/
├── index.ts                      # 比较器的公共出口
├── compare/
│   ├── argtypes.ts               # 逐键 argTypes 规则
│   ├── snippets.ts               # 片段规则 + 框架分发
│   ├── snippets-vue3.ts          # Vue 匹配器
│   ├── snippets-angular.ts       # Angular 匹配器
│   ├── parse-element.ts          # 根元素与属性扫描
│   ├── parse-snapshot.ts         # 已提交 argtypes*.snapshot 文本的解析器
│   ├── expect-current-or-better.ts
│   ├── is-snapshot-update-run.ts
│   └── types.ts
├── vue3/                          # baselines / component-meta-baselines / legacy-gaps / render
├── angular/                       # baselines / legacy-gaps / provider-seam / render / compodoc 解析对等
├── svelte/                        # 计划中
├── web-components/                # 计划中
└── perf/                          # 性能基准
    ├── PERF-METHODOLOGY.md       # 测量契约
    ├── docgen-perf/              # 每引擎延迟与内存套件(含 engines/ 与 generators/)
    ├── docgen-memory/            # docgen-server 内存回归门禁
    └── docgen-shared/           # 两个套件共享的采样、统计、预算与路径

"Current or better" 比较器

核心语义一句话:expectCurrentOrBetter 在候选结果丢失任何基线已记录的东西时失败,同时放行改进

argTypes 规则

  • 基线中的每一个键、description、default value 和 type 都必须存活;
  • type 只允许两种变化:归一化深度相等,或明确的改进——catch-all 变结构化、字面量联合增加成员;
  • 语料库约一半记录的是 other:遗留引擎把无法解析的自由文本停靠在那里(如 TreeNodeArray([object Object]){ theme: string; dense: boolean })。这类桩接受"补上实体结构"(空 enum/union/object 不算改进)或"解析为它已点名的标量/单个字面量"的候选;无关标量或字面量属于侧向变化,失败;
  • 只有三个"什么都没记录"的标记接受任意候选:empty-enumundefined、空字符串——这是今天 Angular 与 Vue 的记法,所以新增框架时要重新审视这个清单;
  • 规则无法识别的解析方式(比如遗留的 TSFunctionType 变成 function sbType)宁可失败也不猜测,重录并 review diff;
  • 已记录的 table.type.summary 必须存活(丢掉即违规),但 strictTable 之外其文本可自由变化;
  • requiredtable.categoryjsDocTagscontrol/action 以及 description/default 的内容被有意不比较(仅 strictTable 下比较 required)——因为这些会固化"被记录的谎言"(#28706)或引擎专属词汇。

compare/argtypes.ts 中的 compareArgTypes 实现了上述规则,产出带种类的违规列表:参数消失是 lost-arg,description 消失是 lost-description,默认值消失是 lost-default,type 消失是 lost-type,类型保真度下降或侧向变化是 type-fidelity。它还明确跳过 ES 私有 #member(遗留 Compodoc 会记录它们,现代提取器只在 propsTable: 'all' 下暴露,它们的丢失永不门禁)。

片段规则

  • 集合比较"被表示的绑定名",所以纯格式差异永不失败,但丢失绑定会失败;
  • 指令写法归一化:Vue 的 :x/v-bind:x@x/v-on:x#x/v-slot:x 及任何 .modifier 都读作同一个名字;
  • Angular 比较额外门禁根元素同一性:标签名必须匹配,且裸(无值)属性——被压坏的属性选择器标记——必须存活。

接受方式与录制门禁

  • 没有 allowlist 文件:已提交的基线就是 allowlist。接受一次有意变更的方式是 -u 重录并 review diff;
  • recorder 读取每个已提交文件,并在快照调用之前跑完所有门禁,因此 -u 运行会拒绝排队一次回归的录制,直到代码修好前保持红色;
  • 已提交的 argtypes*.snapshot 文件是 pretty-format 文本而非 JSON。parseArgTypesSnapshot 读回后通过逐字节重序列化来自我验证,并拒绝任何携带"写入者二义条目边界形状"的解析字符串;对于未转义写入即与真实条目边界字节相同的字符串,解析期无法检测,由 recorder 的 parsed-vs-live 证明在常规运行、CI 运行以及 -u 运行(针对即将写出的确切字节)上守住;
  • 新增框架:扩展 Framework 联合类型,在 snippets.ts 的 switch 处编译失败,直到新匹配器存在——用类型系统强制完成工作。

源码视角:expectCurrentOrBetter 的豁免与双向检查

expect-current-or-better.ts 展示了规则如何落地:

  • argTypes 分支支持 legacyBaselinestrictTable 两个选项,并通过 declaredDefaultOmissions 声明"已知是初始化器源码而非可显示值"的遗留默认值,逐条与 lost-default 违规匹配并检查过期(候选已恢复默认值时必须移除声明);
  • snippet 分支支持 declaredOmissions:候选预期省略、但基线表示了的参数(其源码引用了静态片段无法声明的绑定)。双向检查——列在这里但候选实际表示了的参数会失败,所以这份清单不会超出它记录的缺口而存活;
  • 失败时抛出单个包含全部违规的错误,一次失败展示整个缺口。

信任模型

比较器机器检查的是一个有意的子集:argTypes 的基线参数名、description 存在性、default 存在性、table.type.summary 存在性与类型保真度;snippet 的被表示绑定名、根元素同一性与 Angular 裸属性存活。其余一切(description/default/summary 文本、table.categorycontrol/action、逐参数 jsDocTags、新增参数)只能靠 -u 时 review 的逐字节快照 diff,或沙箱门禁的 change findings 捕获。

两个标志把信任限定在基线真正值得信任的地方:legacyBaseline(仅用于基线是遗留 compodoc 录制的腿)豁免管线虚构的裸 false/NaN/null 默认值;strictTable(仅用于 ACM 自棘轮——其基线由同一引擎记录)额外门禁 table.type.summary 文本变化与 table.type.required 的 true→false 翻转。

README 同时明确承认盲区:整个项目的回归可以在日常 CI 层(沙箱基线门禁)才浮现;已知被接受的盲区包括引号/裸拼写碰撞的 enum 成员('"small"' 读作 small),以及 vitest 在写出时把 \r/\r\n 归一为 LF,导致带 CR 的提取永远无法录绿(常响,但从不沉默)。

Vue 的第二录制器:vue-component-meta

vue3-component-meta-baselines.test.ts 精确复现 vue3-vite vite 插件的 meta 处理——checker 选项、空 meta 跳过、嵌套 schema 修剪、exposed 去重、vue-docgen-api 的事件 description 回填——因此 cm- 快照展示的是 vue-component-meta 用户今天真实得到的东西。对应的生产实现见 code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts。要点:

  • 让这份拷贝与生产插件保持同步是手工的,没有任何东西检测漂移;
  • cm- 前缀让每个 recorder 的过期片段守卫只作用于自己的文件;
  • sourceFiles 记录 <sfc>,因为生产存的是绝对模块 id,而快照必须无路径;
  • 与遗留 recorder 一样,它自比每个已提交基线,因此 checker 或插件的降质变更会以具名违规失败,而不是落成不起眼的 diff;
  • 插件与 checker 的变更在这里落地为 review 过的快照 diff:#35565(schema: true)移动了 25 个 cm-argtypes.snapshot 文件中的 17 个,而所有 cm-snippet-* 逐字节不变;记录的状态取决于 lockfile 解析到的 vue-component-meta 版本(README 写作时是 3.3.9),所以依赖升级同样是 review 过的基线变更。

添加 fixture

每个 case 一个目录,recorder 自动发现。全新 fixture 的首次运行会失败一次(快照文件在套件结束时 flush),再跑一次并提交即可。原则:把薄弱的或错误的遗留输出原样录下,绝不"改进" fixture 让遗留结果看起来更好;快照必须确定——无时间戳、无绝对路径。

  • vue3:一个 PascalCase SFC(文件名成为每个片段中的组件标签)加 input.stories.ts;
  • angular:一个 kebab-case <case>.component.ts(类名必须与 compodoc 捕获完全一致)加 input.stories.ts 与捕获的 compodoc-input.json;signal fixture 还提交从真实 ngc 输出捕获一次的、带 ɵcmp 输入/输出映射的 aot-cmp.ts(JIT 下它们是空的)。Stories 从 src/angular/csf-types.ts 导入 CSF 类型;两个运行时测试文件被排除出 vue-tsc 程序,因为 angular-vite 客户端源码并非 strict-clean。

捕获 compodoc 输入(Angular)

捕获固定在 @compodoc/compodoc@2.0.0(package.json 中即此版本)。用其他版本重捕获是一次 review 过的基线变更——signal 解析跨版本漂移剧烈。Compodoc 扫描最近 package.json 下的一切并忽略 tsconfig include,所以必须从任何 Node 包之外的暂存目录捕获:

  1. 把组件及其支撑源码(绝不放 input.stories.ts)加 case 的 tsconfig.json 复制进一个空目录,如 $(mktemp -d);
  2. 在那里运行 npx -y @compodoc/compodoc@2.0.0 -p tsconfig.json -e json -d .;
  3. 把产出的 documentation.json 移回为 compodoc-input.json;
  4. 运行 cd code && yarn fmt:write

没有任何东西检测 fixture 源码与其提交捕获之间的漂移,所以编辑组件必须与重捕获放在同一次变更里。

已知遗留缺口

README 把"遗留管线今天做不到什么"完整列出,这些清单正是未来 OSA 引擎的验收目标。

Vue3

  • 接受的 delta:OSA 片段是静态的,所以 Controls 的实时更新不会重新渲染它们;
  • 片段从不渲染事件处理器;函数参数被静默丢弃;
  • table.jsDocTags 保持 undefined;script-setup SFC 中组件级 docblock 不被捕获;
  • 字面量字符串联合永远不会变成 enum sbType,且值保留引号;
  • 数组与交叉类型属性的 type 记录的是字符串化的 convert() 兜底(Array([object Object]));
  • 响应式 props 解构的默认值不可见,只有 withDefaults() 会被提取;
  • defineModel('name') 命名模型不可见;片段渲染裸属性而非 v-model:name;
  • 作用域插槽绑定的类型从不被提取,只有名字;
  • defineExpose 成员完全不记录类型——只有名字和 description,而 vue-component-meta 能把同样成员解析为 number() => void;
  • 超过 Number.MAX_SAFE_INTEGER 的 bigint 在片段中丢失精度;
  • 按设计薄的基线:Pick 组合的 props 记录 {},递归类型只留名字桩,运行时数组 props 是 type: undefined;
  • defineProps<ReturnType<typeof useComposable>>() 在遗留工具链中无法构建;语句块事件表达式会直接让 parse() 崩溃(#23851)。两者都不可能存在基线。

Angular

  • 接受的 delta(无标记):片段只含绑定——没有 ng-content 子节点、model() 没有 banana-in-a-box、函数与 undefined 按原文插值;
  • 每个装饰器输入都记录 required: true;compodoc 从不发出 optional(#28706);
  • 无字面量默认值的 number 输入记录虚构的 NaN 默认值;数值表达式默认值也塌缩为 NaN;
  • 非数值表达式默认值记录原始源码字符串(Math.max(1, 3));
  • JSDoc 标签从不结构化地进入 argTypes:@deprecated 消失(#9721),@see 文本泄漏进 description,@default 值保留引号与尾随换行;
  • functionany 与泛型类型字符串塌缩为 { name: 'other', value: 'empty-enum' };
  • 字面量联合、别名联合与 TS enum 在 compodoc 2.0.0 下都解析为 enum sbType——#33779 的塌缩在该版本不复现;
  • 跨文件继承被完整解析(回归基线,不是缺口);
  • angularFilterNonInputControls 关闭时,properties/methods/view child 段面会以 argTypes 出现(含私有字段,#22007);开启后限制为 inputs;
  • model() 记录一个输入加一个合成的 ${name}Change 输出;其背后的 compodoc 怪癖写在 code/lib/angular-compodoc/README.md;
  • 片段只用第一个逗号分隔的选择器;属性选择器被压坏为裸属性。

Issue 关联用例

fixture 复现未关闭的 issue,待 OSA 引擎落地时验证并关闭,每个在 *-legacy-gaps.test.ts 里有红色标记:

  • vue3:#11774/#12331 → cross-file-runtime-props/(导入的运行时 props 必须解析为真实 argTypes,遗留记录 {});#12331/#22187 → cross-file-props-spread/;#12850/#23470 → prop-slot-name-collision/;#19394 → runtime-multi-constructor/(type: [String, Number] 必须变成结构化联合);#20593 → runtime-proptype-cast/;#24270(部分)→ define-slots-literal-bindings/;#26465(部分)→ slots/;#26465(未复现)→ define-slots-with-props/(3.3.9 下不出现,cm-argtypes.snapshot 完整记录 description、默认值与 slot 文档,作回归基线无标记;issue 的次要 HMR 症状是 dev-server 行为,超出本 harness 范围);#29354 → cross-file-union-alias/;#30045 → type-intersection-whole/;
  • angular:#28706 → decorator-io-basics/(TS 可选输入必须记录 required: false);#9721 → jsdoc-tags/;#33779(未复现)→ decorator-union-enum/(回归基线无标记);#29697(未复现)→ signal-io/;#22007 → properties-methods-noise/(过滤标志的起源 case,也是两个标志状态有意义地不同的 fixture;ACM 引擎以 propsTable: 'api' 默认丢弃私有与 # 属性、方法及 @internal 成员,但保留 protected 成员与所有声明的输入/输出,因此 acm- 基线有意比遗留基线少行)。

性能基准:src/perf/

这是 "docgen beyond React" 问题的另一半:上面快照比较器回答正确性,src/perf/ 回答多快、占多少内存。它是一组 CLI 而非包 API 的一部分,src/perf/ 的一切都不从 src/index.ts 再导出。所有命令在 code/lib/docgen-harness 下运行(与 package.jsonscripts 字段对应):

yarn bench:docgen-perf            # 每引擎冷/热延迟与内存,完整 profile(约 1 分钟)
yarn bench:docgen-perf --quick    # 冒烟 profile;其数字被标记为不可比较
yarn bench:docgen-perf-gate       # 同一套件 + 预算断言——CI 门禁所跑的
yarn bench:docgen-memory          # docgen-server 内存回归门禁

bench:docgen-perf 在共享沙箱目录下生成合成项目,每个引擎在独立子进程运行,并把结果 JSON 写到旁边。生成的树保留在磁盘上供打开查看,每个引擎/场景拥有自己的目录,生成器写入前清空——因此两次基准不能并行跑(一次会清掉另一次正在读的树)。bench:docgen-memory 断言重提取无泄漏,且程序回收修复仍能把紧堆运行从 OOM 翻转为存活。

单引擎、非默认引擎

yarn bench:docgen-perf --engine react-osa                       # 单引擎
yarn bench:docgen-perf --engine react-legacy --engine react-osa # 一次调用跑一个对照组
yarn bench:docgen-perf --json /tmp/results.json                  # 结果落点

默认运行是 react-legacyreact-osavue-docgen-apivue-component-metacompodoc。两个 id 在默认之外,只在指名时测量:react-legacy-rdt(react-docgen-typescript 解析器)与 vue-component-meta-next(版本对别名)。只有对照对在同一调用中都测到时 ratio 才出现——只指名一侧给你一行表数据但没有比较。Compodoc 的 CLI 无法解析时带消息跳过;其他所有引擎都从 workspace 读取,缺一个即失败而非跳过。

两种 React 形状

React 引擎把每个场景跑两遍,因为 Storybook 用两种代价相差很大的形状记录组件:

  • whole-index——对索引中所有组件的一次批处理,即 manifest 生成器所做的事;
  • first-story——请求所要求的那一个组件,即 docgen server 所做的事。这是开发者等 Controls 填充前等待的数字。

README 给出的冷启动比值:在 index 上是 0.73,在 first story 上是 0.08——只读其中一个会对引擎成本形成误导。PERF-METHODOLOGY.md 进一步解释了 save 目标的刻意设计:first-story 下轮询会记录冷路径从未触及的组件,retained heap 的上涨无法与泄漏区分,所以反复重触同一个已记录组件以保持稳态可比。

比较同一引擎的两个版本

vue-component-meta-next 是本包 package.json 里的别名,固定到精确版本(当前为 npm:vue-component-meta@3.3.8,而当前侧是 vue-component-meta: ^3.3.9)。把别名指向要测的版本、yarn install,然后一次调用跑两边:

yarn bench:docgen-perf --engine vue-component-meta --engine vue-component-meta-next

要点:候选版本必须精确固定而不是区间——两个 caret 区间可能解析到同一次安装,那这次运行就是引擎自己和自己比。套件在每个 ratio 旁打印两个解析版本,两个相等时明确宣布这不是比较。

机制不绑定 Vue:一个引擎条目声明它测量哪次安装,子进程 import 该说明符而非硬编码包名,一个对就是仅该字段不同的两个条目。PERF-METHODOLOGY.md 给出了在另一个引擎上设置对照对的步骤及其未覆盖的那一个 case。

门禁与预算

两个门禁跑在 CircleCI 的日常层,由 PR 上的 ci:daily 标签按需触发——没有任何调度器,所以这不是夜间防护。

bench:docgen-perf-gate 以固定 profile 跑套件,断言 src/perf/docgen-shared/budgets.ts 中的预算,然后跑一个故意失败的引擎并要求其以非零退出,以此自证失败检测能力。它默认写入沙箱目录;CI 传 --out ./perf-results 以便把结果存为构建产物。

以下情况门禁拒绝报告绿色:--quick 运行、空预算表、被预算的引擎被跳过或失败——每一种都看似在保护实际什么都没断言。

预算是比值与绝对 MB,从不使用原始毫秒,因为共享 CI 执行器上的墙钟噪声太大,不适合门禁。只在 CI 上测得的数字面前修改预算,并在 PERF-METHODOLOGY.md 中记录来源。修改任何指标、预算或版本对之前,先读 PERF-METHODOLOGY.md——这些数字只有在它定义的条件下才有意义。

方法论层面,PERF-METHODOLOGY 明确了五个指标(冷提取、热提取、整项目扫描、峰值内存、泄漏检测,其中泄漏斜率拟合排除第一次 save)、以及可重复性规则:固定合成项目、每次测量全新进程、延迟取中位数、只比较同一机器上的相对比值,且对照对在重复间交替顺序(偶数 N 的奇偶性在 ratios.test.ts 中被断言,而不是留给下次改常量的人)。

基准还自带针对聚合、报告与生成器逻辑的单元测试,所以 yarn test code/lib/docgen-harness 会把它们与 fixture 比较一起跑起来。

不放在这里的东西

  • 框架 provider 代码住在各自框架的包中;
  • 性能基准不属于本包的导出 API;
  • 没有任何东西发布到 npm。

理解 docgen-harness 的关键,是把它看作 Storybook "docgen beyond React" 工程的质量合同:正确性一侧用"基线即 allowlist"的棘轮保证新引擎不丢失任何旧引擎已记录的信息(连规则无法识别的变化都宁可失败),性能一侧用同机比值、版本对与自证失败的门禁保证数字可信。两类机制都刻意把"人 review diff"保留在关键路径上——这正是一个内部 harness 而非发布库的设计取舍。

登录后查看全文
热门项目推荐
相关项目推荐