尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Storybook 复合组件 Stories 模板复用实践:以 List + ListItem 为例讲解 Template 渲染技巧

Storybook 复合组件 Stories 模板复用实践:以 List + ListItem 为例讲解 Template 渲染技巧 Storybook 复合组件 Stories 模板复用实践以 List ListItem 为例讲解 Template 渲染技巧导读在设计相互配合的复合组件如ButtonGroup、List、Page时单独为每个组件写 story 往往无法呈现真实的组合行为。本文围绕 Storybook 官方写作规范中为多个组件编写 Stories的Creating a Template Component创建模板组件方案展开以ListListItem的父子组合为例完整讲解如何在 React、Angular、Vue 3、Solid、Svelte 与 Web Components 六种渲染框架下通过一个共享的模板渲染函数与args复用编写Empty、OneItem等基于数据驱动的复合组件 story并同步给出 CSF Next 实验性写法。读完本文你将掌握复合组件 story 的模板复用模式及其背后的 args 机制。场景为什么复合组件需要专门的写 story 方法当两个或以上组件被设计为协同工作时典型如父组件List与子组件ListItem只针对父组件或子组件单独写 story无法表达父组件下挂着若干子组件的真实渲染形态。官方文档 stories-for-multiple-components.mdx 依次介绍了四种由浅入深的手段subcomponents属性在 meta 中声明父子组件关系仅用于文档化展示如 list-story-with-subcomponents.md复用 story 定义把子组件 story 的args作为输入数据喂给父组件 story如 list-story-reuse-data.mdReact 的 children arg仅 React把渲染出的子组件抽进children参数创建模板组件Template Component即本篇文章主体代码片段 list-story-template.md 演示的数据驱动方案——构建一个专门用于批量生成 story的模板渲染函数。第四种方案前期投入最多但它让复合组件中的每一个 story 都拥有完整可复用的args并且可以直接用 Storybook 的 Controls 面板动态调整参数值是组合场景下可维护性最高的写法。核心原理render 函数 args 展开模板复用的本质依赖 Storybook 的自定义渲染机制这一点在 index.mdx 的 Custom rendering 章节有详细定义story 默认渲染 meta 中声明的component并把args传给组件当需要渲染组件 自定义子结构时可以提供一个render函数或 Svelte 的 template snippet由它决定最终输出。因此上面的List场景可以归纳为一条通用公式从子组件 story 文件导入一个具体 story例如Unchecked把该 story 的args通过{ ...Unchecked.args }展开作为一条列表数据喂给List的items参数编写渲染函数遍历items为每一项渲染一个ListItem复用该模板定义出Empty空列表与OneItem单条数据等多个 story。由于ListTemplate中展开的是items数组而非硬编码的 JSX列表数据真正来自 story 的args所以 Controls 面板可以实时编辑items增删项、修改子项字段都被允许——这正是该方案相比硬编码多个ListItem的核心优势。CSF 3以 ListTemplate 模板共享渲染结构以下按框架逐一给出可复制运行的完整写法。所有片段均以项目根目录相对路径 docs/_snippets/list-story-template.md 中的原始代码为准。ReactJS 与 TSReact 版本将渲染函数定义为ListTemplate对象通过展开运算符...ListTemplate注入到各 story 中import { List } from ./List; import { ListItem } from ./ListItem; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; export default { component: List, }; // The ListTemplate construct will be spread to the existing stories. const ListTemplate { render: ({ items, ...args }) { return ( List {items.map((item) ( ListItem {...item} / ))} /List ); }, }; export const Empty { ...ListTemplate, args: { items: [], }, }; export const OneItem { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };TypeScript 版本使用satisfies Metatypeof List保证类型推导并声明type Story StoryObjtypeof meta// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc. import type { Meta, StoryObj } from storybook/your-framework; import { List } from ./List; import { ListItem } from ./ListItem; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta { component: List, } satisfies Metatypeof List; export default meta; type Story StoryObjtypeof meta; // The ListTemplate construct will be spread to the existing stories. const ListTemplate: Story { render: ({ items, ...args }) { return ( List {items.map((item) ( ListItem {...item} / ))} /List ); }, }; export const Empty { ...ListTemplate, args: { items: [], }, }; export const OneItem { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };两点提示注意渲染函数把...args与items同时传递剩余args会透传给父组件List例如标签、点击回调等额外属性保证 Controls 对这些参数依然生效。ListTemplate本身是一个 story 形状的对象含render并不需要被导出用对象展开把它合并进Empty、OneItem即可。Angular结合 moduleMetadata 装饰器Angular 的组件必须归属某个 NgModule 才能渲染因此 meta 中需要通过moduleMetadata装饰器声明List与ListItem并引入CommonModule模板中的*ngFor依赖它。Angular 的render返回一个描述组件模板的对象{ props: args, template: ... }。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; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta: MetaList { component: List, decorators: [ moduleMetadata({ declarations: [List, ListItem], imports: [CommonModule], }), ], }; export default meta; type Story StoryObjList; /* * Render functions are a framework specific feature to allow you control on how the component renders. * See https://storybook.js.org/docs/api/csf * to learn how to use render functions. */ const ListTemplate: Story { render: (args) ({ props: args, template: app-list div *ngForlet item of items app-list-item [item]item/app-list-item /div /app-list , }), }; export const Empty: Story { ...ListTemplate, args: { items: [] }, }; export const OneItem: Story { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };Vue 3Vue 3 的render函数返回的是 Vue 组件选项对象需要在components中注册List、ListItem通过setup()把args暴露给模板再用v-for遍历items。JS 与 TS 两个版本仅类型声明不同逻辑完全一致import List from ./List.vue; import ListItem from ./ListItem.vue; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; export default { component: List, }; // The ListTemplate construct will be spread to the existing stories. const ListTemplate { render: (args) ({ components: { List, ListItem }, setup() { return { ...args }; }, template: List v-bindargs div v-foritem in items :keyitem.title ListItem :itemitem / /div /List , }), }; export const Empty { ...ListTemplate, args: { items: [], }, }; export const OneItem { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };import type { Meta, StoryObj } from storybook/vue3-vite; import List from ./List.vue; import ListItem from ./ListItem.vue; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta { component: List, } satisfies Metatypeof List; export default meta; type Story StoryObjtypeof meta; // The ListTemplate construct will be spread to the existing stories. export const ListTemplate: Story { render: (args) ({ components: { List, ListItem }, setup() { return { ...args }; }, template: List v-bindargs div v-foritem in items :keyitem.title ListItem :itemitem / /div /List , }), }; export const Empty: Story { ...ListTemplate, args: { items: [], }, }; export const OneItem: Story { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };需要注意CSF 3 的常规约束在这里同样适用循环中给每一条数据一个稳定 key如示例中的:keyitem.title有助于 diff 性能但前提是item.title确实存在请根据自身组件的数据模型调整。SolidSolid 框架的写法与 React 几乎相同仅类型来源改为storybook-solidjs-viteimport { List } from ./List; import { ListItem } from ./ListItem; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; export default { component: List, }; // The ListTemplate construct will be spread to the existing stories. const ListTemplate { render: ({ items, ...args }) { return ( List {items.map((item) ( ListItem {...item} / ))} /List ); }, }; export const Empty { ...ListTemplate, args: { items: [], }, }; export const OneItem { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };import type { Meta, StoryObj } from storybook-solidjs-vite; import { List } from ./List; import { ListItem } from ./ListItem; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta { component: List, } satisfies Metatypeof List; export default meta; type Story StoryObjtypeof meta; // The ListTemplate construct will be spread to the existing stories. const ListTemplate: Story { render: ({ items, ...args }) { return ( List {items.map((item) ( ListItem {...item} / ))} /List ); }, }; export const Empty { ...ListTemplate, args: { items: [], }, }; export const OneItem { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };SvelteSvelte CSFdefineMeta 模板 snippetSvelte 不采用导出对象的传统 CSF而是通过社区主导的storybook/addon-svelte-csf的defineMeta声明组件、用Story组件声明 story。共享的渲染结构用 Svelte 5 的 snippet 语法{#snippet template(args)}定义再把template作为属性传给每个Storyscript module import { defineMeta } from storybook/addon-svelte-csf; import List from ./List.svelte; import ListItem from ./ListItem.svelte; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories.svelte; const { Story } defineMeta({ component: List, }); /script !-- The template construct will be spread to the existing stories. Its based on Sveltes snippet syntax allowing you share the same UI with small variations. -- {#snippet template(args)} List {...args} {#each args.items as item} ListItem {...item} / {/each} /List {/snippet} Story nameEmpty args{{ items: [] }} {template} / Story nameOne Item args{{ items: [{ ...Unchecked.args }], }} {template} /TypeScript 版本代码结构与上完全相同模板中 Svelte 的自动类型检查会依据args推导类型这里不再重复展开。可留意 snippet 中{template}的简写属性形式等价于template{template}它把外层定义的共享 UI 注入到每个 story 中。Web ComponentsLitWeb Components 通过storybook/web-components-vite的 meta 描述自定义元素名component: demo-list渲染层使用 Lit 的html标签模板与repeat指令保证列表 diff 正确。children 是具名的自定义元素因此不需要 spread 型 props而是直接向子元素传值import { html } from lit; import { repeat } from lit/directives/repeat.js; import { Unchecked } from ./ListItem.stories; export default { component: demo-list, }; // The ListTemplate construct will be spread to the existing stories. const ListTemplate { render: ({ items, ...args }) { return html demo-list ${repeat(items, (item) htmldemo-list-item${item}/demo-list-item)} /demo-list ; }, }; export const Empty { ...ListTemplate, args: { items: [], }, }; export const OneItem { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };import type { Meta, StoryObj } from storybook/web-components-vite; import { html } from lit; import { repeat } from lit/directives/repeat.js; const meta: Meta { component: demo-list, }; export default meta; type Story StoryObj; // The ListTemplate construct will be spread to the existing stories. const ListTemplate { render: ({ items, ...args }) { return html demo-list ${repeat(items, (item) htmldemo-list-item${item}/demo-list-item)} /demo-list ; }, }; export const Empty: Story { ...ListTemplate, args: { items: [], }, }; export const OneItem: Story { ...ListTemplate, args: { items: [{ ...Unchecked.args }], }, };CSF Next预览特性从 ListTemplate 到 meta.story 工厂链上述同主题片段中还包含一类tabTitleCSF Next 的写法其形态可以在 csf-next.mdx 中找到完整定义。CSF Next 是 Storybook 对 Component Story Format 的下一代演进采用工厂函数链definePreview → preview.meta → meta.story提供端到端类型安全。仓库文档 csf-next.mdx 同时注明该特性目前处于 preview 阶段仅支持React、Vue、Angular、Web Components项目不支持 Svelte 与 SolidAPI 在未来发布中仍可能调整。与传统 CSF 3 的差异集中体现在三处见 csf-next.mdx 的迁移章节meta 不再用default export导出而是通过从../.storybook/preview导入的preview.meta({ ... })创建story 从meta.story({ ... })工厂创建需要基于某个已有 story 派生出新 story 时使用Story.extend({ ... })方法需要读取某个 story 的直接输入即没有被其它 story 合成覆盖前的原始 args时使用Story.input而非Story.composed。下面以 Angular 为例展示把模板复用迁移到 CSF Next 后的形态import { CommonModule } from angular/common; import { moduleMetadata } from storybook/angular; import preview from ../.storybook/preview; import { List } from ./list.component; import { ListItem } from ./list-item.component; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta preview.meta({ component: List, decorators: [ moduleMetadata({ declarations: [List, ListItem], imports: [CommonModule], }), ], }); /* * Render functions are a framework specific feature to allow you control on how the component renders. * See https://storybook.js.org/docs/api/csf * to learn how to use render functions. */ const ListTemplate { render: (args) ({ props: args, template: app-list div *ngForlet item of items app-list-item [item]item/app-list-item /div /app-list , }), }; export const Empty meta.story({ ...ListTemplate, args: { items: [] }, }); export const OneItem Empty.extend({ args: { items: [{ ...Unchecked.input.args }], }, });注意这里出现了两个变化Empty由meta.story({ ... })工厂生成OneItem不再...ListTemplate平铺而是通过Empty.extend({ ... })派生——它天然继承Empty的渲染与全部 args取子 story 的入参时使用Unchecked.input.args而不是 CSF 3 的Unchecked.args。React 与 Vue、Web Components 的 CSF Next 版本遵循同一条工厂链仅在渲染函数形态上有框架差异。以 Vue 3 为例JS/TS 均同构import preview from ../.storybook/preview; import List from ./List.vue; import ListItem from ./ListItem.vue; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta preview.meta({ component: List, }); export const Empty meta.story({ render: (args) ({ components: { List, ListItem }, setup() { return { ...args }; }, template: List v-bindargs div v-foritem in items :keyitem.title ListItem :itemitem / /div /List , }), args: { items: [], }, }); export const OneItem Empty.extend({ args: { items: [{ ...Unchecked.input.args }], }, });React 的 CSF Next 写法是渲染函数返回 JSX、story 通过Empty.extend派生import preview from ../.storybook/preview; import { List } from ./List; import { ListItem } from ./ListItem; // Imports a specific story from ListItem stories import { Unchecked } from ./ListItem.stories; const meta preview.meta({ component: List, }); export const Empty meta.story({ render: ({ items, ...args }) { return ( List {items.map((item) ( ListItem {...item} / ))} /List ); }, args: { items: [], }, }); export const OneItem Empty.extend({ args: { items: [{ ...Unchecked.input.args }], }, });以及 Web ComponentsLit的 CSF Next 版本import { html } from lit; import { repeat } from lit/directives/repeat.js; import preview from ../.storybook/preview; import { Unchecked } from ./ListItem.stories; const meta preview.meta({ component: demo-list, }); export const Empty meta.story({ render: ({ items, ...args }) { return html demo-list ${repeat(items, (item) htmldemo-list-item${item}/demo-list-item)} /demo-list ; }, args: { items: [], }, }); export const OneItem Empty.extend({ args: { items: [{ ...Unchecked.input.args }], }, });import { html } from lit; import { repeat } from lit/directives/repeat.js; import preview from ../.storybook/preview; import { Unchecked } from ./ListItem.stories; const meta preview.meta({ component: demo-list, }); export const Empty meta.story({ render: ({ items, ...args }) { return html demo-list ${repeat(items, (item) htmldemo-list-item${item}/demo-list-item)} /demo-list ; }, args: { items: [], }, }); export const OneItem Empty.extend({ args: { items: [{ ...Unchecked.input.args }], }, });收益与适用边界综合原片段与 stories-for-multiple-components.mdx 中的官方说明这套模板组件方案的价值和代价可归纳为收益列表数据items真正存于 story 的args中因此可以通过 Controls 面板实时增删与编辑子项进而复用同一模板压测边界情形ListTemplate可以继续用于构建更复杂的组合比如在页面级 story 中再次拼接List由于数据来自Unchecked.args当ListItem的入参结构发生变化时只需更新ListItem.storiesList侧所有 story 自动同步避免了在多处重复维护同一份数据。边界与注意事项该方案比简单平铺子组件需要更多前期设置见 stories-for-multiple-components.mdx 原文结论args整体上要求具备 JSON 序列化能力组件、函数类入参不建议直接放入其中若选用 CSF Next 写法需先确认项目已采用对应的definePreview配置体系且引擎框架属于 React / Vue / Angular / Web Components 之一若团队仍使用 Svelte 的Story/ snippet 模式则保持 Svelte CSF 写法它并不属于 CSF Next 的覆盖范围。结语把共享一个模板渲染函数 用对象展开或.extend派生多个 story的组合实际上是把UI 结构与数据两件事彻底解耦结构只写一遍数据以args形式各自注入。这种写法正是 Storybook 提倡的 args 驱动设计在复合组件上的延伸。如果你想继续深挖可在本仓库内顺序阅读 stories-for-multiple-components.mdx、index.mdx、csf-next.mdx 以及片段文件 list-story-template.md对照源码中的subcomponents、reuse-data、with-unchecked-children三个邻近片段即可完整掌握复合组件 story 的全部演进路线。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表