Storybook 自定义索引器实战:用 experimental_indexers 将 JSON 文件动态索引为 Story

发布时间:2026/9/18 21:55:01
Storybook 自定义索引器实战:用 experimental_indexers 将 JSON 文件动态索引为 Story
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 APIStorybook 在启动时会把配置目录.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 3JavaScript / 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 文件中的具名导出。fileName在createIndex收到的是匹配文件的绝对路径源码见后文第 6 节所以fs.readFileSync(fileName)可以直接工作。示例中使用fs/promises的readFileSync写法与async函数并存仅为演示实践中建议统一使用异步fs.readFile或node:fs的同步读取并保持类型一致。3. CSF NextdefineMain各框架完整变体如果你的项目使用新版 CSF Next 配置风格则通过storybook/framework/node导出defineMain来包裹整个配置并引入真实的框架包React、Vue 3、Angular、Web Components。核心的索引器逻辑完全一致差异只在框架导入路径与framework字段。Reactimport 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 3Vue 3 的 CSF Next 配置从storybook/vue3-vite/node导入defineMainframework填storybook/vue3-viteimport { 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], });AngularAngular 的 CSF Next 配置从storybook/angular/node导入defineMainframework填storybook/angularimport { 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 ComponentsWeb 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 configstorybook/your-framework替换为实际框架ReactCSF Nextstorybook/your-framework/node如 react-vite、nextjs、nextjs-vite同上Vue 3CSF Nextstorybook/vue3-vite/nodestorybook/vue3-viteAngularCSF Nextstorybook/angular/nodestorybook/angularWeb ComponentsCSF Nextstorybook/web-components-vite/nodestorybook/web-components-vite4. 类型契约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) PromiseIndexInput[]; }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以回退到原始fileNametype必填—恒为storysubtype可选实验性story标记条目是普通故事还是测试故事testrawComponentPath可选—提供meta.component的源文件原始路径/包名metaId可选由title自动生成条目的 meta 自定义 id若指定CSF 文件中的export default必须有对应的id属性才能正确匹配name可选由exportName自动生成条目显示名tags可选—用于 Storybook 及其工具过滤条目的标签title可选由importPath的 metadefault export自动生成决定条目在侧边栏中的位置。绝大多数情况应不指定交给默认命名行为确需指定时必须借助makeTitle保持命名一致性参见 docs/_snippets/main-config-indexers-title.md 中追加 Custom 前缀的示例索引器__id可选由title/metaId与exportName自动生成故事条目自定义 id若指定CSF 中的故事必须带匹配的__id实际落在parameters.__id才能正确匹配仅在需要覆盖自动 id 时使用额外说明类型定义中还有可选的__statsIndexInputStats用于向索引报告当前文件对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 foundfileName是绝对路径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等条目componentNamename。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),仅供参考