首页
/ Storybook Angular Compodoc 包深度解析:把 Compodoc 的 documentation.json 转成 Storybook argTypes,以及 model() 怪癖的处理之道

Storybook Angular Compodoc 包深度解析:把 Compodoc 的 documentation.json 转成 Storybook argTypes,以及 model() 怪癖的处理之道

2026-09-06 17:38:44作者:晏闻田Solitary

本文以 Storybook 仓库内的 code/lib/angular-compodoc/README.md 为主体,围绕 @storybook/angular-compodoc 这个内部包展开:它如何被 @storybook/angular@storybook/angular-vite 两个框架共用,其“环境无关根入口 + 浏览器适配器”的双入口设计如何落地,以及 README 中着墨最多的 Angular model() 双向绑定元数据错报问题如何在源码中被结构化地检测与修复。读完后,你将能够独立读懂 Compodoc 元数据到 Storybook Controls 的完整转换链路,并理解该包被冻结、其维护重心移向 @storybook/angular-cm 的工程背景。

一、包的定位:一份解析逻辑,服务两个 Angular 框架

README 开篇即说明该包的职责:“Shared parsing of Compodoc's documentation.json into Storybook argTypes”——将 Compodoc 生成的 documentation.json 解析为 Storybook 的 argTypes。Compodoc 是 Angular 生态中用于抽取组件/指令元数据(输入、输出、方法、JSDoc 等)的静态分析工具,其产物是一份 JSON。Storybook 的 Angular 集成会把这份 JSON 转成文档页上的 props 表格与 Controls 控件,而 README 强调的关键设计决策是:这段转换逻辑只维护一份,供 @storybook/angular(webpack 构建)和 @storybook/angular-vite(Vite 构建)两个框架包调用,“This package holds that logic once so those call sites cannot drift apart”(把逻辑收拢在一处,两个调用点就不会各自漂移)。

code/lib/angular-compodoc/package.json 可以确认包的工程属性:

  • 包名 @storybook/angular-compodocprivate: true——它是 monorepo 内部包,不对外发布;
  • 运行期依赖只有两个:@storybook/global(浏览器全局对象的安全代理)与 entities(HTML 实体解码),依赖面极小;
  • exports 字段声明了两个入口:"."(根入口,指向 src/index.ts)和 "./browser"(指向 src/browser.ts)。

两个框架确实以此为调用方:code/frameworks/angular/src/client/compodoc.tscode/frameworks/angular-vite/src/client/compodoc.ts 均从该包再导出 setCompodocJson 等函数,作为各自 preview 端 docgen 的接入点。

二、双入口设计:环境无关核心与浏览器适配器

README 用两条要点概括了入口划分:

  • The root entry is environment-agnostic: it reads no globals and takes the Compodoc JSON, the feature flag, the logger and the HTML unwrapper as arguments.
  • ./browser is the preview-side adapter that supplies those four things from the browser globals.

在源码中这一设计体现得非常彻底。src/extract-arg-types.ts 文件首行注释即声明:“Shared by the browser adapters and the Node docgen worker, so this module reads no globals.”(被浏览器适配器和 Node docgen worker 共享,因此本模块不读任何全局变量。)核心函数 extractArgTypesFromData 的签名要求调用方显式传入四样东西(见 ExtractArgTypesOptions 接口,src/extract-arg-types.ts):

参数 含义
compodocJson Compodoc 的 documentation.json 内容;在浏览器端被 setCompodocJson 调用前读取时允许为 undefined
filterNonInputControls angularFilterNonInputControls 特性开关;类型是 boolean | undefined,README 级别的注释说明这是“required so no host inherits a silent default”——宁可让宿主显式决定,也不静默继承默认值
logger 具备 warn / debug 两个方法的日志器,缺省为 noop
unwrapHtml 把 Compodoc 用 Markdown 渲染出的 HTML 片段解包为纯文本;无 DOM 的宿主使用同文件导出的 unwrapPlainText
modern(可选) 丢弃若干 Compodoc 遗留怪癖,默认关闭,原因是“off by default while the committed baselines pin them”——已提交的基线测试把这些遗留行为钉死了

src/browser.ts 就是浏览器侧适配器,负责从浏览器全局变量中把上面四样“找出来”再交给共享模块:

  • Compodoc JSON:通过 setCompodocJson / getCompodocJson 读写 global.__STORYBOOK_COMPODOC_JSON__
  • 特性开关:读取 FEATURES.angularFilterNonInputControls。源码注释特别说明 const { FEATURES } = global 只捕获一次,但每次调用时都重新读取开关值,因为宿主(包括测试)会在两次调用之间修改该对象;同时“a missing FEATURES must keep throwing”——preview 环境中缺少 FEATURES 属于损坏状态,这里始终抛错而不是把开关静默读成 false
  • 日志器:直接引用 storybook/internal/client-loggerlogger
  • HTML 解包器:preview 端有真实的 HTML 解析器,用 new global.DOMParser().parseFromString(html, 'text/html').body.textContent 实现。

