Storybook 自定义索引器实战:用 experimental_indexers 将 JSON 文件动态索引为 Story
导读
Storybook 默认从 CSF(.stories.*)与 MDX 文件中扫描并建立"故事索引",而experimental_indexers(索引器 API)允许你绕过这一约定,把任何格式的文件(例如包含组件故事元数据的*.stories.json)解析成可被侧边栏展示、可被浏览器加载渲染的 Story。本文以本仓库文档 docs/_snippets/main-config-indexers-jsonstories.md 中的 JSON 索引器为主线,完整讲解experimental_indexers的配置写法、Indexer/IndexInput类型约束、底层索引构建流程,以及如何配合 Vite 插件把 JSON 文件"转译"成浏览器可执行的 CSF,最终实现用 fixture 数据或 API 数据批量驱动 Story。
1. 先理解 Storybook 的故事索引与 Indexers API
Storybook 在启动时,会把配置目录(.storybook/)下通过stories匹配到的所有文件,构建成一份story index(索引):即全部故事条目的列表,以及每个条目的一部分元数据(id、title、tags等)。这份索引可以在 Storybook 运行时的/index.json路由被读取,也是侧边栏导航的数据来源。
Indexers(索引器)正是负责这一环节的可定制组件。官方文档将其定位为一个高级特性(见 docs/api/main-config/main-config-indexers.mdx):通过它你可以改写 Storybook 解析文件为故事条目的方式——包括"故事可以用什么语言/格式书写""故事从哪里来"。
从源码上看,索引器管线贯穿于 core-server 的索引构建过程中:
- 在 code/core/src/core-server/build-index.ts#L15 中,通过
presets.apply('experimental_indexers', [])收集你在main.js|ts里声明的索引器,作为预设(preset)注入索引生成器; - 在 code/core/src/core-server/utils/StoryIndexGenerator.ts 中,对每一个匹配到的文件挑选索引器执行,并把返回的
IndexInput归一化为最终的索引条目。
⚠️实验性 API:由于该特性仍处于实验阶段,必须通过
experimental_indexers属性声明(而不是indexers),类型定义位于StorybookConfig上,参见 docs/api/main-config/main-config-indexers.mdx 的警告说明。
2. 核心骨架:一个为stories.json服务的 JSON 索引器
下面是最小可用的 JSON 索引器配置。它做的事情很清晰:
- 用
test正则锁定所有以stories.json结尾的文件; - 在
createIndex中读取文件内容(JSON); - 通过辅助函数把 JSON 结构展开为一组故事;
- 返回一组
{ type: 'story', importPath, exportName }结构,交给 Storybook 写入索引。
CSF 3(JavaScript / TypeScript 通用写法)
import fs from 'fs/promises'; const jsonStoriesIndexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; const config = { framework: '@storybook/your-framework', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], }; export default config;import type { Indexer } from 'storybook/internal/types'; // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { StorybookConfig } from '@storybook/your-framework'; import fs from 'fs/promises'; const jsonStoriesIndexer: Indexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; const config: StorybookConfig = { framework: '@storybook/your-framework', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], }; export default config;四个关键点,缺一不可:
| 要素 | 说明 |
|---|---|
test: /stories\.json$/ | 正则基于文件名匹配,决定了哪些文件会交给本索引器处理。它的匹配对象是已被stories配置收纳进来的文件(见下文第 3 步) |
storiesglob 中追加'../src/**/*.stories.json' | 被索引的文件必须先被stories通配符收录,索引器才有机会看到它们。注释里也明确提示了这一点 |
createIndex | 接收一个文件绝对路径,读取并解析内容,返回IndexInput[](每一个元素代表一条故事) |
experimental_indexers | 接收当前全部索引器existingIndexers,必须返回完整的新列表——这里用展开运算符把自定义索引器追加到末尾 |
注意:generateStoryIndexesFromJson是示例代码中引用的辅助函数(用于把 JSON 解析为故事集合),它不是 Storybook 内置的 API,需要你在实际项目中自行实现,通常返回形如[{ componentName: 'Button', name: 'Primary' }, ...]的数组。返回元素中的componentName会拼接进虚拟模块路径importPath,name则对应 CSF 文件中的具名导出。
fileName在createIndex收到的是匹配文件的绝对路径(源码见后文第 6 节),所以fs.readFileSync(fileName)可以直接工作。示例中使用fs/promises的readFileSync写法与async函数并存仅为演示,实践中建议统一使用异步fs.readFile或node:fs的同步读取并保持类型一致。
3. CSF Next(defineMain)各框架完整变体
如果你的项目使用新版 CSF Next 配置风格,则通过@storybook/<framework>/node导出defineMain来包裹整个配置,并引入真实的框架包(React、Vue 3、Angular、Web Components)。核心的索引器逻辑完全一致,差异只在框架导入路径与framework字段。
React
import type { Indexer } from 'storybook/internal/types'; // Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) import { defineMain } from '@storybook/your-framework/node'; import fs from 'fs/promises'; const jsonStoriesIndexer: Indexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; export default defineMain({ framework: '@storybook/your-framework', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], });React 的 JavaScript 版本同理,仅将 TS 类型标注与import换为 JS 语法,defineMain从'@storybook/your-framework/node'导入:
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) import { defineMain } from '@storybook/your-framework/node'; import fs from 'fs/promises'; const jsonStoriesIndexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; export default defineMain({ framework: '@storybook/your-framework', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], });Vue 3
Vue 3 的 CSF Next 配置从'@storybook/vue3-vite/node'导入defineMain,framework填'@storybook/vue3-vite':
import { defineMain } from '@storybook/vue3-vite/node'; import fs from 'fs/promises'; const jsonStoriesIndexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; export default defineMain({ framework: '@storybook/vue3-vite', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], });Angular
Angular 的 CSF Next 配置从'@storybook/angular/node'导入defineMain,framework填'@storybook/angular':
import { defineMain } from '@storybook/angular/node'; import fs from 'fs/promises'; const jsonStoriesIndexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; export default defineMain({ framework: '@storybook/angular', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], });Web Components
Web Components 的 CSF Next 配置从'@storybook/web-components-vite/node'导入defineMain,framework填'@storybook/web-components-vite'(另有同名 JavaScript 写法):
import { defineMain } from '@storybook/web-components-vite/node'; import fs from 'fs/promises'; const jsonStoriesIndexer = { test: /stories\.json$/, createIndex: async (fileName) => { const content = JSON.parse(fs.readFileSync(fileName)); const stories = generateStoryIndexesFromJson(content); return stories.map((story) => ({ type: 'story', importPath: `virtual:jsonstories--${fileName}--${story.componentName}`, exportName: story.name, })); }, }; export default defineMain({ framework: '@storybook/web-components-vite', stories: [ '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)', // 👇 Make sure files to index are included in `stories` '../src/**/*.stories.json', ], experimental_indexers: async (existingIndexers) => [...existingIndexers, jsonStoriesIndexer], });各框架的导入差异可归纳为一张速查表:
| 框架风格 | defineMain导入路径 | framework字段 |
|---|---|---|
| 通用 CSF 3 | 不使用(直接export default config) | @storybook/your-framework(替换为实际框架) |
| React(CSF Next) | @storybook/<your-framework>/node(如 react-vite、nextjs、nextjs-vite) | 同上 |
| Vue 3(CSF Next) | @storybook/vue3-vite/node | @storybook/vue3-vite |
| Angular(CSF Next) | @storybook/angular/node | @storybook/angular |
| Web Components(CSF Next) | @storybook/web-components-vite/node | @storybook/web-components-vite |
4. 类型契约:Indexer、IndexerOptions与IndexInput
要写出健壮的索引器,需要先吃透官方在 docs/api/main-config/main-config-indexers.mdx 中给出的类型定义(仓库中的实际 TS 类型定义见 code/core/src/types/modules/indexer.ts)。
Indexer:索引器本体
{ test: RegExp; createIndex: (fileName: string, options: IndexerOptions) => Promise<IndexInput[]>; }test(必填):作用于stories配置收录文件名的正则表达式,凡匹配的文件都会被本索引器接管;createIndex(必填):接收一个文件的路径,返回一组待索引条目。
IndexerOptions:createIndex的第二个参数
{ makeTitle: (userTitle?: string) => string; }makeTitle是 Storybook 注入给你的标题构造函数:传入用户自定义标题会得到格式化结果,不传则由文件名与路径自动推导标题。在 StoryIndexGenerator 中,这个默认实现来自userOrAutoTitleFromSpecifier(见 code/core/src/core-server/utils/StoryIndexGenerator.ts#L406-L413)。
IndexInput:一条故事条目的"输入形态"
{ exportName: string; importPath: string; type: 'story'; subtype?: 'story' | 'test'; rawComponentPath?: string; metaId?: string; name?: string; tags?: string[]; title?: string; __id?: string; }各字段含义与约束:
| 字段 | 是否必填 | 默认值 | 说明 |
|---|---|---|---|
exportName | 必填 | — | 每个IndexInput都对应importPath所指文件中的一个具名导出,Storybook 会以该导出为一条故事入口 |
importPath | 可选 | createIndex收到的fileName | 要从哪个文件导入故事。自定义importPath(如virtual:前缀)仅在 Vite 系项目受支持;Webpack 项目需把源文件转译为 CSF 并留空importPath以回退到原始fileName |
type | 必填 | — | 恒为'story' |
subtype | 可选(实验性) | 'story' | 标记条目是普通故事还是测试故事('test') |
rawComponentPath | 可选 | — | 提供meta.component的源文件原始路径/包名 |
metaId | 可选 | 由title自动生成 | 条目的 meta 自定义 id;若指定,CSF 文件中的export default必须有对应的id属性才能正确匹配 |
name | 可选 | 由exportName自动生成 | 条目显示名 |
tags | 可选 | — | 用于 Storybook 及其工具过滤条目的标签 |
title | 可选 | 由importPath的 meta(default export)自动生成 | 决定条目在侧边栏中的位置。绝大多数情况应不指定,交给默认命名行为;确需指定时必须借助makeTitle保持命名一致性(参见 docs/_snippets/main-config-indexers-title.md 中"追加 Custom 前缀"的示例索引器) |
__id | 可选 | 由title/metaId与exportName自动生成 | 故事条目自定义 id;若指定,CSF 中的故事必须带匹配的__id(实际落在parameters.__id)才能正确匹配,仅在需要覆盖自动 id 时使用 |
额外说明:类型定义中还有可选的
__stats(IndexInputStats),用于向索引报告当前文件对loaders、play、tests、render、moduleMock、globals、factory、tags等语言特性的使用情况,详见 code/core/src/types/modules/indexer.ts#L100-L144。
5.IndexInput是如何变成真实索引条目的
理解了输入形态,再看它如何在底层被消费,就明白为什么要返回上述结构。在 code/core/src/core-server/utils/StoryIndexGenerator.ts#L415-L459 中,索引流程如下:
const indexer = this.options.indexers.find((ind) => ind.test.exec(absolutePath)); invariant(indexer, `No matching indexer found for ${absolutePath}`); const indexInputs = (await indexer.createIndex(absolutePath, { makeTitle: defaultMakeTitle, })) as StoryIndexInput[]; // ... 对每个 indexInput: const name = input.name ?? storyNameFromExport(input.exportName); const title = input.title ?? defaultMakeTitle(); const id = input.__id ?? toId(input.metaId ?? title, storyNameFromExport(input.exportName)); const tags = combineTags(...projectTags, ...(input.tags ?? [])); const subtype = input.subtype ?? 'story';可以看到:
- 索引器按文件名挑选:
indexers.find(...)用test正则逐个探测,命中第一个即采用;如果没有任何索引器命中某文件,会直接抛出No matching indexer found; fileName是绝对路径:createIndex收到的是标准化后的绝对路径;- 默认值在此补齐:
name、title、id、tags、subtype均在映射阶段按上一节的规则回填,makeTitle由 Storybook 传入; - 虚拟路径被放行:
toImportPath对virtual:开头的importPath原样返回(见同文件 StoryIndexGenerator.ts#L436-L444),这正是 JSON 索引器使用virtual:jsonstories--...这类 id 的原因——它不指向真实磁盘文件,而是等待构建插件在浏览器侧动态提供内容。
整理后的条目最终写入 Storybook 索引,并可在/index.json路由读取。
6. 端到端闭环:输入 JSON 与转译到 CSF 的 Vite 插件
自定义importPath指向的virtual:jsonstories--*模块并不是 CSF 文件,而索引条目的importPath必须能解析为浏览器可读取的 CSF。因此,"用 JSON 生成故事"通常还需要两样东西:一份符合约定的 JSON 数据文件,以及一个把 JSON 内容现场翻译成 CSF 的构建插件。官方文档(docs/api/main-config/main-config-indexers.mdx)把这个例子完整展开如下。
6.1 一份可作为输入的 JSON 故事文件
以*.stories.json为例,其顶层按组件名组织,每个组件下有组件源码路径componentPath与一组stories(每个 story 的键名即故事名,值为故事配置):
{ "Button": { "componentPath": "./button/Button.jsx", "stories": { "Primary": { "args": { "primary": true } }, "Secondary": { "args": { "primary": false } } } }, "Dialog": { "componentPath": "./dialog/Dialog.jsx", "stories": { "Closed": {}, "Open": { "args": { "isOpen": true } } } } }对应地,前面示例中generateStoryIndexesFromJson的职责就是从该结构提取Button/Primary、Button/Secondary、Dialog/Closed、Dialog/Open等条目(componentName+name)。
6.2 把virtual:jsonstories模块转译成 CSF 的 Vite 插件
Vite 插件在load钩子中识别以virtual:jsonstories开头的模块 id,按--分隔解析出原始文件名与组件名,读取 JSON 后拼接出一段合法的 CSF 源码返回:
// vite-plugin-storybook-json-stories.ts import type { PluginOption } from 'vite'; import fs from 'fs/promises'; function JsonStoriesPlugin(): PluginOption { return { name: 'vite-plugin-storybook-json-stories', load(id) { if (!id.startsWith('virtual:jsonstories')) { return; } const [, fileName, componentName] = id.split('--'); const content = JSON.parse(fs.readFileSync(fileName)); const { componentPath, stories } = getComponentStoriesFromJson(content, componentName); return ` import ${componentName} from '${componentPath}'; export default { component: ${componentName} }; ${stories.map((story) => `export const ${story.name} = ${story.config};\n`)} `; }, }; }这样,索引条目中的exportName: 'Primary'才能在浏览器请求virtual:jsonstories--...--Button时,从插件生成的 CSF 里取到名为Primary的具名导出并完成渲染。整条链路的时序为:
- 依据
stories配置与索引器test正则找到*.stories.json文件; createIndex把 JSON 解析成一组IndexInput(含虚拟importPath与exportName),侧边栏据此填充;- 用户在 UI 中打开某条 story,浏览器请求该
importPath; - 服务端由构建插件把源 JSON 转译为 CSF 后返回客户端;
- UI 读取 CSF,按
exportName导入对应故事并渲染。
Webpack 注意点:由于自定义importPath(包括virtual:)仅在 Vite 系项目受支持,Webpack 项目必须走"构建期 loader 转译"路线,让转译产物仍挂在原始fileName下,并把IndexInput.importPath留空(自动回退为fileName)。
7. 实战要点与常见误区
- 被索引文件必须先进入
stories通配:索引器只能处理已被stories收录的文件,漏掉'../src/**/*.stories.json'这一行会导致索引器形同虚设; experimental_indexers返回的是完整列表:回调收到的existingIndexers是 Storybook 内置的默认索引器(负责 CSF/MDX)。[...existingIndexers, jsonStoriesIndexer]表示追加;若把自定义索引器放在数组前部,则可以覆盖/替换同正则命中的默认行为;- 记住返回的每一个对象都要带
type: 'story':类型上还允许docs类条目与实验性的subtype: 'test',但 Storybook 在运行时实际只会消费 story 型输入(见 code/core/src/core-server/utils/StoryIndexGenerator.ts#L419-L421 的类型注释); - 不要随意指定
title/__id:除非必须覆盖自动生成的 id/标题,否则应让 Storybook 沿用默认命名,确需自定义标题时借助makeTitle(参考 docs/_snippets/main-config-indexers-title.md); - 虚拟模块只是索引入口,CSF 化是渲染前提:
IndexInput只是把故事"登记"进索引;要让故事真的能被浏览器渲染,importPath解析到的必须是合法 CSF(通过 builder 插件/loader 转译实现); - "简单命名约定"场景可以不必转译:如果只是让 Storybook 识别另一种
*.custom-stories.*文件命名、内部仍是标准 CSF 语法,那么索引器配合stories通配即可,无需 builder 插件。入门写法的完整示例见 docs/_snippets/main-config-indexers.md。
更进一步的官方案例
JSON 驱动只是索引器 API 的一个代表性场景。官方文档 docs/api/main-config/main-config-indexers.mdx 的 Examples 一节还给出了以下同类用法,可作为扩展阅读:
- 用替代 API 定义故事:通过自定义索引器 + builder 插件扩展现有 CSF 格式,创建属于你自己的故事定义 DSL;
- 用非 JavaScript 语言定义故事:例如 Svelte 模板语法(
@storybook/addon-svelte-csf)与 Vue 模板语法场景,把模板文件转译为 CSF; - 从 URL 集合生成侧边栏链接:自定义索引器解析
.url.js文件中的具名导出(导出名为故事标题、值为唯一标识),返回type: 'docs'条目并配合manager.ts的sidebar.renderLabel渲染成链接——该案例同时展示了索引器也能产出 docs 型条目(注意这属于 UI 扩展场景,运行时处理方式与type: 'story'不同)。
8. 小结
experimental_indexers把"Storybook 如何发现故事"从硬编码的 CSF 约定中解放了出来。以*.stories.json为载体的索引器,本质上回答了三个问题:哪些文件归我管(test)、如何把文件内容变成故事条目(createIndex→IndexInput[])、浏览器拿到故事时去哪里读 CSF(虚拟importPath+ 构建插件)。三者配齐后,你就可以把组件故事数据外包给接口、fixture、CMS 或任何 JSON 数据源,实现大规模、可编程的 Story 生成。
进一步阅读本仓库的相关实现与文档:
- Indexer / IndexInput 类型定义
- 索引生成器对索引器的调用与归一化
- 索引构建入口:experimental_indexers 预设收集
- 官方 API 文档(含多框架示例与转译架构图)
- 入门版自定义命名约定索引器示例
- makeTitle 使用示例(自定义标题前缀)
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考