ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Storybook 跨框架 Props 声明指南:彻底搞懂 argTypes、Controls 与 Docs 的自动生成

Storybook 跨框架 Props 声明指南:彻底搞懂 argTypes、Controls 与 Docs 的自动生成 Storybook 跨框架 Props 声明指南彻底搞懂 argTypes、Controls 与 Docs 的自动生成你的 Storybook Controls 面板是空的——明明在组件里做了 Props 声明argTypes 却一个字段都没读出来。这种声明了却不生效的错位几乎都发生在 Props 声明与 docgen 管线的衔接处你把类型写在了 docgen 读不到的位置或者注释挂错了层级于是布尔开关没变成勾选框、字符串没变成文本框整个面板空得像个毛坯房。这篇文章不逐个框架复述语法而是把一行 JSDoc 如何变成 Controls 里一个控件这条链路拆开再按声明范式运行时 / 编译期 / 装饰器指令把六大框架的写法归位最后给你三张不会报错但文档悄悄消失的陷阱清单。元信息提取机制docgen 如何把源码变成 argTypes docgen 读取 JSDoc 的完整链路整条管线分三段。第一段发生在构建期以 React 为例Vite 插件在 react-docgen.ts 里对每个模块跑parse()解析出propTypes、interface 字段和 JSDoc然后往源码末尾追写一行;Button.__docgenInfo{...}——也就是说组件在被打进 bundle 时就已经把元信息焊死在了自己身上。第二段发生在预览期核心库用 docgenInfo.ts 里的hasDocgen判断组件是否带__docgenInfo再用getDocgenSection取出props段。第三段是归类extractDocgenProps.ts 依据 prop 上存在的是type、flowType还是tsType判定走 JAVASCRIPT、FLOW 还是 TYPESCRIPT 分支把每条 prop 交给对应的转换工厂。因为各框架产出的元信息结构不完全一样Vue 走 docgen-worker.ts 在独立 worker 线程里用 vue-component-meta 做类型检查输出的是数组而非对象所以核心库必须保留一个统一入口去兼容对象与数组两种__docgenInfo.props。这就是为什么你换框架后偶尔会遇到同一份注释React 有描述、Vue 没描述——差异不在注释而在各自 docgen 是否把注释塞进了那个字段。argTypes 字段逐项拆解上面三步的产物就是每个 Story 的 argTypes。仓库里 storybook-generated-argtypes.md 给了一份最小样例const argTypes { label: { name: label, type: { name: string, required: false }, defaultValue: Hello, description: demo description, table: { type: { summary: string }, defaultValue: { summary: Hello } }, control: { type: text }, }, };读懂type.name与type.required前者决定控件形态string→ 文本框、boolean→ 勾选框后者来自isRequired或字段是否带?所以你在 interface 里补一个?这里的required就会从true翻成false。核对defaultValue与table.defaultValue.summary两者都源自组件里的初值或解构默认值前者喂给 Controls 的初始值后者喂给 Docs 的 ArgsTable。对照description与control.typedescription就是你写在字段上方那段 JSDoc 的原文control.type由 convert/index.ts 根据类型系统路由到propTypesConvert/tsConvert/flowConvert计算得出你几乎不用手写它只是类型的一次翻译。分范式实战按声明范式分组而非逐个框架把六大框架硬分成六节只会让你在这段是 Vue 的、那段是 Svelte 的里迷路。真正决定 docgen 怎么读的是声明范式——运行时、编译期、装饰器指令三种。同一范式下的框架docgen 读法几乎一致差异只在元信息塞在哪个语法里。运行时范式PropTypes 与 Options API props这一派的共同点是类型信息以可执行对象的形式挂在组件上docgen 直接读对象而不是读类型标注。React 的 JavaScript 版就是典型——import React from react; import PropTypes from prop-types; export function Button({ isDisabled, content }) { return ( button typebutton disabled{isDisabled} {content} /button ); } Button.propTypes { /** * Checks if the button should be disabled */ isDisabled: PropTypes.bool.isRequired, /** * The display content of the button */ content: PropTypes.string.isRequired, };检查注释是否紧贴propTypes的键react-docgen 只提取键上方那段 JSDoc 作为description把注释挪到组件顶部或函数参数上都会被丢。注意isRequired是唯一能进入type.required的写法PropTypes.bool本身不带必填语义漏写它控件仍会出现只是 Docs 里不标必填。避免在解构里给默认值却不写isRequired运行时范式的默认值要么来自defaultPropType极少用要么干脆没有所以这里isDisabled不传时组件会拿到undefined。Vue 的 Options API 把同一套信息塞进props对象isDisabled写type: Boolean, default: false, required: truelabel写type: String, default: One注释位置从propTypes的键上方挪到每个 prop 对象上方docgen 换成 vue-docgen-api。因为required: true本身就是必填语义所以 Vue 不需要额外的required标注——这是它和 React 最大的区别必填写在运行时对象里而不是靠注释约定。编译期类型范式interface 与 property 装饰器这一派的元信息活在类型系统里docgen 必须借助 TypeScript 解析器react-docgen-typescript、vue-component-meta才能读出来。Lit 的 Web Components 版把声明 默认值 描述压缩进了装饰器和类上方 JSDoc——import { LitElement, html } from lit; import { customElement, property } from lit/decorators.js; /** * prop {string} content - The display label of the button * prop {boolean} isDisabled - Checks if the button should be disabled * summary This is a custom button element * tag custom-button */ customElement(custom-button) export class CustomButton extends LitElement { property() content?: string One; property() isDisabled?: boolean false; render() { return htmlbutton typebutton ?disabled${this.isDisabled}${this.content}/button; } }检查prop是否写在类上方而非字段上方Web Components 的 docgen 认类级prop与tag把它们拆到字段上会丢描述。注意?与字段初值成对出现?让属性非必填 One / false提供defaultValue两者缺一Controls 的初始态就会错。避免customElement(custom-button)与 JSDoc 的tag不一致两者必须指向同一个标签名否则 Storybook 解析元素时会对不上详见后文陷阱。React 的 TypeScript 版走同一条编译期链路但不写propTypes改用一个ButtonPropsinterfaceisDisabled: boolean与content: string上方各挂一段 JSDoc组件签名写成React.FCButtonProps。因为 convert 阶段会走tsConvert分支所以 interface 字段的?直接决定required字段初值 false / 直接成为defaultValue——你把类型写进 interface 的那一刻文档就已经生成了。装饰器与指令范式Input 与 export let这一派靠框架自有的属性声明语法Angular 的Input、Svelte 的export let暴露属性描述仍靠 JSDocCompodoc 之类工具再把它们转成__docgenInfo。Angular 版如下——import { Component, Input } from angular/core; Component({ selector: my-button, template: button typebutton [disabled]isDisabled {{ content }} /button, styleUrls: [./button.css], }) export class ButtonComponent { /** * Checks if the button should be disabled */ Input() isDisabled: boolean; /** * The display content of the button */ Input() content: string; }检查注释是否贴在Input()上方而不是类名上Compodoc 按装饰器 紧邻 JSDoc取描述位置错位会让 ArgsTable 的说明列空白。注意selector: my-button是模板里的标签名而 docgen 认的是导出类名两者对不上时 Story 的component匹配会失败。避免字段既无初值又无required注释Angular 的必填语义只能靠 JSDoc 的required表达见 button-implementation.md 的写法否则它会被当成可选且无默认值。Svelte 没有装饰器script里的export let disabled false就是属性声明required写在变量上方供工具识别。因为 Svelte 的属性名是disabled而非其他框架的isDisabled所以对照时别按名字硬套。注意官方片段 button-component-with-proptypes.md 第 90 行把闭合标签误写成script/真实组件必须写/script否则export let根本不会被解析。对照表与静默失败陷阱必填、默认值与描述提取逐项对齐 ⚠️把六种写法摊到一张表上必填语义 / 默认值写法 / 描述提取位置三列的差异一目了然范式框架必填语义默认值写法描述提取位置典型 docgen 盲区运行时React (JS)PropTypes.xxx.isRequired无靠解构初值propTypes键上方 JSDoc...props透传的 prop 不被枚举运行时Vue Optionsprop 内required: truedefault: falseprop 对象上方注释validator联合值不生成枚举控件编译期React (TS)字段不带?解构 falseinterface 字段上方 JSDocReact.FCT内联联合读不全编译期Litproperty()?字段初值类上方prop类名与tag不一致时解析丢失装饰器指令AngularJSDocrequired字段初值Input()上方 JSDoc导出类名与 selector 名对不上装饰器指令SvelteJSDocrequiredexport let x ...变量上方 JSDoc脚本块未闭合时 props 整体为空下面三个坑的共同点是构建不报错、控制台不告警但文档面板悄悄缺了一块排查起来格外费劲。Svelte 脚本块未闭合把/script写成script/自闭合后Svelte 编译器视脚本块为空export let全部失效docgen 读到的 props 是空对象于是 Controls 一个控件都不渲染——它不报缺失只报没有。Vue 单字组件名name: button会触发vue/multi-word-component-names的 ESLint 告警button-implementation.md 里用// eslint-disable-next-line vue/multi-word-component-names压掉了它。因为 docgen 靠组件注册名去匹配 Story 里的component一旦你顺手改名又忘了同步 metaargTypes 就匹配不到文档区只剩占位文案。Lit 的tag与customElements.define名不一致JSDoc 里tag custom-button和注册名必须完全相等仓库里 Web Components 的 Story meta 甚至写成字符串component: demo-button。因为 Web Components 的component只能是个字符串标签名它和真实注册的 tag 差一个字母Storybook 就找不到那个元素Docs 面板整片空白。接上 CSF用 component 字段把 Props 元数据接入 Story 前面所有工作最终要在一行component字段上兑现。因为核心库预览期就是靠组件引用 __docgenInfo来定位元信息的所以 meta 里只要把真实组件传进去argTypes 就会自动挂到这个 Story 上Web Components 则传标签名字符串让解析器按 tag 反查元素。import type { Meta } from storybook/react; import { Button } from ./Button; const meta: Metatypeof Button { component: Button, parameters: { actions: { argTypesRegex: ^on.* } }, }; export default meta;检查component指向的确实是你声明了 Props 的那个导出传错对象等于把元信息挂空argTypes 会回退到无法自动推导。注意argTypesRegex: ^on.*只负责把on*开头的属性映射进 Actions 面板它不参与 argTypes 的生成别指望它补类型。避免在 meta 里再手写一遍argTypes除非要覆盖 docgen 的推断重复声明会把你辛苦写的 JSDoc 直接盖掉。把类型写准、把注释挂对位置Controls 与 Docs 就不再是需要额外维护的产物而是 Props 声明的自然副产品——这也是跨框架写组件时最该统一的工程习惯。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进