browser.ts 还导出了 extractArgTypesextractComponentDescription 两个便利函数:前者先调用 getComponentData 按名字在 JSON 里查到组件条目,再调用共享的 extractArgTypesFromData。值得注意的是 CompodocExtractOptions 上的注释:@storybook/angular-vitepropsTable 框架选项取代了 angularFilterNonInputControls 特性,因此它向适配器传入自己的决定,而不是让适配器去读一个“在那里已不再拥有决定权”的开关;@storybook/angular 则不传,继续使用该特性。这正是双入口 + 可注入参数的价值:同一个解析核心,能同时适配两种不同决策来源的宿主。

三、数据形态:README 背后的 compodoc-types 契约

理解 README 中 model() 一节的钥匙,是 src/compodoc-types.ts 中对 documentation.json 的结构建模。几个关键字段:

  • DirectiveComponent = Directive 的别名)拥有 propertiesClassinputsClassoutputsClassmethodsClass 四个成员数组,而 Class / Injectable / Pipe 只有 propertiesmethods——这是后续按入口类型区分读取路径的依据;
  • Property.line从 1 开始、成员声明所在行号。README 明确指出“Both producers therefore have to record line on the members the rule can match”(两个生产者都必须给可被规则匹配的成员记录 line),这是 model() 检测规则成立的前提;
  • Property.requiredProperty.optional:注释分别指出 required 对应 signal 输入与 @Input({ required }) 的键存在性,而 optional@Input() 属性会被 Compodoc 省略(对应上游 compodoc#863 的行为)。源码中的 isRequired 函数(src/extract-arg-types.ts)因此实现为 (item.required ?? true) && !item.optional,使两个字段保持语义一致;
  • CompodocJson.miscellaneous:包含 typealiasesenumerations,是后续类型解析(类型别名展开、枚举取值)的数据源。文件注释提醒:所有数组都是可选的,因为 Compodoc 会省略项目中没有对应条目的数组,手写的或被截断的 documentation.json 可能省略更多——解析逻辑必须全程做防御。

四、核心专题:model() 被错报两次、且名字不对

这是 README 篇幅最大、也最值得细读的部分。原文陈述的事实链条如下:

  1. Angular 的 model() 本质是一个属性,既是输入 foo 又是输出 fooChange
  2. Compodoc 把它同时列进 inputsClassoutputsClass,两次都只用裸名 foo,且从不输出 fooChange
  3. 若不处理,一个真实的双向绑定会被渲染成“一个输入 + 一个组件根本不存在的输出”。该包因此丢弃裸名输出条目、自行合成 fooChange
  4. JSON 中没有任何 model() 标记,所以检测必须是结构化的:同名条目同时出现在两个数组中、且声明行号相同;
  5. 仅凭同名不够@Input('shared')@Output('shared') 的别名冲突会在无 model() 参与的情况下产生完全相同的撞名,若据此判定双向绑定,会删掉一个真实存在的输出、并凭空发明一个不存在的 sharedChange

源码中这一规则的实现集中在 getModelPropertiessrc/extract-arg-types.ts):

// 仅对 component / directive 条目生效:分析器也会把装饰器 IO 拆到普通 class 上,
// 若对 class 读取 `*Class` 字段,持有 model() 的基类会在无输入的条目上
// 凭空合成一个 `${name}Change` 输出。
const isDirectiveEntry = (componentData: CompodocEntry): componentData is Directive =>
  componentData.type === 'component' || componentData.type === 'directive';

const getModelProperties = (componentData: CompodocEntry): Property[] => {
  if (!isDirectiveEntry(componentData)) {
    return [];
  }
  const inputsByName = new Map(componentData.inputsClass.map((item) => [item.name, item]));
  return componentData.outputsClass.filter((item) => {
    const input = inputsByName.get(item.name);
    return input?.line !== undefined && input.line === item.line;
  });
};

