首页
/ Storybook 侧边栏 Single-Story Hoisting 单 Story 提升机制解析与跨框架写法

Storybook 侧边栏 Single-Story Hoisting 单 Story 提升机制解析与跨框架写法

2026-09-07 13:59:09作者:齐添朝

Storybook 的侧边栏默认按 title/ 分段生成「分组 → 组件 → Story」三层结构。但当某个组件只包含一个与组件同名的 Story 时,Storybook 会把这个 Story 自动"提升"(hoist)到组件所在位置,直接以组件名显示在父分组下,省去一层多余的展开节点。本文以仓库中的官方代码示例 docs/_snippets/button-story-hoisted.md 为核心骨架,讲解单 Story 提升的触发条件、跨框架(Angular / React / Vue / Web Components)跨语法(CSF 3 与 CSF Next)的完整写法,并结合侧边栏渲染源码剖析其底层实现,帮助你掌握一套可复制、可验证的 Storybook 导航组织方案。

一、什么是 Single-Story Hoisting

在阅读代码示例前,先看官方文档 docs/writing-stories/naming-components-and-hierarchy.mdx 中对这一行为的定义:

Single-story components(即没有兄弟 Story 的组件),当其 display name 与组件名称(title 的最后一段)完全一致时,会自动被提升(hoisted),在 UI 中替代其父级组件节点显示。

结合本示例所用的分层标题 Design System/Atoms/Button,普通情况下侧边栏会渲染出:

Design System
└── Atoms
    ├── Button        ← 可展开的组件节点
    │   └── Button    ← 唯一的 Story
    └── Checkbox
        └── Checkbox

开启提升后,唯一的 Story 会向上"顶替"组件节点,界面直接呈现为:

Design System
└── Atoms
    ├── Button        ← 单 Story 直接显示,不可再展开
    └── Checkbox

下面的截图取自仓库文档资源 docs/_assets/writing-stories/naming-hierarchy-single-story-hoisting.png,可以看到 Atoms 下的 ButtonCheckbox 都以叶子节点形式直接展示:

Storybook 侧边栏中单 Story 组件被提升后的层级效果

二、提升的触发条件(精确到源码)

仅凭直觉拼名字可能触发不了提升。仓库中侧边栏树的渲染实现在 code/core/src/manager/components/sidebar/Tree.tsx,其中 singleStoryComponentIds 的计算逻辑给出了两条精确条件:

  1. 该节点必须是 component 类型(而非 root/group/story);
  2. children.length === 1,即组件下只有一个子节点;
  3. 子节点满足二选一
    • 子节点类型为 docs(纯文档组件)——直接折叠;
    • 子节点类型为 storysubtype === 'story'——需进一步通过 isStoryHoistable 名称比对。

名称比对函数定义在 code/core/src/manager/utils/tree.ts

export const removeNoiseFromName = (storyName: string) => storyName.replaceAll(/(\s|-|_)/gi, '');

export const isStoryHoistable = (storyName: string, componentName: string) =>
  removeNoiseFromName(storyName) === removeNoiseFromName(componentName);

也就是说:Story 的展示名与组件名的比较会先剔除空格、连字符、下划线等噪声字符,再要求字符串完全相等。这与 code/core/src/manager/utils/tree.test.js 中的单元测试相互印证:

// 'Very_Long-Button Story Name' 清洗后为 'VeryLongButtonStoryName',与组件名相等 → true
const output = utils.isStoryHoistable('Very_Long-Button Story Name', 'VeryLongButtonStoryName');
expect(output).toEqual(true);

// 'Butto Story' 清洗后为 'ButtoStory',与 'ButtonStory' 不等 → false
const output2 = utils.isStoryHoistable('Butto Story', 'ButtonStory');
expect(output2).toEqual(false);

确认可提升后,Tree.tsx 中的 collapsedData 会执行"树重写":把子 Story 的 name 替换为组件名、parent 指向组件的父级、depth 减一,最终渲染时不再输出该组件节点,Story 就出现在组件原本的位置上。

三、编写满足提升条件的 Story 文件

综上,要写出可被提升的 Story 文件,只需保证三点:

  1. meta 中声明分层 title,例如 'Design System/Atoms/Button'——它是可选项,省略时会依据文件路径生成自动标题(详见 docs/configure/user-interface/sidebar-and-urls.mdx);
  2. component 指向被测组件;
  3. 文件中只导出一个命名 Story,且该 Story 的展示名与 title 最后一段(组件名)一致。

由于 Story 导出名会被自动转成"起始大写"的展示名(如 myStoryMy Story),文件里通常直接使用组件同名导出(Button),或者通过 Story.storyName = '...' 把展示名显式改写成组件名。

仓库以 docs/_snippets/button-story-hoisted.md 一份 snippet 同时维护了多种框架(angular / react / vue / web-components)与两种 CSF 语法的等价写法。下面分节给出完整示例。

CSF 3 语法

Angular(TypeScript)

import type { Meta } from '@storybook/angular';

import { Button as ButtonComponent } from './button.component';

const meta: Meta<ButtonComponent> = {
  // title 可选,缺省时 Storybook 会依据文件位置生成自动标题
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
};

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

// 文件中唯一的命名导出,且与组件同名
export const Button: Story = {};

通用 / Common(JavaScript,适用于 React/Vue3 等渲染器)

import { Button as ButtonComponent } from './Button';

export default {
  // title 可选,缺省时 Storybook 会依据文件位置生成自动标题
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
};

// 文件中唯一的命名导出,且与组件同名
export const Button = {};

通用 / Common(TypeScript)

