首页
/ Storybook 多组件 Story 数据复用指南:把 ListItem 的故事数据带进 List(CSF 3 与 CSF Next 全渲染器示例)

Storybook 多组件 Story 数据复用指南:把 ListItem 的故事数据带进 List(CSF 3 与 CSF Next 全渲染器示例)

2026-09-07 09:27:40作者:余洋婵Anita

本指南围绕 Storybook 组件故事格式(CSF)中的"复合组件(composite component)"场景,系统讲解如何复用子组件(如 ListItem)的 story 定义与 args 数据,来驱动父组件(如 List)的故事渲染。读完你将掌握跨组件复用 story 数据的完整写法,覆盖 React、Vue、Angular、HTML、Solid、Svelte 六种渲染器,并理解 CSF 3 与实验性的 CSF Next(preview.meta / meta.story)两种语法的差异与取舍,为构建 ButtonGroupListPage 等天然需要协作渲染的组件提供可直接落地的工程范式。

场景起点:为什么要"一个故事渲染多个组件"

官方文档 docs/writing-stories/index.mdx 在 "Stories for two or more components" 一节中指出:有些组件从设计之初就是要协同工作的,例如 ButtonGroup 需要若干 Button、父级 List 需要若干子项 ListItemPage 需要组合多个区块。

当遇到这类父子结构组件时,如果只在 List.stories.ts 里手工编写每个 ListItem 的 props,数据一旦变更就需要在多个位置同步修改,维护成本很高。更稳妥的做法是:把子组件的 story 当作可引用的数据源,直接在父组件 story 里 import 进来,例如:

import { Selected, Unselected } from './ListItem.stories';

story 本质上只是普通导出的 JavaScript 对象,story.args 就是一份可序列化的组件输入数据。因此父组件的 story 可以读取子组件 story 的 .args 来填充自己的渲染内容,从而做到"只在一处维护数据、多处自动同步"。docs 中的 index.mdx 提到:当 Button 的签名变化时,只需修改 Button 的 stories,ButtonGroup 的 stories 就会自动更新——这正是本篇数据复用模式的核心价值。

更完整的工作流讨论见 docs/writing-stories/stories-for-multiple-components.mdx,其中按 "Subcomponents"、"Reusing story definitions"、"Using children as an arg"、"Creating a Template Component" 四个层次递进展开。本篇聚焦其中的 "Reusing story definitions"(即 list-story-reuse-data.md)并在其基础上扩展到各渲染器与各 CSF 版本。

核心模式:从 ListItem.stories 导入 story,用 .args 渲染

本片段的模板代码位于 docs/_snippets/list-story-reuse-data.md。它假设子组件侧已经导出了两个带数据的 story:

  • Selected(选中项,如 args: { isSelected: true }
  • Unselected(未选中项,如 args: { isSelected: false }

List.stories 中,我们把它们 import 进来,然后在 ManyItems story 的 render 里用 Selected.args.isSelectedUnselected.args.isSelected 拼装出多个 ListItem

import { Selected, Unselected } from './ListItem.stories';

关键点有两个:

  1. story.args 是普通数据对象,可以像访问组件输入属性一样读取(isSelected 只是一个布尔 prop);
  2. render 是自定义渲染函数,父组件默认渲染逻辑不满足"一个 List 里放多个 ListItem"时,由你完全控制输出结构。

这样写比在父 story 里重新硬编码 props 更易维护,因为 isSelected 的真实取值只存在于 ListItem.stories 一处。

各渲染器完整实现

该片段覆盖六种渲染器,下面按渲染器逐一给出可运行的实现与要点。为节省篇幅,同一渲染器的 CSF 3 与 CSF Next 两种写法在 React / Vue 一节分别完整列出,其余渲染器保留典型 CSF 3 写法并说明 Next 迁移要点。

React(JSX/TSX,CSF 3)

对于 React,ManyItemsListItem 展开渲染到 List 内部,三个子项分别使用 Selected.argsUnselected.argsUnselected.args

import * as React from 'react';

import { List } from './List';
import { ListItem } from './ListItem';

//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';

const meta = {
  component: List,
} satisfies Meta<typeof List>;

export default meta;
type Story = StoryObj<typeof meta>;

export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem {...Selected.args} />
      <ListItem {...Unselected.args} />
      <ListItem {...Unselected.args} />
    </List>
  ),
};

<ListItem {...Selected.args} /> 通过 JSX 展开语法把选中 story 的 args 一次性作为 props 传入,数据与子组件 story 保持单一来源。JS 版去掉类型标注即可(参见 list-story-reuse-data.md 中 React CSF 3 JS 变体)。

React(CSF Next 🧪)

实验性的 CSF Next 语法中,meta 改由 preview.meta({...}) 创建,story 用 meta.story({...}) 声明,且读取 args 的路径从 Selected.args 变为 Selected.input.args

import * as React from 'react';

import preview from '../.storybook/preview';

import { List } from './List';
import { ListItem } from './ListItem';

//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';

const meta = preview.meta({
  component: List,
});

