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

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

2026-09-07 11:52:55作者:凤尚柏Louis

导读

当一个组件天生需要与其他组件配合使用(例如父组件 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.mdlist-story-reuse-data.mdlist-story-with-subcomponents.md 共同组成一套递进式教学序列,对应正文位于 docs/writing-stories/index.mdx

该序列的演进逻辑非常清晰:

  1. starter 阶段List.stories.js 中仅定义一个空的 List,不含任何 ListItem 子节点——正如代码注释所写 "Always an empty list, not super interesting",它只是一个可运行但单调的基线。
  2. expanded 阶段(本文主体):在同一文件中导出 EmptyOneItemManyItems 三个 Story,通过自定义渲染函数把不同数量的 ListItem 作为子内容嵌进 List,完整覆盖 0、1、N 三种形态,同时仍将 args 透传给组件以便使用 Controls。
  3. reuse-data 阶段:进一步从 ListItem.stories 中导入子组件的 Story(如 SelectedUnselected)并复用其 args,避免在多处重复维护数据。

理解 list-story-expanded 的关键在于:它示范的不是如何写「单个组件的一个故事」,而是如何为复合组件准备一系列可对照的状态快照——这正对应正文中 "it makes sense to customize the rendering to output the List component with different numbers of ListItem children" 的表述。

二、三种形态背后的核心机制:自定义 render 函数

在展开该片段前,需要先理解它依赖的底层概念。默认情况下,Storybook 渲染 meta(default export)中声明的组件,并把 args 作为其属性传入(见 docs/writing-stories/index.mdx 中 "Custom rendering" 一节)。但当你要渲染的是 父组件 + 若干子组件 的复合结构时,默认渲染就不够用了,此时需要为 Story 提供 render 函数:

export const OneItem = {
  render: (args) => <List {...args}><ListItem /></List>,
};

render 是框架相关的特性,用于完全掌控组件如何被渲染。从源码结构看,CSF(Component Story Format)在元数据层面把 render 视为 Story 对象的一个普通属性,Storybook 在收集与执行 Story 时会优先生效自定义 render 而非默认渲染路径。

文档明确提示了两个必须掌握的细节:

  • 透传 args:自定义 render 中应当把接收到的 args 展开({...args})到目标组件上。只有这样做,依赖 args 的特性(如 Controls 面板)才能继续工作,让用户在 Storybook UI 里动态修改 List 的属性、在线压测边界状态。
  • 第二参数 contextrender 函数实际上接收两个参数,第二个参数 context 携带该 Story 的全部上下文信息,包括 parametersglobals 等,可按需读取。

Svelte 渲染器则使用 template 片段(snippet)扮演同样的角色:在 <Story> 内用 {#snippet template(args)} 定义渲染结构;也可以把复用性高的片段提取到 defineMetarender 属性上实现「meta 级复用、story 级覆盖」。

三、React(JSX)实现:最直观的复合渲染写法

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 Meta<typeof List>;

export default meta;
type Story = StoryObj<typeof 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>
  ),
};

注意两个有意思的对照:

  1. Empty 不需要任何 render——它表示 List 在无子节点时的默认渲染,因此保持空对象即可,恰好还原了 starter 阶段的状态。
  2. OneItemManyItems 才引入 render,二者只是子节点数量不同。这意味着当 ListListItem 的接口发生变化时,只需同步改动这一处 JSX 结构即可。

Solid 渲染器(storybook-solidjs-vite)的写法与 React 几乎逐字相同,仅 satisfies Meta<typeof List> 与导出类型保持一致即可,这里不再重复贴码,完整可参考 list-story-expanded.mdrenderer="solid" 的两个代码块。

四、Vue 与 Web Components:render 返回组件选项 / lit 模板

Vue 3(@storybook/vue3-vite

Vue 的 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 Meta<typeof List>;

export default meta;
type Story = StoryObj<typeof 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 中以 PascalCase(ListItem)注册,在 template 中通常使用 kebab-case(<list-item/>),这正是 Vue 模板编译对已注册组件的解析规则,初学者最容易在此处踩坑。

Web Components(@storybook/web-components-vite

Web Components 场景中 meta.component 不再指向构造函数,而是自定义元素标签名字符串(如 'demo-list'),渲染使用 lithtml 模板标签:

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 工厂 + appendChild

@storybook/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: Meta<ListArgs> = {
  title: 'List',
};

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

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({}) 或直接依赖默认渲染;阅读代码块时需留意这一细节。

六、Angular:moduleMetadata 声明 + 模板片段

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: 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></app-list-item>
      </app-list>`,
  }),
};

两个值得深挖的工程点:

  • decorators 的声明时机declarations 中把父组件 List 与子组件 ListItem 一并声明,是从 starter 到 expanded 的关键差异——一旦模板里同时出现 <app-list><app-list-item>,遗漏任何一个都会导致运行时编译失败。imports: [CommonModule] 则为模板提供 *ngIf*ngFor 等公共指令支持。
  • propsargs:render 返回对象中 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 name="Empty" />

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

Svelte 版有两个语言特性值得说明:name="Many Items" 允许 Story 名称含空格而不必受导出标识符限制;Empty 直接使用自闭合 <Story name="Empty" />,意味着它完全交给默认渲染(仅渲染 List 本身),与 React 版本中 Empty: Story = {} 的语义一致。仓库中还以 TS 语言标注提供了一份 renderer="svelte" language="ts" 的等价版本,差异仅在 Svelte 版本对 script 块的类型标注,结构完全相同。

八、CSF 3 与 CSF Next:同一场景的两种组织范式

在 Angular、Vue、React、Web Components 的代码块中,snippet 同时提供了标注为 CSF 3CSF Next 🧪 的两套 tab。后者即官方文档中所称的 CSF 工厂式 API,详细介绍见 docs/api/csf/csf-next.mdx

CSF Next 的工厂链

CSF Next 采用链式工厂设计,每一步都推进类型推断:definePreviewpreview.metameta.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 meta,meta 经由 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 3,Svelte 则一直使用 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 子节点拆分为 EmptyOneItemManyItems,命名即状态
默认形态 Empty 尽量留空或仅依赖默认渲染,与 starter 基线保持一致
自定义渲染 非空形态提供 render/template snippet,完整书写嵌套结构
args 透传 JSX 用 {...args};Vue 用 components + template;Angular 用 props: args;Svelte 用 {#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 原文对照使用。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
916
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388