三点值得注意:

  • 类型门槛isDirectiveEntry 先按 type 排除 class/injectable/pipe 条目,源码注释解释了原因——分析器会把装饰器 IO 拆到普通类上,放宽门槛会让持有 model() 的基类在“没有输入”的条目上凭空发明输出;
  • 行号门槛input?.line !== undefined && input.line === item.line 严格执行“同名 + 同行”双条件;
  • 两侧动作:在成员遍历循环中,命中 model() 的裸名输出条目被直接 return 跳过(“A model() surfaces as an input plus the ${name}Change synthesized below, so Compodoc's bare-name output duplicate of it is dropped.”);遍历结束后,循环外再为每个命中的成员合成一个输出条目。

合成的 fooChange 条目(extractArgTypesFromData 循环之后的 modelProperties.forEach 段)字段设计如下:

字段 取值 说明
name ${item.name}Change 即 Angular 约定生成的输出名
action ${item.name}Change outputs 分区的每个条目都带 action,用于 Actions 集成
type { name: 'other', value: 'void' } 源码注释:这是输出而非它所派生的 model 输入——没有默认值、绑定上永远非必填
table.type.summary (e: ${item.type}) => void 按发射载荷类型手写的事件处理器签名
table.type.required false 输出不需要必填

源码还特意让合成发生在成员循环之后,注释说明原因:“synthesized after the loop so filterNonInputControls cannot hide it”——即使宿主开启了只看输入的特性开关,这个合成的 Change 输出也不会被丢掉。

为什么不绑定 Compodoc 版本? README 给出了一个优雅的回答:规则是结构化的,所以无需锁定 Compodoc 版本;一旦 Compodoc 将来修复该行为,两个条目不再同时以裸名同现,规则自然匹配不到任何东西,本包停止合成——这是正确的,因为修复后的 Compodoc 会自行输出 fooChange

README 列出的三条已知局限(与源码行为一一对应):

  • 两个撞名且又共享同一物理源码行的别名成员,仍会被读成 model()——行号门槛无法区分这种情况;
  • 别名冲突时,真实的输出被保留,但输入丢失其表格行:因为 argTypes 以绑定名为键、且 outputs 分区最后写入,同名条目后者覆盖前者。README 认为这是“两害相权取其轻”:输出是组件真正声明的成员;
  • 没有行号的捕获结果永远不被视为双向绑定,因此真正的 model() 会按 Compodoc 上报的样子出现——outputs 分区中一条裸名条目、没有合成的 fooChange

五、转换管线全貌:从 Compodoc 成员到 argTypes

extractArgTypesFromDatasrc/extract-arg-types.ts)是整个包的主入口,其管线可以分为五步,均可在源码中逐一对上:

1. 选择成员来源。 根据特性开关与条目类型决定读取哪些键:

const componentClasses: CompodocMemberKey[] = filterNonInputControls
  ? ['inputsClass']
  : ['propertiesClass', 'methodsClass', 'inputsClass', 'outputsClass'];
const compodocClasses: CompodocMemberKey[] = isDirectiveEntry(componentData)
  ? componentClasses
  : ['properties', 'methods'];

即:非 component/directive 条目(class、injectable、pipe)读 properties + methods;component/directive 按开关读 *Class 四数组或仅 inputsClassmodern 模式下还会跳过 # 开头的 ES 私有成员(外部无法绑定,表格行只是噪音)。

2. 分区分桶。 mapItemToSection 把键映射到分区:methods / methodsClassmethodsinputsClassinputsoutputsClassoutputsproperties / propertiesClass 则按装饰器细分——带 ViewChild / ViewChildren / ContentChild / ContentChildren 装饰器的属性分别落入 view childview childrencontent childcontent children 分区,其余落入 properties。最终输出按 SECTION_ORDER(properties → inputs → outputs → methods → 四个 child 分区)稳定排序写入 argTypes

3. 类型推导。 extractType 的决策链:

  • 属性无 type 字段时回退到默认值的 typeofextractTypeFromValue),但只有 defaultValue 真值或 number / boolean / string 类型才返回,否则为 null{ name: 'other', value: 'void' }
  • string / boolean / number 直接返回对应基础类型;
  • modern 模式下,构造函数类型(以 new 开头)与泛型函数签名(如 <T>(...) => ...)会被识别为 { name: 'function' },正则 /^(new\s+)?(<.*>\s*)?\(.*\)\s*=>/ 覆盖这两种前缀形态;
  • 其余先经 resolveTypealias 展开类型别名(用 seen 集合防 type A = B; type B = A 这类循环别名把整个 docgen worker 递归挂掉),再交给 extractEnumValues:若 miscellaneous.enumerations 中有同名枚举且每个子项都有取值,返回取值数组({ name: 'enum', value });若是字面量联合(如 "A" | "B"),JSON.parse 每个成员还原字面量;都失败则落入 { name: 'other', value: 'empty-enum' } 兜底。

其中 pickDeclaration 值得一读:Compodoc 的条目顺序每次运行都可能不同,而 Size 这类名字经常每个组件文件夹各声明一次,若“先到先得”,控件类型会在源码未改动的情况下漂移。该函数优先选与组件同文件(file === componentFile)的声明,否则按 file 字典序取第一个,保证结果可复现。

