Storybook 多组件 Story 数据复用指南:把 ListItem 的故事数据带进 List(CSF 3 与 CSF Next 全渲染器示例)
本指南围绕 Storybook 组件故事格式(CSF)中的"复合组件(composite component)"场景,系统讲解如何复用子组件(如 ListItem)的 story 定义与 args 数据,来驱动父组件(如 List)的故事渲染。读完你将掌握跨组件复用 story 数据的完整写法,覆盖 React、Vue、Angular、HTML、Solid、Svelte 六种渲染器,并理解 CSF 3 与实验性的 CSF Next(preview.meta / meta.story)两种语法的差异与取舍,为构建 ButtonGroup、List、Page 等天然需要协作渲染的组件提供可直接落地的工程范式。
场景起点:为什么要"一个故事渲染多个组件"
官方文档 docs/writing-stories/index.mdx 在 "Stories for two or more components" 一节中指出:有些组件从设计之初就是要协同工作的,例如 ButtonGroup 需要若干 Button、父级 List 需要若干子项 ListItem、Page 需要组合多个区块。
当遇到这类父子结构组件时,如果只在 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.isSelected、Unselected.args.isSelected 拼装出多个 ListItem:
import { Selected, Unselected } from './ListItem.stories';
关键点有两个:
story.args是普通数据对象,可以像访问组件输入属性一样读取(isSelected只是一个布尔 prop);render是自定义渲染函数,父组件默认渲染逻辑不满足"一个List里放多个ListItem"时,由你完全控制输出结构。
这样写比在父 story 里重新硬编码 props 更易维护,因为 isSelected 的真实取值只存在于 ListItem.stories 一处。
各渲染器完整实现
该片段覆盖六种渲染器,下面按渲染器逐一给出可运行的实现与要点。为节省篇幅,同一渲染器的 CSF 3 与 CSF Next 两种写法在 React / Vue 一节分别完整列出,其余渲染器保留典型 CSF 3 写法并说明 Next 迁移要点。
React(JSX/TSX,CSF 3)
对于 React,ManyItems 把 ListItem 展开渲染到 List 内部,三个子项分别使用 Selected.args、Unselected.args、Unselected.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)。迁移时 args 与 input.args 的命名差异是首个需要适配的点。
Angular(CSF 3)
Angular 版本需要 moduleMetadata 声明 List、ListItem 及 CommonModule,然后通过模板插值渲染:
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,
},
};
需要注意模板中的 Selected、Unselected 实际来自 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-csf 的 defineMeta 与模板语法:
<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 上直接编辑其中某个ListItem的isSelected;- 复用停留在"数据粘贴"层面,数据并没有真正成为父 story 可参数化的部分。
更进一步的三种演进方案
官方在 stories-for-multiple-components.mdx 中针对上述局限给出了三种升级路径,可作为本文模式的自然延伸:
-
subcomponents文档化:在 meta 中声明subcomponents: { ListItem }(示例见 list-story-with-subcomponents.md),可以让 ArgTypes / Controls 面板额外展示子组件的 props 表。其限制是子组件 argTypes 只能自动推断、不能手工覆盖,且 Controls 始终只作用于主组件 args,子组件没有独立 controls。 -
把子组件提升为
childrenarg:React 专属增强(示例见 list-story-with-unchecked-children.md)。将渲染好的子元素放入args.children(如children: <Unchecked {...Unchecked.args} />),使子内容也可作为 arg 被复用。由于 args 需要 JSON 可序列化,官方提醒避免空值、复杂映射值建议配合 Controls 的 mapping 机制,并谨慎对待引入第三方库的组件。 -
创建模板组件(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.mdx 的
Stories 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 级控制来决定是否演进到更复杂的组合模式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00