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 即可验证其行为与边界条件。
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 StartedRust0625
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
