Storybook 实战:用 CSF 3 与 CSF Next 编写类型安全的 Button 基线故事

发布时间:2026/9/18 2:31:34
Storybook 实战:用 CSF 3 与 CSF Next 编写类型安全的 Button 基线故事 Storybook 实战用 CSF 3 与 CSF Next 编写类型安全的 Button 基线故事这篇指南围绕 Storybook 官方代码片段 docs/_snippets/button-story-baseline.md 展开——它是在 docs/configure/integration/typescript.mdx 的 “Write stories with TypeScript” 一节中作为开箱即用、零配置示例被引用的“基线故事”模板。文章会逐步拆解 CSF 3 与实验性的 CSF Nextpreview.meta/meta.story两种写法覆盖 Angular、React、Vue 3、Web Components 等渲染器变体并结合仓库源码解释Meta/StoryObj泛型如何把args与组件 props 关联起来做编译期校验以及如何用 TypeScript 4.9 的satisfies运算符进一步收紧类型。读完后你将能独立写出任意框架下类型安全、可被编辑器自动补全并被 Storybook 正确索引的故事文件。一段“基线故事”到底由哪几部分组成所谓基线baseline故事是指一个组件在没有任何装饰器、全局参数、标签tags和渲染逻辑时的最小故事文件骨架。它只承担一件事把组件声明给 Storybook并用args声明一种渲染状态。以 Button 组件为例这段骨架通常包含三个固定动作从框架入口导入类型Meta与StoryObj不是手写的普通接口而是每个渲染器包如storybook/react、storybook/angular、storybook/web-components-vite对外导出的泛型类型通过默认导出定义meta用component字段告诉 Storybook“这段故事要渲染哪个组件”并可补充title、tags、parameters等元信息导出具名故事并声明args每个导出的具名变量就是一个故事args会被作为组件的输入props / inputs注入。这份片段之所以被官方文档当作 TypeScript 示例反复引用是因为它集中演示了 Storybook 的核心理念默认导出为 meta、具名导出为 storyComponent Story FormatCSF。仓库中负责在构建期把.stories文件解析成故事索引的正是 CSF 工具链例如 code/core/src/csf-tools/CsfFile.ts 与 code/core/src/csf/index.ts它们以该文件结构作为解析输入。CSF 3同一骨架在不同框架下的写法差异片段的第一组变体对应的是标准 CSF 3MetaStoryObj同一段意图在 Angular、通用框架、Web Components 上各有细微差别下面逐一给出可完整运行的最小文件。通用写法React、Vue 3、Preact、Svelte 等需替换框架名// Button.stories.ts|tsx // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Meta, StoryObj } from storybook/your-framework; import { Button } from ./Button; const meta { component: Button, } satisfies Metatypeof Button; export default meta; type Story StoryObjtypeof meta; // Throws a type error if the args dont match the component props export const Primary: Story { args: { primary: true, }, };这是全仓库大多数.stories.tsx文件的默认形态例如 code/core/src/components/components/Button/Button.stories.tsx 就沿用了Meta/StoryObj的组织方式。三处细节值得注意import type只引入类型不会进入运行时打包产物meta用satisfies Metatypeof Button而非: Metatypeof Button注解保证component、title等字段在不丢失精确类型的前提下被约束StoryObjtypeof meta让故事对象的args精确对应当前组件的 props 类型这是“args 不匹配就报类型错误”的关键后文会结合源码展开。Angular 写法Component 类 类装饰器输入// Button.stories.ts (Angular) import type { Meta, StoryObj } from storybook/angular; import { Button } from ./button.component; const meta: MetaButton { component: Button, }; export default meta; type Story StoryObjButton; // Throws a type error if the args dont match the component props export const Primary: Story { args: { primary: true, }, };Angular 的组件是一个带Input()的类而非纯函数因此Meta与StoryObj的泛型参数直接传入组件类Buttonargs会被按Input声明做类型匹配。Angular 渲染器入口位于 code/frameworks/angular/src/client/preview.ts其公开类型定义Meta、StoryObj在 code/frameworks/angular/src/client/public-types.ts。Web Components 写法component 传的是元素标签名// Button.stories.ts (Web Components) import type { Meta, StoryObj } from storybook/web-components-vite; const meta: Meta { component: demo-button, }; export default meta; type Story StoryObj; export const Primary: Story { args: { primary: true, }, };Web Components 渲染器没有“导入组件类”这一步component字段接收的是已在浏览器注册的自定义元素标签字符串如demo-buttonStorybook 会把渲染结果包裹进该标签并通过属性/插槽注入args。其渲染器与类型出口分别位于 code/renderers/web-components/src/preview.ts 与 code/renderers/web-components/src/public-types.ts。由于组件是字符串标签args无法与真实组件实现一一对应因此这里既没有typeof Button也没有 story 级别的组件级类型推导。类型安全从何而来StoryObj 在源码里做了什么片段中反复出现的注释 “Throws a type error if the args dont match the component props” 并不是空话它的保证来自渲染器包中StoryObj类型定义的写法。以 React 为例code/renderers/react/src/public-types.ts 从第 47 行起定义了条件类型StoryObj它会检查传入的TMetaOrCmpOrArgs是否带有render或component字段并据此尝试从组件身上反向推断出args的精确形状否则退回Args基线类型。可以推断出的调用关系是当meta携带component: Button时StoryObjtypeof meta能拿到 Button 的 props 类型故事对象里args的每一项键值都会被逐一核对一旦Primary故事写了meta中不存在的属性、或把字符串赋给了布尔字段如把primary: true写成primary: yes编译期就会抛错。在组件与args无法静态关联的场景如 Web Components 的标签字符串StoryObj泛型自动退回到不约束args形状的宽松形态这正是上文 Web Components 变体不写typeof meta也能编译通过的原因。编辑器与构建工具的这类补全能力均由storybook/csf提供的基础类型与各渲染器扩展共同支撑。CSF Next 实验特性preview.meta 与 meta.story 工厂写法片段的第二组变体标注为 “CSF Next ”它把“默认导出 meta”的传统写法替换成了先拿到一个全局preview实例再由它工厂化地派生 meta 与 story// Button.stories.ts (React / CSF Next ) import preview from ../.storybook/preview; import { Button } from ./Button; const meta preview.meta({ component: Button, }); // Throws a type error if the args dont match the component props export const Primary meta.story({ args: { primary: true, }, });CSF Next 的核心思路是故事文件从.storybook/preview导入一个已携带全局配置preview annotations、addons 及其扩展类型的 preview 对象再通过preview.meta()创建组件级 meta、通过meta.story()创建故事。这样 meta 与 story 会天然继承 preview 中 addon 声明的参数/全局量类型而不必再靠各文件手动重复声明。这一套工厂 API 在仓库中的实现位于 code/core/src/csf/csf-factories.ts由 code/core/src/csf/index.ts 统一导出核心概念是definePreview、definePreviewAddon与preview.meta(...)。仓库的测试用例 code/core/src/csf/csf-factories.test.ts 直观地证明了这套类型推断能力它通过definePreviewAddon声明带类型的 addon然后用preview.type{ args: ... }().meta({...}).story({...})连缀创建故事并断言错误参数如把value: 1赋给期望字符串的字段会在类型层被拦截对应测试中的ts-expect-error断言。也就是说基线片段中preview.meta({ component: Button })与meta.story({ args: { primary: true } })的写法不仅是语法糖背后还有真实的类型测试覆盖。CSF Next 的框架变体Angular组件类作为输入// Button.stories.ts (Angular / CSF Next ) import preview from ../.storybook/preview; import { Button } from ./button.component; const meta preview.meta({ component: Button, }); // Throws a type error if the args dont match the component props export const Primary meta.story({ args: { primary: true, }, });Web Components自定义元素标签// Button.stories.ts (Web Components / CSF Next ) import preview from ../.storybook/preview; const meta preview.meta({ component: demo-button, }); // Throws a type error if the args dont match the component props export const Primary meta.story({ args: { primary: true, }, });Vue 3SFC 组件// Button.stories.ts (Vue 3 / CSF Next ) import preview from ../.storybook/preview; import Button from ./Button.vue; const meta preview.meta({ component: Button, }); // Throws a type error if the args dont match the component props export const Primary meta.story({ args: { primary: true, }, });Vue 3 渲染器的公开类型在 code/renderers/vue3/src/public-types.ts其 CSF 工厂相关测试位于 code/renderers/vue3/src/csf-factories.test.tsReact 侧的同类工厂测试见 code/renderers/react/src/csf-factories.test.tsx。若在本地手动搭建新故事文件官方 CLI 也会按该模板生成对应的工厂写法模板源见 code/core/src/core-server/utils/new-story-templates/csf-factory-template.ts。用 satisfies 运算符把校验再收紧一层基线故事只保证args的类型正确而“某个必填的 prop 是否遗漏”这类约束要靠 TypeScript 4.9 的satisfies运算符实现。官方文档对应的两个增强片段是 docs/_snippets/button-story-baseline-with-satisfies.md组件级与 docs/_snippets/button-story-baseline-with-satisfies-story-level.md故事级。组件级 satisfies// Button.stories.ts|tsx (通用写法 / React、Vue 3 等) // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Meta } from storybook/your-framework; import { Button } from ./Button; const meta { component: Button, } satisfies Metatypeof Button; // Satisfies operator being used for stricter type checking. export default meta;// Button.stories.ts (Angular) import type { Meta } from storybook/angular; import { Button } from ./button.component; const meta { component: Button, } satisfies MetaButton; // Satisfies operator being used for stricter type checking. export default meta;注意基础基线片段button-story-baseline.md里只有通用common写法内置了satisfies而 Angular 与 Web Components 的基础版本没有。这与 docs/configure/integration/typescript.mdx 故障排查一节 “Thesatisfiesoperator is not working as expected” 的描述一致——由于 Angular 与 Web Components 渲染器的实现约束Storybook 目前难以判断这两类组件的属性是否必填因此对它们使用satisfies可能得不到预期效果。故事级 satisfiessatisfies不止用于 meta还可以逐条收紧每个故事对象从而在新增或修改故事时提示是否缺少必填的 arg// Button.stories.ts|tsx (通用写法 / React、Vue 3 等) // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { Meta, StoryObj } from storybook/your-framework; import { Button } from ./Button; const meta { component: Button, } satisfies Metatypeof Button; export default meta; type Story StoryObjtypeof meta; export const Example { args: { primary: true, label: Button, }, } satisfies Story;// Button.stories.ts (Angular) import type { Meta, StoryObj } from storybook/angular; import { Button } from ./button.component; const meta { component: Button, } satisfies MetaButton; export default meta; type Story StoryObjtypeof meta; export const Example { args: { primary: true, label: Button, }, } satisfies Story;故事级satisfies Story的价值在于当 Button 组件新增了必填 prop例如label所有未显式标注类型、仅靠 satisfies 约束的故事会被编译器标记为缺参从而把“漏传必填属性”的问题前置到编码阶段。它同时保留字面量类型的精确性不会把label: Button拓宽为宽泛的string。三种写法如何取舍场景推荐写法理由跟随当前稳定版、追求可移植性CSF 3MetaStoryObj全框架通用文档与工具链支持最成熟是仓库内绝大多数故事文件的形态想统一管理 addon 与全局类型、愿意尝鲜CSF Nextpreview.meta/meta.storymeta/story 自动继承 preview 的类型化配置有 csf-factories.test.ts 等类型测试背书但文档以 标注实验性接口可能演进需要强制“必填 prop 不遗漏”CSF 3 satisfies组件级与故事级satisfies可双管齐下Angular、Web Components 上存在限制需实测确认写在最后回顾 button-story-baseline.md 这短短一组片段其实浓缩了 Storybook 组件开发三条最重要的心智模型meta/story 的导出约定、args 与组件输入的编译期绑定、以及meta → story 的类型继承链条无论走StoryObjtypeof meta还是 CSF Next 的preview.meta()。把这段基线吃透后任何新组件的故事文件都可以从零开始 30 秒搭好再在其上叠加 decorator、play function 与 tags逐步演化出 docs/_snippets 目录中button-story-*系列其余更高阶的完整示例。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考