Storybook 组合组件 Story 中复用子组件 Story 数据:导入并渲染 ListItem 的 Unchecked Story 完整指南(CSF 3 与 CSF Next 多框架示例)
导读
在 Storybook 中,List 与 ListItem 这类父子组件通常需要"组合渲染"才能展示有意义的界面。本篇文章聚焦于一个实用且易于维护的写法:在父组件(List)的 story 中直接导入子组件(ListItem.stories)里已定义好的某个 story(如 Unchecked),把它当作元素渲染出来,并通过展开其 args 复用子组件的输入数据。读完本文,你将掌握 CSF 3 与 CSF Next(实验特性)两套写法在 Angular、React、Solid、Vue 中的完整落地方案,理解 story 复用 story 的数据流原理,并能结合 subcomponents、children-as-arg、Template 组件等相邻模式做出取舍。本文以仓库文档片段 list-story-unchecked.md 为骨架展开。
这个代码片段在整个 Storybook 文档体系中扮演的角色
在官方文档中,"为一个复合组件(一次渲染两个及以上组件的组合)编写 stories"是编写 stories 的进阶课题,对应正文页面:
- docs/writing-stories/index.mdx 的 Stories for two or more components 小节:以
List+ListItem为例,先给出只渲染空列表的 list-story-starter.md,再通过自定义渲染展示多子项的 list-story-expanded.md,随后引出"复用子组件 story 数据"的 list-story-reuse-data.md。 - docs/writing-stories/stories-for-multiple-components.mdx:完整讲解
subcomponents、reusing story definitions、children-as-arg、Template Component 四类进阶手段。
docs/_snippets/ 下的 list-story-*.md 七个文件正是上述正文页面通过 CodeSnippets 标签引用的多框架可运行代码片段:
| 片段文件 | 技术主题 |
|---|---|
| list-story-starter.md | 只渲染父组件本身的空 story |
| list-story-expanded.md | 自定义渲染输出多个子组件 |
| list-story-reuse-data.md | 把子组件的多个 story args 拼入父 story 渲染 |
| list-story-unchecked.md(本文主体) | 把子组件某个具体 story(Unchecked)作为元素渲染进父 story,并复用其 args |
| list-story-with-unchecked-children.md | 把渲染出的子组件放进 children arg |
| list-story-with-subcomponents.md | 用 subcomponents 属性联合文档化父子组件 |
| list-story-template.md | 创建"生成 story 的模板组件" |
可以认为,list-story-unchecked.md 演示的是"单条子 story 直插父组件"的最小复用手法,list-story-reuse-data.md 则是它在"多条数据、多个子 story"场景下的推广。二者在 docs/writing-stories 系列页面中共同构成了 story 之间数据复用的最佳实践基线。
核心模式:story 导入 story
在 List 的 story 里写死 ListItem 的输入数据(例如手动写 item={{ title: '...', checked: false }}),会让同一份数据散落在 ListItem.stories.ts 与 List.stories.ts 两处,任何改动都需要同步维护多处。而 list-story-unchecked.md 给出的模式只有三步:
- 导入子组件的 stories 文件:
import { Unchecked } from './ListItem.stories'; - 在父 story 中把
Unchecked当作组件元素渲染(由各框架的 render 函数完成); - 通过展开
Unchecked.args复用子 story 已经定义好的输入数据。
这样"数据定义只存在于 ListItem.stories 一处",List 的 story 只需要表达"我包含一个处于 uncheck 状态的 ListItem"这一组合意图。
从 Storybook 数据流看,story 的 args 本质就是一段会被传入组件渲染逻辑的输入;当一个 story 对象作为"数据源"被另一个 story 引用时,子 story 的 args 通过对象展开被摊平到父 story 的渲染上下文中。父组件接收到的 props 与渲染出的子组件收到的 props 因此始终一致,从而保证 Controls、Actions 等基于 args 的机制看到的数据同源。
Angular:moduleMetadata + 渲染函数返回模板
Angular 组合渲染的差异点在于:模板中的自定义标签(<app-list>、<app-list-item>)必须经由 moduleMetadata 装饰器完成组件声明,渲染函数则返回一个描述模板的对象,并通过 props: args 把 args 桥接进模板作用域。
CSF 3 写法
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: Meta<List> = {
component: List,
decorators: [
moduleMetadata({
declarations: [List, ListItem],
imports: [CommonModule],
}),
],
};
export default meta;
type Story = StoryObj<List>;
export const OneItem: Story = {
render: (args) => ({
props: args,
template: `
<app-list>
<app-list-item [item]="item"></app-list-item>
</app-list>
`,
}),
args: {
...Unchecked.args,
},
};
要点拆解:
moduleMetadata必须在组件级或 story 级注入,declarations需要同时包含List与其模板中用到的ListItem,否则 Angular 编译器会因为模板中出现未知标签而报错;render函数返回的模板对象中,props: args等价于把 story 的 args 放入组件模板上下文,因此[item]="item"绑定的item正是由...Unchecked.args提供的属性(Uncheckedstory 定义里包含item输入);- 这样设置后,Controls 面板能直接编辑父 story 的
item输入,且修改只发生在这一个数据源上。
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],
}),
],
});
export const OneItem = meta.story({
render: (args) => ({
props: args,
template: `
<app-list>
<app-list-item [item]="item"></app-list-item>
</app-list>
`,
}),
args: {
...Unchecked.input.args,
},
});
CSF Next 不再使用 export default meta + satisfies Meta<typeof List> 的手动类型声明,而是经由 preview.meta({...}) 返回带完整类型推断的 meta,再用 meta.story({...}) 产出 story。组件类型、args 类型全部沿工厂链自动推导。可参考仓库中的 docs/api/csf/csf-next.mdx 说明:definePreview → preview.meta → meta.story 三个工厂逐级产生下一个函数,每一级都保留类型安全。
React:渲染函数直接返回 JSX
React 系(React 与 Solid)是组合渲染最直接的一类:render 函数本身就是组件渲染,把导入的 Unchecked 当作普通 React 元素嵌套进 List 即可。
CSF 3 —— React/JavaScript
import { List } from './List';
// Instead of importing ListItem, we import the stories
import { Unchecked } from './ListItem.stories';
export default {
component: List,
};
export const OneItem = {
render: (args) => (
<List {...args}>
<Unchecked {...Unchecked.args} />
</List>
),
};
CSF 3 —— React/TypeScript
// 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';
// Instead of importing ListItem, we import the stories
import { Unchecked } from './ListItem.stories';
export const meta = {
component: List,
} satisfies Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OneItem: Story = {
render: (args) => (
<List {...args}>
<Unchecked {...Unchecked.args} />
</List>
),
};
CSF 3 —— Solid/JavaScript
import { List } from './List';
// Instead of importing ListItem, we import the stories
import { Unchecked } from './ListItem.stories';
export default {
component: List,
};
export const OneItem = {
render: (args) => (
<List {...args}>
<Unchecked {...Unchecked.args} />
</List>
),
};
CSF 3 —— Solid/TypeScript
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
import { List } from './List';
// Instead of importing ListItem, we import the stories
import { Unchecked } from './ListItem.stories';
export const meta = {
component: List,
} satisfies Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OneItem: Story = {
render: (args) => (
<List {...args}>
<Unchecked {...Unchecked.args} />
</List>
),
};
注意两个容易踩的细节:
<List {...args}>与<Unchecked {...Unchecked.args} />是两层独立展开。args传给父组件List,而Unchecked.args传给被渲染的子 story 元素;之所以在 JSX 里引用 story 而非原始组件,是为了"数据"和"渲染表现"(比如 Unchecked 状态下绑定的 handler)都从子 story 继承;- React 写法中也可以省略显式
render,改为把子元素放进childrenarg——那是 children-as-arg 变体(见后文 list-story-with-unchecked-children.md),当前写法则把组合关系锁定在 render 函数内,父组件收到的仍是普通 props。
CSF Next —— React/TypeScript 与 JavaScript
import preview from '../.storybook/preview';
import { List } from './List';
// Instead of importing ListItem, we import the stories
import { Unchecked } from './ListItem.stories';
const meta = preview.meta({
component: List,
});
export const OneItem = meta.story({
render: (args) => (
<List {...args}>
<Unchecked {...Unchecked.input.args} />
</List>
),
});
import preview from '../.storybook/preview';
import { List } from './List';
// Instead of importing ListItem, we import the stories
import { Unchecked } from './ListItem.stories';
const meta = preview.meta({
component: List,
});
export const OneItem = meta.story({
render: (args) => (
<List {...args}>
<Unchecked {...Unchecked.input.args} />
</List>
),
});
(注:仓库片段中 CSF Next 的 React TS 与 JS 代码体完全一致,差异仅在文件类型;两者都保留的原因见片段中的 JS snippets still needed while providing both CSF 3 & Next 注释。)
Vue 3:render 返回组件与模板描述
Vue 3 的 render 函数需要返回 { components, setup, template } 描述对象:components 注册 List、ListItem,setup() 把 args 暴露给模板,模板中通过 v-bind="args" 把 Unchecked story 的数据注入 ListItem。
CSF 3 —— JavaScript
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,
};
export const OneItem = {
args: {
...Unchecked.args,
},
render: (args) => ({
components: { List, ListItem },
setup() {
// The args will now be passed down to the template
return { args };
},
template: '<List v-bind="args"><ListItem v-bind="args" /></List>',
}),
};
CSF 3 —— TypeScript
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 Meta<typeof List>;
export default meta;
type Story = StoryObj<typeof meta>;
export const OneItem: Story = {
render: (args) => ({
components: { List, ListItem },
setup() {
// The args will now be passed down to the template
return { args };
},
template: '<List v-bind="args"><ListItem v-bind="args" /></List>',
}),
args: {
...Unchecked.args,
},
};
Vue 版本的关键在于 <ListItem v-bind="args" />:这里 v-bind="args" 以展开方式把 story 的 args 全部绑定为子组件 props。由于 args 里已包含 Unchecked.args 展开出的字段,父组件 List 与子组件 ListItem 会接收到同一份数据,Controls 修改其中一个都会即时反映到组合界面上。
CSF Next —— TypeScript、JavaScript
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 OneItem = meta.story({
render: (args) => ({
components: { List, ListItem },
setup() {
// The args will now be passed down to the template
return { args };
},
template: '<List v-bind="args"><ListItem v-bind="args" /></List>',
}),
args: {
...Unchecked.input.args,
},
});
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 OneItem = meta.story({
render: (args) => ({
components: { List, ListItem },
setup() {
// The args will now be passed down to the template
return { args };
},
template: '<List v-bind="args"><ListItem v-bind="args" /></List>',
}),
args: {
...Unchecked.input.args,
},
});
CSF 3 与 CSF Next 在"数据复用"上的关键差异:.args 还是 .input.args
对比两套写法不难发现复用子 story 数据时的取数路径不同:
- CSF 3:直接访问
Unchecked.args; - CSF Next:访问
Unchecked.input.args。
为什么?仓库的 CSF Next 迁移文档 docs/api/csf/csf-next.mdx(3.1 Reusing story properties 小节)解释了这一点:CSF Next 中直接读取 Story.args、Story.parameters 等属性虽然仍被支持,但已属弃用用法。story 的全部属性被收纳进新属性 composed(Story.composed.args / Story.composed.parameters),之所以叫 "composed",是因为其中的值是由 story、其组件 meta 以及 preview 全局配置共同组合(compose)出来的;如果只想要"直接喂给这个 story 的原始输入",则应读取 Story.input。
因此,在父 story 中引用一个尚未经过完整组合的子 story 时,代码片段统一使用 Unchecked.input.args 来取得 Unchecked 的原始 args 输入,再通过展开合成到 OneItem 自己的 args 中——这与 meta.story() 工厂产出的新对象模型保持一致,也意味着一旦子 story 后续经过 .extend() 等 CSF Next 组合能力调整,父 story 读取到的仍是稳定的原始输入层。如果你的项目全部采用 CSF Next,还可以进一步关注 .composed 与 Story.extend(复用 story 属性时官方推荐 .extend)以做更复杂的组合。
与其他"多组件 story"写法的取舍
list-story-unchecked.md 并不是多组件 story 的唯一答案。把它放入 stories-for-multiple-components.mdx 的语境看,可以得出如下选型参考:
- subcomponents(list-story-with-subcomponents.md):当子组件"不单独使用、只作为父组件的一部分"时,在 meta 上声明
subcomponents,可在 ArgTypes/Controls 文档表里额外展示子组件 props。但 subcomponents 仅用于文档化:其 argTypes 只能靠自动推断、不可手动覆盖,且子组件表格不提供 controls(controls 永远作用于主组件 args)。 - 本文件演示的 story 复用 + render(unchecked 版本):通过把子 story 作为数据源渲染进父 story,减少重复、且能通过父组件渲染控制组合结构;但父 story 的数据依然由父组件的 args 主导,子 story 本身不能被单独 controls。仓库 docs/writing-stories/index.mdx 在引出这组写法时也特别提示:以这种方式编写复合组件 story,无法充分发挥 args 机制与后续组合 args 的能力(对应 index.mdx 中 "Stories for two or more components" 的 Callout),更复杂时建议转向 children-as-arg 或 Template 组件方案。
- children as an arg(list-story-with-unchecked-children.md):把渲染出的
<Unchecked {...Unchecked.args} />放进childrenarg,使子元素成为可复用、可跨 story 引用的数据。代价是childrenarg 与所有 args 一样必须 JSON 可序列化:应避免空值、若需用 controls 微调值应使用 mapping、对含第三方库的组件需谨慎。 - Template 组件(list-story-template.md):把组合逻辑封装成"story 生成器"模板组件,更偏"数据驱动",需要更多初始化代码,但每个子 story 都能保留自己的 args 并接受 Controls 面板编辑。
实际工程中可参考这条决策线:仅文档化 → subcomponents;单条简单复用 → 本文的 story-import-story;需要 controls 深入编辑子数据 → children arg / Template 组件。
小结
- 把子组件的 story 当作数据与表现的双重来源:
import { Unchecked } from './ListItem.stories'后在父 story 的 render 中渲染它并...Unchecked.args,数据只需维护一处; - Angular 需
moduleMetadata声明父子组件并借助props: args把 args 暴露给模板;React/Solid 在 render 里直接返回 JSX;Vue 3 在 render 返回对象中通过components+setup+template组织渲染; - 从 CSF 3 迁移到 CSF Next 时,复用子 story 的 args 从
Unchecked.args改为Unchecked.input.args,背后的.composed/.input语义与meta.story()工厂链保持一致(详见 docs/api/csf/csf-next.mdx); - 更多上下文与相邻模式见 docs/writing-stories/index.mdx 与 docs/writing-stories/stories-for-multiple-components.mdx 正文,完整的多框架对照可继续查看
docs/_snippets/list-story-*.md系列片段。
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