ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Storybook 组合组件 Story 展开指南:List + ListItem 多状态渲染的 CSF 3 与 CSF Next 多框架实现

Storybook 组合组件 Story 展开指南:List + ListItem 多状态渲染的 CSF 3 与 CSF Next 多框架实现 Storybook 组合组件 Story 展开指南List ListItem 多状态渲染的 CSF 3 与 CSF Next 多框架实现导读当一个组件天生需要与其他组件配合使用例如父组件List与子组件ListItem协同构成列表 UI时仅渲染一个「空壳」组件无法有效展示真实业务状态。本文以 Storybook 文档中list-story-expanded代码片段为骨架系统拆解如何通过自定义render函数或 Svelte 的 template 片段为组合组件编写 Empty / One Item / Many Items 三种展开形态的 Story并给出 Angular、React、Vue、Web Components、HTML、Solid、Svelte 七个渲染器的完整写法。读完本文你将掌握组合组件 Story 的标准组织模式、args 与渲染函数的协作关系以及 CSF 3 与 CSF Next工厂式 API两代组件故事格式在同一场景下的写法差异。一、代码片段在文档中的定位与适用场景docs/_snippets/list-story-expanded.md是 Storybook 官方写作指南中「Stories for two or more components」一节的进阶示例与 list-story-starter.md、list-story-reuse-data.md、list-story-with-subcomponents.md 共同组成一套递进式教学序列对应正文位于 docs/writing-stories/index.mdx。该序列的演进逻辑非常清晰starter 阶段List.stories.js中仅定义一个空的List不含任何ListItem子节点——正如代码注释所写Always an empty list, not super interesting它只是一个可运行但单调的基线。expanded 阶段本文主体在同一文件中导出Empty、OneItem、ManyItems三个 Story通过自定义渲染函数把不同数量的ListItem作为子内容嵌进List完整覆盖 0、1、N 三种形态同时仍将args透传给组件以便使用 Controls。reuse-data 阶段进一步从ListItem.stories中导入子组件的 Story如Selected、Unselected并复用其args避免在多处重复维护数据。理解list-story-expanded的关键在于它示范的不是如何写「单个组件的一个故事」而是如何为复合组件准备一系列可对照的状态快照——这正对应正文中it makes sense to customize the rendering to output the List component with different numbers of ListItem children的表述。二、三种形态背后的核心机制自定义 render 函数在展开该片段前需要先理解它依赖的底层概念。默认情况下Storybook 渲染 metadefault export中声明的组件并把args作为其属性传入见 docs/writing-stories/index.mdx 中 Custom rendering 一节。但当你要渲染的是父组件 若干子组件的复合结构时默认渲染就不够用了此时需要为 Story 提供render函数export const OneItem { render: (args) List {...args}ListItem //List, };render是框架相关的特性用于完全掌控组件如何被渲染。从源码结构看CSFComponent Story Format在元数据层面把 render 视为 Story 对象的一个普通属性Storybook 在收集与执行 Story 时会优先生效自定义 render 而非默认渲染路径。文档明确提示了两个必须掌握的细节透传 args自定义 render 中应当把接收到的args展开{...args}到目标组件上。只有这样做依赖 args 的特性如 Controls 面板才能继续工作让用户在 Storybook UI 里动态修改List的属性、在线压测边界状态。第二参数 contextrender函数实际上接收两个参数第二个参数context携带该 Story 的全部上下文信息包括parameters、globals等可按需读取。Svelte 渲染器则使用 template 片段snippet扮演同样的角色在Story内用{#snippet template(args)}定义渲染结构也可以把复用性高的片段提取到defineMeta的render属性上实现「meta 级复用、story 级覆盖」。三、ReactJSX实现最直观的复合渲染写法React 的 JSX 让「组合」变得自然render只需返回嵌入子组件的 JSX。CSF 3 版本如下为清晰省略了args透传等注释原文中的外链说明import type { Meta, StoryObj } from storybook/your-framework; // 按实际框架替换 import { List } from ./List; import { ListItem } from ./ListItem; const meta { component: List, } satisfies Metatypeof List; export default meta; type Story StoryObjtypeof meta; export const Empty: Story {}; export const OneItem: Story { render: (args) ( List {...args} ListItem / /List ), }; export const ManyItems: Story { render: (args) ( List {...args} ListItem / ListItem / ListItem / /List ), };注意两个有意思的对照Empty不需要任何render——它表示List在无子节点时的默认渲染因此保持空对象即可恰好还原了 starter 阶段的状态。OneItem与ManyItems才引入render二者只是子节点数量不同。这意味着当List或ListItem的接口发生变化时只需同步改动这一处 JSX 结构即可。Solid 渲染器storybook-solidjs-vite的写法与 React 几乎逐字相同仅satisfies Metatypeof List与导出类型保持一致即可这里不再重复贴码完整可参考 list-story-expanded.md 中renderersolid的两个代码块。四、Vue 与 Web Componentsrender 返回组件选项 / lit 模板Vue 3storybook/vue3-viteVue 的 render 需要返回一个「渲染选项对象」在components字段声明用到的组件并在template字符串中书写结构import type { Meta, StoryObj } from storybook/vue3-vite; import List from ./ListComponent.vue; import ListItem from ./ListItem.vue; const meta { component: List, } satisfies Metatypeof List; export default meta; type Story StoryObjtypeof meta; export const Empty: Story { render: () ({ components: { List }, template: List/, }), }; export const ManyItems: Story { render: (args) ({ components: { List, ListItem }, template: List list-item/ list-item/ list-item/ /List, }), };需要特别留意模板中组件标签的大小写在components中以 PascalCaseListItem注册在template中通常使用 kebab-caselist-item/这正是 Vue 模板编译对已注册组件的解析规则初学者最容易在此处踩坑。Web Componentsstorybook/web-components-viteWeb Components 场景中meta.component不再指向构造函数而是自定义元素标签名字符串如demo-list渲染使用lit的html模板标签import type { Meta, StoryObj } from storybook/web-components-vite; import { html } from lit; const meta: Meta { component: demo-list, }; export default meta; type Story StoryObj; export const ManyItems: Story { render: () html demo-list demo-list-item/demo-list-item demo-list-item/demo-list-item demo-list-item/demo-list-item /demo-list , };复合组件与 Web Components 的组合尤为契合——自定义元素本身就是通过嵌套文档结构表达的因此 story 中的嵌套书写几乎等价于最终使用方式。完整代码块中每种状态均以字面量形式展开便于与浏览器渲染结果一一对照。五、HTML命令式 DOM 工厂 appendChildstorybook/html渲染器没有 JSX 或模板编译示例假定组件文件导出createList(args)、createListItem()这类工厂函数render 内以命令式 DOM 操作拼接子元素import type { Meta, StoryObj } from storybook/html; import { createList, ListArgs } from ./List; import { createListItem } from ./ListItem; const meta: MetaListArgs { title: List, }; export default meta; type Story StoryObjListArgs; export const OneItem: Story { render: (args) { const list createList(args); list.appendChild(createListItem()); return list; }, };对比三种形态可以发现HTML 版本唯一变化的代码量被压缩为「空、一个 appendChild、三个 appendChild」非常直白。但注意上述示例代码块中存在一个明显的写法遗留Empty的 render 写成了render: () createList(args)args在此作用域未定义这是示例性文档代码实际使用时Empty更稳妥的写法是render: () createList({})或直接依赖默认渲染阅读代码块时需留意这一细节。六、AngularmoduleMetadata 声明 模板片段Angular 渲染器的模板基于Component元数据风格的对象字面量且必须借助moduleMetadata装饰器为 Storybook 的运行环境声明组件所属的 NgModule否则 Angular 编译器无法解析模板中的自定义标签import { type Meta, type StoryObj, moduleMetadata } from storybook/angular; import { CommonModule } from angular/common; import { List } from ./list.component; import { ListItem } from ./list-item.component; const meta: MetaList { component: List, decorators: [ moduleMetadata({ declarations: [List, ListItem], imports: [CommonModule], }), ], }; export default meta; type Story StoryObjList; export const OneItem: Story { render: (args) ({ props: args, template: app-list app-list-item/app-list-item /app-list, }), };两个值得深挖的工程点decorators 的声明时机declarations中把父组件List与子组件ListItem一并声明是从 starter 到 expanded 的关键差异——一旦模板里同时出现app-list和app-list-item遗漏任何一个都会导致运行时编译失败。imports: [CommonModule]则为模板提供*ngIf、*ngFor等公共指令支持。props与argsrender 返回对象中props: args负责把 Storybook 的 args 映射为模板可读取的属性这正是 Angular 语境下「透传 args」的实现方式。装饰器与渲染的运行时逻辑位于渲染器实现目录 code/frameworks/angular/src/client 之下可供深入阅读。七、Svelte CSF用Story组件与 template 片段声明Svelte 渲染器不采用命名导出定义 story而是使用storybook/addon-svelte-csf的Story组件需在svelte.config/.storybook/main中注册该 CSF 支持。模板片段{#snippet template(args)}等价于其他框架的 render 函数script module import { defineMeta } from storybook/addon-svelte-csf; import List from ./List.svelte; import ListItem from ./ListItem.svelte; const { Story } defineMeta({ component: List, }); /script Story nameEmpty / Story nameMany Items {#snippet template(args)} List {...args} ListItem / ListItem / ListItem / /List {/snippet} /StorySvelte 版有两个语言特性值得说明nameMany Items允许 Story 名称含空格而不必受导出标识符限制Empty直接使用自闭合Story nameEmpty /意味着它完全交给默认渲染仅渲染List本身与 React 版本中Empty: Story {}的语义一致。仓库中还以 TS 语言标注提供了一份renderersvelte languagets的等价版本差异仅在 Svelte 版本对 script 块的类型标注结构完全相同。八、CSF 3 与 CSF Next同一场景的两种组织范式在 Angular、Vue、React、Web Components 的代码块中snippet 同时提供了标注为CSF 3与CSF Next 的两套 tab。后者即官方文档中所称的 CSF 工厂式 API详细介绍见 docs/api/csf/csf-next.mdx。CSF Next 的工厂链CSF Next 采用链式工厂设计每一步都推进类型推断definePreview→preview.meta→meta.story。以 Vue 版本为例import preview from ../.storybook/preview; import List from ./ListComponent.vue; import ListItem from ./ListItem.vue; const meta preview.meta({ component: List, }); export const Empty meta.story({ render: () ({ components: { List }, template: List/, }), }); export const ManyItems meta.story({ render: (args) ({ components: { List, ListItem }, template: List list-item/ list-item/ list-item/ /List, }), });与 CSF 3 的差异集中在三点不再export default metameta 经由preview.meta(...)产生每个 story 不再使用命名导出 StoryObj类型标注而是通过meta.story({...})创建类型安全在每一步自动向下游传递这也是代码片段中Empty可写成零参数meta.story()React/Web Components 版本的原因——默认渲染无需任何配置。从 csf-next.mdx 的声明看CSF Next 目前处于 preview 阶段且仅面向 React、Vue、Angular、Web Components 项目开放。这也解释了为何list-story-expanded中只有这四个渲染器提供 CSF Next 变体而 HTML、Solid 只提供 CSF 3Svelte 则一直使用addon-svelte-csf的Story语法。升级路径与混用边界CSF Next 设计为可增量采用无需一次性迁移全部 story 文件但同一文件内不能混用两代格式。官方升级入口为npx storybook migrate csf-3-to-next --glob**/*.stories.ts自动升级前需先将项目升级到 CSF 3旧文件也可按 csf-next.mdx 的升级说明手工迁移。需要同步升级.storybook/main.*与.storybook/preview.*至defineMain/definePreview形态。此外若后续复用子组件 story 的 args 数据reuse-data 模式CSF Next 中推荐改用Story.input.args而非旧的Story.args直接访问方式——后者虽仍受支持但已被标记为弃用详见 csf-next.mdx 关于复用 story 的说明。九、组合组件的可维护性权衡当需要复用子组件状态时官方建议从ListItem.stories导入 Story 并把它们的 args 注入当前 story示例见 list-story-reuse-data.md。但这种「逐层展开子节点」的写法也有代价文档明确给出警示见 index.mdx 中的 Callout——以硬编码子组件嵌套的方式写 story无法充分利用 args 机制与 args 组合能力构建更深层的复合组件时会越来越吃力。因此实践中的选型建议是组件组合层级浅、状态形态有限空/单/多等少量快照时采用本文的 expanded 展开模式直观且易于演示需要精细控制 props 分布、交互与可组合性时应转向 stories-for-multiple-components.mdx 描述的工作流若希望 List 的 story 以子组件 Story而非裸组件形式接收内容可参考 list-story-with-subcomponents.md 中的subcomponents写法该文件同时被 autodocs.mdx 引用说明它还会影响自动文档对关联组件的展示。十、模式总结与速查表list-story-expanded展示的核心可复用模式可归纳为关注点建议做法状态划分按 0 / 1 / N 子节点拆分为Empty、OneItem、ManyItems命名即状态默认形态Empty尽量留空或仅依赖默认渲染与 starter 基线保持一致自定义渲染非空形态提供render/template snippet完整书写嵌套结构args 透传JSX 用{...args}Vue 用components templateAngular 用props: argsSvelte 用{#snippet template(args)}多组件声明Angular 需在moduleMetadata.declarations声明全部父子组件Vue 需在 render 返回对象的components中注册格式选择未启用 CSF Next 时统一 CSF 3启用后同文件内不得混用格式组合深度层级深、需 args 组合时转向 subcomponents / stories-for-multiple-components 方案把这套模式套用到任何「容器 子项」型组件菜单与菜单项、表格与单元格、卡片组与卡片……上即可在 Storybook 中稳定呈现组件在不同子内容规模下的全部关键状态。文中所有多框架完整代码块含 JS 与 TS 双版本均可直接查阅 list-story-expanded.md 原文对照使用。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表