Storybook 自定义索引器实战:用 experimental_indexers 将 JSON 文件动态索引为 Story
2026/9/18 21:54:46 网站建设 项目流程

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(索引):即全部故事条目的列表,以及每个条目的一部分元数据(idtitletags等)。这份索引可以在 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 索引器配置。它做的事情很清晰:

  1. test正则锁定所有以stories.json结尾的文件;
  2. createIndex中读取文件内容(JSON);
  3. 通过辅助函数把 JSON 结构展开为一组故事;
  4. 返回一组{ 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会拼接进虚拟模块路径importPathname则对应 CSF 文件中的具名导出。

fileNamecreateIndex收到的是匹配文件的绝对路径(源码见后文第 6 节),所以fs.readFileSync(fileName)可以直接工作。示例中使用fs/promisesreadFileSync写法与async函数并存仅为演示,实践中建议统一使用异步fs.readFilenode: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'导入defineMainframework'@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'导入defineMainframework'@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'导入defineMainframework'@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. 类型契约:IndexerIndexerOptionsIndexInput

要写出健壮的索引器,需要先吃透官方在 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(必填):接收一个文件的路径,返回一组待索引条目。

IndexerOptionscreateIndex的第二个参数

{ 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/metaIdexportName自动生成故事条目自定义 id;若指定,CSF 中的故事必须带匹配的__id(实际落在parameters.__id)才能正确匹配,仅在需要覆盖自动 id 时使用

额外说明:类型定义中还有可选的__statsIndexInputStats),用于向索引报告当前文件对loadersplaytestsrendermoduleMockglobalsfactorytags等语言特性的使用情况,详见 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';

可以看到:

  1. 索引器按文件名挑选indexers.find(...)test正则逐个探测,命中第一个即采用;如果没有任何索引器命中某文件,会直接抛出No matching indexer found
  2. fileName是绝对路径createIndex收到的是标准化后的绝对路径;
  3. 默认值在此补齐nametitleidtagssubtype均在映射阶段按上一节的规则回填,makeTitle由 Storybook 传入;
  4. 虚拟路径被放行toImportPathvirtual:开头的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/PrimaryButton/SecondaryDialog/ClosedDialog/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的具名导出并完成渲染。整条链路的时序为:

  1. 依据stories配置与索引器test正则找到*.stories.json文件;
  2. createIndex把 JSON 解析成一组IndexInput(含虚拟importPathexportName),侧边栏据此填充;
  3. 用户在 UI 中打开某条 story,浏览器请求该importPath
  4. 服务端由构建插件把源 JSON 转译为 CSF 后返回客户端;
  5. 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.tssidebar.renderLabel渲染成链接——该案例同时展示了索引器也能产出 docs 型条目(注意这属于 UI 扩展场景,运行时处理方式与type: 'story'不同)。

8. 小结

experimental_indexers把"Storybook 如何发现故事"从硬编码的 CSF 约定中解放了出来。以*.stories.json为载体的索引器,本质上回答了三个问题:哪些文件归我管test)、如何把文件内容变成故事条目createIndexIndexInput[])、浏览器拿到故事时去哪里读 CSF(虚拟importPath+ 构建插件)。三者配齐后,你就可以把组件故事数据外包给接口、fixture、CMS 或任何 JSON 数据源,实现大规模、可编程的 Story 生成。

进一步阅读本仓库的相关实现与文档:

  • Indexer / IndexInput 类型定义
  • 索引生成器对索引器的调用与归一化
  • 索引构建入口:experimental_indexers 预设收集
  • 官方 API 文档(含多框架示例与转译架构图)
  • 入门版自定义命名约定索引器示例
  • makeTitle 使用示例(自定义标题前缀)

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询