Storybook 侧边栏 Single-Story Hoisting 单 Story 提升机制解析与跨框架写法
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下的Button、Checkbox都以叶子节点形式直接展示:
二、提升的触发条件(精确到源码)
仅凭直觉拼名字可能触发不了提升。仓库中侧边栏树的渲染实现在 code/core/src/manager/components/sidebar/Tree.tsx,其中singleStoryComponentIds的计算逻辑给出了两条精确条件:
- 该节点必须是
component类型(而非 root/group/story); children.length === 1,即组件下只有一个子节点;- 子节点满足二选一:
- 子节点类型为
docs(纯文档组件)——直接折叠; - 子节点类型为
story且subtype === '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 文件,只需保证三点:
meta中声明分层title,例如'Design System/Atoms/Button'——它是可选项,省略时会依据文件路径生成自动标题(详见 docs/configure/user-interface/sidebar-and-urls.mdx);component指向被测组件;- 文件中只导出一个命名 Story,且该 Story 的展示名与
title最后一段(组件名)一致。
由于 Story 导出名会被自动转成"起始大写"的展示名(如myStory→My 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.md 与 docs/_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 的实现与使用可以浓缩为三句话:
- 两个条件缺一不可:组件下只有一个 Story 子节点,且其展示名(清洗空格/连字符/下划线后)与组件名相等;
- 代码上只需约定:在
meta中声明分层title,文件中仅导出一个与组件同名的 Story,CSF 3 写export const Button: Story = {},CSF Next 写export const Button = meta.story(); - 行为由渲染层保证:
Tree.tsx在渲染前先筛出可折叠组件、再重写节点关系,因此你看到的是一个无需手动配置、自动生效的侧边栏优化。
围绕本文的完整多框架、双语法的代码骨架均可在 docs/_snippets/button-story-hoisted.md 中按需取用,配合 code/core/src/manager/components/sidebar/Tree.tsx、code/core/src/manager/utils/tree.ts 与 code/core/src/manager/utils/tree.test.js 即可验证其行为与边界条件。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考