export const ManyItems = meta.story({
  render: (args) => (
    <List {...args}>
      <ListItem {...Selected.input.args} />
      <ListItem {...Unselected.input.args} />
      <ListItem {...Unselected.input.args} />
    </List>
  ),
});

注意差异:CSF Next 中 preview.meta() 返回的是受约束的 meta 工厂,meta.story() 接收的故事对象需要访问 Selected.input.args——input 是 CSF Next 对"输入 args"的命名。CSF 3 与 CSF Next 的对照在官方文档中定义为:CSF Next 提供 preview.meta() + meta.story() 的新式 API(相关片段同样位于 list-story-reuse-data.md)。迁移时 argsinput.args 的命名差异是首个需要适配的点。

Angular(CSF 3)

Angular 版本需要 moduleMetadata 声明 ListListItemCommonModule,然后通过模板插值渲染:

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';

//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';

const meta: Meta<List> = {
  component: List,
  decorators: [
    moduleMetadata({
      declarations: [List, ListItem],
      imports: [CommonModule],
    }),
  ],
};

export default meta;
type Story = StoryObj<List>;

export const ManyItems: Story = {
  args: {
    Selected: Selected.args.isSelected,
    Unselected: Unselected.args.isSelected,
  },
  render: (args) => ({
    props: args,
    template: `
      <app-list>
        <app-list-item [isSelected]="Selected"></app-list-item>
        <app-list-item [isSelected]="Unselected"></app-list-item>
        <app-list-item [isSelected]="Unselected"></app-list-item>
      </app-list>
    `,
  }),
};

实现要点:

  • Angular 的 render 返回 { props, template } 对象,props 即绑定到模板上下文的数据;
  • 由于模板里只能引用顶层变量,示例先把 Selected.args.isSelected 提取为 args 中的 Selected / Unselected 布尔值,再在模板中 [isSelected]="Selected" 使用;
  • 每个 app-list-item 通过属性绑定 [isSelected] 获取选中状态,从而在渲染阶段完成数据复用。

Vue 3(CSF 3,Vue TS 变体)

Vue 的写法把导入的 story 数值转存进 args,同时 render 返回组件配置与 setup()

import type { Meta, StoryObj } from '@storybook/vue3-vite';

import List from './ListComponent.vue';
import ListItem from './ListItem.vue';

//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';

const meta = {
  component: List,
} satisfies Meta<typeof List>;

export default meta;
type Story = StoryObj<typeof meta>;

export const ManyItems: Story = {
  render: (args) => ({
    components: { List, ListItem },
    setup() {
      return { ...args };
    },
    template: `
      <List v-bind="args">
        <list-item :isSelected="Selected" />
        <list-item :isSelected="Unselected" />
        <list-item :isSelected="Unselected" />
      </List>`,
  }),
  args: {
    Selected: Selected.args.isSelected,
    Unselected: Unselected.args.isSelected,
  },
};

需要注意模板中的 SelectedUnselected 实际来自 args,因此必须显式放在 args 字段里(setup() 解构 ...args 后注入模板上下文),JS 版与此等价。Vue 的 CSF Next 变体同样把 Selected.args.isSelected 换成 Selected.input.args.isSelected

HTML(TS 变体)

HTML 渲染器使用纯 DOM 拼接,render 中创建元素后把 story 的 args 作为子项数据传入工厂函数:

import type { Meta, StoryObj } from '@storybook/html';

import { createList, ListArgs } from './List';
import { createListItem } from './ListItem';

// 👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';

const meta: Meta<ListArgs> = {
  title: 'List',
};

export default meta;
type Story = StoryObj<ListArgs>;

export const ManyItems: Story = {
  render: (args) => {
    const list = createList(args);
    list.appendChild(createListItem(Selected.args));
    list.appendChild(createListItem(Unselected.args));
    list.appendChild(createListItem(Unselected.args));
    return list;
  },
};

在这里,Selected.args / Unselected.args 直接作为 createListItem 的参数传入,复用的是 story 数据而非 UI 逻辑——这正是"数据复用"模式的本质。

Solid(TSX 变体)

Solid 与 React 的 JSX 展开思路一致,仅类型来自 storybook-solidjs-vite

import type { Meta, StoryObj } from 'storybook-solidjs-vite';

import { List } from './List';
import { ListItem } from './ListItem';

//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';

const meta = {
  component: List,
} satisfies Meta<typeof List>;

export default meta;
type Story = StoryObj<typeof meta>;

export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem {...Selected.args} />
      <ListItem {...Unselected.args} />
      <ListItem {...Unselected.args} />
    </List>
  ),
};

Svelte(Svelte CSF)

Svelte 渲染器使用 @storybook/addon-svelte-csfdefineMeta 与模板语法:

<script module>
  import { defineMeta } from '@storybook/addon-svelte-csf';

  import List from './List.svelte';
  import ListItem from './ListItem.svelte';

  //👇 We're importing the necessary stories from ListItem
  import { Selected, Unselected } from './ListItem.stories.svelte';

  const { Story } = defineMeta({
    component: List,
  });
</script>