// 将 your-framework 替换为实际使用的框架,如 react-vite、nextjs、vue3-vite 等
import type { Meta, StoryObj } from '@storybook/your-framework';

import { Button as ButtonComponent } from './Button';

const meta = {
  // title 可选,缺省时 Storybook 会依据文件位置生成自动标题
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
} satisfies Meta<typeof ButtonComponent>;

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

// 文件中唯一的命名导出,且与组件同名
export const Button: Story = {};

Web Components(JavaScript)

export default {
  title: 'Design System/Atoms/Button',
  component: 'demo-button',
};

// 文件中唯一的命名导出,且与组件同名
export const Button = {};

Web Components(TypeScript)

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

const meta: Meta = {
  title: 'Design System/Atoms/Button',
  component: 'demo-component',
};

export default meta;
type Story = StoryObj;

// 文件中唯一的命名导出,且与组件同名
export const Button: Story = {};

CSF Next(实验性语法)

CSF Next 是仓库正在演进的下一代写法,通过从 .storybook/preview 导入的 preview.meta()preview.story() 声明元数据与 Story。以下各框架示例与上面的 CSF 3 写法语义一致。

Angular(TypeScript)

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

import { Button as ButtonComponent } from './button.component';

const meta = preview.meta({
  // title 可选,缺省时 Storybook 会依据文件位置生成自动标题
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

React(TypeScript)

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

import { Button as ButtonComponent } from './Button';

const meta = preview.meta({
  // title 可选,缺省时 Storybook 会依据文件位置生成自动标题
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

React(JavaScript)

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

import { Button as ButtonComponent } from './Button';

const meta = preview.meta({
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

Vue 3(TypeScript)

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

import ButtonComponent from './Button.vue';

const meta = preview.meta({
  // title 可选,缺省时 Storybook 会依据文件位置生成自动标题
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

Vue 3(JavaScript)

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

import ButtonComponent from './Button.vue';

const meta = preview.meta({
  title: 'Design System/Atoms/Button',
  component: ButtonComponent,
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

Web Components(TypeScript)

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

const meta = preview.meta({
  title: 'Design System/Atoms/Button',
  component: 'demo-component',
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

Web Components(JavaScript)

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

const meta = preview.meta({
  title: 'Design System/Atoms/Button',
  component: 'demo-button',
});

// 文件中唯一的命名导出,且与组件同名
export const Button = meta.story();

四、常见"不生效"原因与规避建议

结合文档描述与源码逻辑,以下场景不会发生提升,需要逐一排查:

  • 存在多个命名 Story:只要组件下有多于一个子节点,children.length !== 1,提升即失效。此时应回到分组展开模式,参考 docs/_snippets/button-story-grouped.mddocs/_snippets/checkbox-story-grouped.md 的写法。
  • Story 展示名与组件名不一致:Story 导出会自动"起始大写",例如导出 primary 得到展示名 Primary。若组件名为 Button 而 Story 名为 Primary,比对失败;可通过 Primary.storyName = 'Button' 覆盖展示名来对齐。若组件名自身含空格/连字符/下划线分隔词(如 VeryLongButtonStoryName),由于比较前会剔除噪声字符,Very_Long-Button Story Name 这类名称也能通过比对——这一点在 code/core/src/manager/utils/tree.test.js 中有明确测试覆盖。
  • 组件下只有一个自动文档(docs)节点:这是独立的折叠路径,仅含 docs 子节点的组件会被收起展示,属于另一类 UI 简化行为,通常配合自动文档使用(参见 docs/writing-docs/autodocs.mdx)。

另外需要注意:本示例与文档中"Single-story hoisting"一节均不包含 Svelte 渲染器(该章节对 Svelte 使用条件排除),Svelte CSF 有自己独立的 Story 声明方式,不要把上面的写法直接照搬到 .stories.svelte

五、在更大层级组织中的定位

Single-Story Hoisting 是 Storybook 侧边栏层级组织(隐式 vs 显式命名)的一部分:

  • 隐式组织:依赖 Story 文件物理位置自动生成标题;
  • 显式组织:用 title 显式指定位置,并用 / 做分组,本示例即属此类;
  • Roots:默认最顶层分组以"Root"形式展示(大写、不可展开),可在 UI 配置中关闭该行为。

当 Storybook 组件规模较大时,官方建议按文件层级命名组件,让侧边栏层级与文件系统保持一致;当组件只有一个同名 Story 时,则让提升机制自动替你收起一层,最终在 docs/writing-stories/naming-components-and-hierarchy.mdx 所述的五层结构(Category → Folder → Component → Docs → Story)里,把"Story"精确地放到"Component"的位置上。

六、小结

Single-Story Hoisting 的实现与使用可以浓缩为三句话:

  1. 两个条件缺一不可:组件下只有一个 Story 子节点,且其展示名(清洗空格/连字符/下划线后)与组件名相等;
  2. 代码上只需约定:在 meta 中声明分层 title,文件中仅导出一个与组件同名的 Story,CSF 3 写 export const Button: Story = {},CSF Next 写 export const Button = meta.story()
  3. 行为由渲染层保证Tree.tsx 在渲染前先筛出可折叠组件、再重写节点关系,因此你看到的是一个无需手动配置、自动生效的侧边栏优化。

围绕本文的完整多框架、双语法的代码骨架均可在 docs/_snippets/button-story-hoisted.md 中按需取用,配合 code/core/src/manager/components/sidebar/Tree.tsxcode/core/src/manager/utils/tree.tscode/core/src/manager/utils/tree.test.js 即可验证其行为与边界条件。

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