Storybook Angular Compodoc 包深度解析:把 Compodoc 的 documentation.json 转成 Storybook argTypes,以及 model() 怪癖的处理之道
本文以 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-compodoc,private: true——它是 monorepo 内部包,不对外发布; - 运行期依赖只有两个:
@storybook/global(浏览器全局对象的安全代理)与entities(HTML 实体解码),依赖面极小; exports字段声明了两个入口:"."(根入口,指向 src/index.ts)和"./browser"(指向 src/browser.ts)。
两个框架确实以此为调用方:code/frameworks/angular/src/client/compodoc.ts 与 code/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.
./browseris 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 missingFEATURESmust keep throwing”——preview 环境中缺少FEATURES属于损坏状态,这里始终抛错而不是把开关静默读成false; - 日志器:直接引用
storybook/internal/client-logger的logger; - HTML 解包器:preview 端有真实的 HTML 解析器,用
new global.DOMParser().parseFromString(html, 'text/html').body.textContent实现。
browser.ts 还导出了 extractArgTypes 与 extractComponentDescription 两个便利函数:前者先调用 getComponentData 按名字在 JSON 里查到组件条目,再调用共享的 extractArgTypesFromData。值得注意的是 CompodocExtractOptions 上的注释:@storybook/angular-vite 用 propsTable 框架选项取代了 angularFilterNonInputControls 特性,因此它向适配器传入自己的决定,而不是让适配器去读一个“在那里已不再拥有决定权”的开关;@storybook/angular 则不传,继续使用该特性。这正是双入口 + 可注入参数的价值:同一个解析核心,能同时适配两种不同决策来源的宿主。
三、数据形态:README 背后的 compodoc-types 契约
理解 README 中 model() 一节的钥匙,是 src/compodoc-types.ts 中对 documentation.json 的结构建模。几个关键字段:
Directive(Component = Directive的别名)拥有propertiesClass、inputsClass、outputsClass、methodsClass四个成员数组,而Class/Injectable/Pipe只有properties与methods——这是后续按入口类型区分读取路径的依据;Property.line:从 1 开始、成员声明所在行号。README 明确指出“Both producers therefore have to recordlineon the members the rule can match”(两个生产者都必须给可被规则匹配的成员记录line),这是model()检测规则成立的前提;Property.required与Property.optional:注释分别指出required对应 signal 输入与@Input({ required })的键存在性,而optional对@Input()属性会被 Compodoc 省略(对应上游 compodoc#863 的行为)。源码中的isRequired函数(src/extract-arg-types.ts)因此实现为(item.required ?? true) && !item.optional,使两个字段保持语义一致;CompodocJson.miscellaneous:包含typealiases与enumerations,是后续类型解析(类型别名展开、枚举取值)的数据源。文件注释提醒:所有数组都是可选的,因为 Compodoc 会省略项目中没有对应条目的数组,手写的或被截断的documentation.json可能省略更多——解析逻辑必须全程做防御。
四、核心专题:model() 被错报两次、且名字不对
这是 README 篇幅最大、也最值得细读的部分。原文陈述的事实链条如下:
- Angular 的
model()本质是一个属性,既是输入foo又是输出fooChange; - Compodoc 把它同时列进
inputsClass和outputsClass,两次都只用裸名foo,且从不输出fooChange; - 若不处理,一个真实的双向绑定会被渲染成“一个输入 + 一个组件根本不存在的输出”。该包因此丢弃裸名输出条目、自行合成
fooChange; - JSON 中没有任何
model()标记,所以检测必须是结构化的:同名条目同时出现在两个数组中、且声明行号相同; - 仅凭同名不够:
@Input('shared')与@Output('shared')的别名冲突会在无model()参与的情况下产生完全相同的撞名,若据此判定双向绑定,会删掉一个真实存在的输出、并凭空发明一个不存在的sharedChange。
源码中这一规则的实现集中在 getModelProperties(src/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跳过(“Amodel()surfaces as an input plus the${name}Changesynthesized 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
extractArgTypesFromData(src/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 四数组或仅 inputsClass。modern 模式下还会跳过 # 开头的 ES 私有成员(外部无法绑定,表格行只是噪音)。
2. 分区分桶。 mapItemToSection 把键映射到分区:methods / methodsClass → methods,inputsClass → inputs,outputsClass → outputs;properties / propertiesClass 则按装饰器细分——带 ViewChild / ViewChildren / ContentChild / ContentChildren 装饰器的属性分别落入 view child、view children、content child、content children 分区,其余落入 properties。最终输出按 SECTION_ORDER(properties → inputs → outputs → methods → 四个 child 分区)稳定排序写入 argTypes。
3. 类型推导。 extractType 的决策链:
- 属性无
type字段时回退到默认值的typeof(extractTypeFromValue),但只有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' → 5、EventEmitter → undefined);若结果为 null 且属性有 jsdoctags,继续从 JSDoc 的 @default / @defaultvalue 标签取值(legacy 规则是“最后一个标签胜出”)。整个提取包在 try/catch 中:一旦失败只 logger.debug 并返回 undefined,不会拖垮整个组件的提取。modern 模式额外提供 castDefaultValueModern——绝不臆造值:缺失的默认值保持缺失,而不是变成 NaN / false;表达式默认值保留原始源码文本。
5. 组装 argType。 每个成员产出一条包含 name、description(优先 rawdescription,回退 Markdown 渲染后的 description)、type、table.category、table.type.summary(方法用 displaySignature 渲染 (arg?: type, ...) => returnType)、table.type.required、table.defaultValue.summary 的记录;outputs 分区条目额外带 action。
HTML 解包的 Node 替身。 由于 JSDoc 注释被 Compodoc 经 Markdown 渲染成 HTML,@default 的取值到达时是 HTML 片段,必须解包。浏览器端用 DOMParser;Node docgen worker 没有 DOM,于是 src/html-to-text.ts 提供了 htmlToText 作为等价实现。文件注释列出了三条“承重”规则,每条都对应真实 bug:
- 先剥标签,后解码实体:若先解码,
Array<string>会变成Array——解码后的<string>会被当成标签剥掉; <后必须紧跟字母、/、!或?才算开标签:HTML 把< 4视为纯文本,5 > 3 && 2 < 4必须原样存活;- 实体恰好解码一次:
&amp;解码后是&,而不是&。
实现上,剥标签正则 TAG_INNER 专门处理“引号属性值里的 > 不结束标签”这一情形(<a href=">"> 是一个标签而不是标签加文本 ">),且其分支互斥以保证线性匹配而非回溯;截断标签(输入结尾处未闭合)按 HTML 解析器的行为直接丢弃;实体解码采用 entities 库的属性模式而非文本模式,避免文本模式把无分号的遗留引用(如 ¬arealentity;)错误展开。
六、冻结状态: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-cmcarries a specialised fork of the conversion below: the two are deliberately not kept in sync.——@storybook/angular-cm(code/lib/angular-cm)携带的是上述转换的一个专门化分叉,两者刻意不同步。README 还交代了分叉的动机之一:angular-cm在自己的 signal 成员上也会输出line字段,正是为了让同样的“同名 + 同行”结构化规则在另一种元数据源上同样可匹配(参见 code/lib/angular-cm/README.md)。
对使用者的实际含义是:如果你在基于 Compodoc 的旧版 Angular 管线中遇到解析 bug,修复不会落在这个包里;这个包的测试(extract-arg-types.test.ts、browser.test.ts、html-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”这一容易被双份维护撕扯的逻辑收拢为一份,并且用结构化规则(同名 + 同行)去修复一个上游工具持续多年的元数据错报——规则不依赖版本、不依赖标记位,上游哪天修好了,规则自然失效,行为自然正确。这也是在遗留管线之上做“防御性解析”的一个值得借鉴的样本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00