4. 默认值提取。 extractDefaultValue 先从 property.defaultValue 去掉成对引号,再经 castDefaultValue 按类型转型('true'true'5'5EventEmitterundefined);若结果为 null 且属性有 jsdoctags,继续从 JSDoc 的 @default / @defaultvalue 标签取值(legacy 规则是“最后一个标签胜出”)。整个提取包在 try/catch 中:一旦失败只 logger.debug 并返回 undefined,不会拖垮整个组件的提取。modern 模式额外提供 castDefaultValueModern——绝不臆造值:缺失的默认值保持缺失,而不是变成 NaN / false;表达式默认值保留原始源码文本。

5. 组装 argType。 每个成员产出一条包含 namedescription(优先 rawdescription,回退 Markdown 渲染后的 description)、typetable.categorytable.type.summary(方法用 displaySignature 渲染 (arg?: type, ...) => returnType)、table.type.requiredtable.defaultValue.summary 的记录;outputs 分区条目额外带 action

HTML 解包的 Node 替身。 由于 JSDoc 注释被 Compodoc 经 Markdown 渲染成 HTML,@default 的取值到达时是 HTML 片段,必须解包。浏览器端用 DOMParser;Node docgen worker 没有 DOM,于是 src/html-to-text.ts 提供了 htmlToText 作为等价实现。文件注释列出了三条“承重”规则,每条都对应真实 bug:

  1. 先剥标签,后解码实体:若先解码,Array&lt;string&gt; 会变成 Array——解码后的 <string> 会被当成标签剥掉;
  2. < 后必须紧跟字母、/!? 才算开标签:HTML 把 < 4 视为纯文本,5 > 3 && 2 < 4 必须原样存活;
  3. 实体恰好解码一次&amp;amp; 解码后是 &amp;,而不是 &

实现上,剥标签正则 TAG_INNER 专门处理“引号属性值里的 > 不结束标签”这一情形(<a href=">"> 是一个标签而不是标签加文本 ">),且其分支互斥以保证线性匹配而非回溯;截断标签(输入结尾处未闭合)按 HTML 解析器的行为直接丢弃;实体解码采用 entities 库的属性模式而非文本模式,避免文本模式把无分号的遗留引用(如 &notarealentity;)错误展开。

六、冻结状态:Storybook 11 之前的事,修到 angular-cm 去

README 的元信息同样关键,原文逐句给出了该包的生存策略:

  • This package is frozen and scheduled for deletion in Storybook 11, along with the Compodoc pipeline itself.——包已冻结,与 Compodoc 管线一同计划在 Storybook 11 删除;
  • It stays on the legacy behaviour its committed baselines pin——它停留在已提交基线所钉死的遗留行为上,所以 bug 修复应去其后继包 @storybook/angular-cm,而不是这里;
  • @storybook/angular-cm carries a specialised fork of the conversion below: the two are deliberately not kept in sync.——@storybook/angular-cmcode/lib/angular-cm)携带的是上述转换的一个专门化分叉,两者刻意不同步。README 还交代了分叉的动机之一:angular-cm 在自己的 signal 成员上也会输出 line 字段,正是为了让同样的“同名 + 同行”结构化规则在另一种元数据源上同样可匹配(参见 code/lib/angular-cm/README.md)。

对使用者的实际含义是:如果你在基于 Compodoc 的旧版 Angular 管线中遇到解析 bug,修复不会落在这个包里;这个包的测试(extract-arg-types.test.tsbrowser.test.tshtml-to-text.test.ts)的职责是钉住现状,而非推进行为。

七、在仓库中定位与验证:相关入口清单

若要在仓库内沿着本文的线索继续深入,以下路径构成最小验证集:

关注点 路径
本包 README(本文主体) code/lib/angular-compodoc/README.md
环境无关核心转换 code/lib/angular-compodoc/src/extract-arg-types.ts
浏览器适配器 code/lib/angular-compodoc/src/browser.ts
Node 端 HTML 解包 code/lib/angular-compodoc/src/html-to-text.ts
documentation.json 类型契约 code/lib/angular-compodoc/src/compodoc-types.ts
webpack 框架调用方 code/frameworks/angular/src/client/compodoc.ts
Vite 框架调用方 code/frameworks/angular-vite/src/client/compodoc.ts
特性开关的行为验证 code/frameworks/angular/src/client/docs/angular-properties.test.ts(断言了开关关闭时“model 输入控件 + 合成输出”与开启时“仅遍历 inputsClass”两种行为)
两代管线的解析一致性检查 code/lib/docgen-harness/src/angular/compodoc-parsing-parity.test.ts

最后回到 README 想传达的核心:这个包的价值不在于它有多少功能,而在于它把“Compodoc 元数据 → argTypes”这一容易被双份维护撕扯的逻辑收拢为一份,并且用结构化规则(同名 + 同行)去修复一个上游工具持续多年的元数据错报——规则不依赖版本、不依赖标记位,上游哪天修好了,规则自然失效,行为自然正确。这也是在遗留管线之上做“防御性解析”的一个值得借鉴的样本。

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