<Story name="Many Items">
  {#snippet children(args)}
    <List {...args}>
      <ListItem {...Selected.args} />
      <ListItem {...Unselected.args} />
      <ListItem {...Unselected.args} />
    </List>
  {/snippet}
</Story>

Svelte 场景下通过 children snippet 的 args 向子内容传入数据,再在模板内展开三个 ListItem。值得留意的是,docs 在 index.mdx 的 Svelte 部分有专门的提示:Svelte CSF 对该类 args 联动特性支持有限,如需完整的 args 机制应改用标准的 Component Story Format(即普通 CSF 文件而非 Svelte CSF)。

CSF 3 与 CSF Next 🧪 的对照要点

片段中为 Angular、React、Vue 同时提供了 CSF Next 实验性写法。两套 API 对照如下:

关注点 CSF 3 CSF Next 🧪
meta 创建 export default { component: List } / satisfies Meta const meta = preview.meta({ component: List })preview../.storybook/preview 导入
story 声明 export const ManyItems: Story = {...} export const ManyItems = meta.story({...})
复用 args Selected.args.isSelected Selected.input.args.isSelected
Angular 声明 decorators: [moduleMetadata({...})] 于 meta 同左,传入 preview.meta({...})
模板/渲染 render: (args) => ({...}) 语法与 CSF 3 一致,仅外围工厂不同

CSF Next 把 story 定义收敛进 preview.meta() / meta.story() 的工厂体系,并通过 .input.args 明确区分"输入数据"与"内部处理后的 args"。它是当前 Storybook 正在演进的方向,标注 🧪 表示实验性(相关片段可见 list-story-reuse-data.md 中带 tabTitle="CSF Next 🧪" 的代码块)。

数据的单一来源与手动 render 的代价

这种写法的优点是直观、易维护:子组件 props 只在 ListItem.stories 定义一次,父组件 story 通过 Selected.args 引用。docs 用一句话概括其局限(见 index.mdx 中紧随 list-story-reuse-data 的 Callout):这样写无法充分发挥 args 机制的能力,也不能在构建更复杂组合组件时继续组合 args。具体表现为:

  • ListItem 的故事数据虽是复用对象,但通过手写 render 落到 DOM,子项并不会被 Controls 面板识别,你无法在 UI 上直接编辑其中某个 ListItemisSelected
  • 复用停留在"数据粘贴"层面,数据并没有真正成为父 story 可参数化的部分。

更进一步的三种演进方案

官方在 stories-for-multiple-components.mdx 中针对上述局限给出了三种升级路径,可作为本文模式的自然延伸:

  1. subcomponents 文档化:在 meta 中声明 subcomponents: { ListItem }(示例见 list-story-with-subcomponents.md),可以让 ArgTypes / Controls 面板额外展示子组件的 props 表。其限制是子组件 argTypes 只能自动推断、不能手工覆盖,且 Controls 始终只作用于主组件 args,子组件没有独立 controls。

  2. 把子组件提升为 children arg:React 专属增强(示例见 list-story-with-unchecked-children.md)。将渲染好的子元素放入 args.children(如 children: <Unchecked {...Unchecked.args} />),使子内容也可作为 arg 被复用。由于 args 需要 JSON 可序列化,官方提醒避免空值、复杂映射值建议配合 Controls 的 mapping 机制,并谨慎对待引入第三方库的组件。

  3. 创建模板组件(Template Component):一种更偏"数据"的方案,即编写一个专门用于按数据生成 story 的模板组件(示例见 list-story-template.md)。设置成本更高,但每个 story 的 args 都能在组合组件内被真正复用,并且可以通过 Controls 面板修改传入复合组件的 args。

选择何种方案取决于你的诉求:若只是让父子组件"共同出图",subcomponents 足够;若希望复用可控、可参数化,优先考虑 children arg 或 Template Component。

底层原理与阅读建议

Storybook 的 CSF 本质上要求每个 *.stories.* 模块导出一个默认的 meta 对象与若干具名 story 对象。story.args 是框架无关的纯数据层,因此跨文件 import story 并不会触发渲染副作用,只是读取普通 JS 对象——这正是本模式成立的前提。

若想深入:

  • 先对照阅读"组合组件总论":stories-for-multiple-components.mdx,其中包含 subcomponents 面板截图、children arg 与 template 方案的完整讨论;
  • 再回到"多组件故事入门":index.mdxStories for two or more components 一节,确认 List / ListItem 样例的完整上下文(从空列表 starter list-story-starter.md、展开列表 expanded list-story-expanded.md 一路读到数据复用本片段);
  • CSF 基础语法可参考 Component Story Format 文档,Controls 对复杂值的处理见官方 Controls 文档中的 mapping 说明。

结语

复用 story 数据(story.args)是 Storybook 中处理父子组件组合的关键技巧:它以最小的重复代价保持了 props 数据的单一来源,是通往 subcomponents、children-arg、Template Component 等高级组合方案的起点。对照本文给出的 React、Vue、Angular、HTML、Solid、Svelte 六种渲染器及 CSF 3 / CSF Next 双语法示例,你可以直接在自己的 List.stories 中落地,并依据是否需要对子项做 Controls 级控制来决定是否演进到更复杂的组合模式。

登录后查看全文
热门项目推荐
相关项目推荐