Storybook 自定义索引器(experimental_indexers)实战:用 `.custom-stories`、JSON 与任意源码格式扩展 stories 索引
Storybook 自定义索引器experimental_indexers实战用.custom-stories、JSON 与任意源码格式扩展 stories 索引Storybook 内置的 stories 索引器只识别.stories.js|ts|jsx|tsx与.mdx这类 CSF 文件experimental_indexers是 Storybook 提供的高级实验性扩展点用于替换或追加索引器把任意文件不同命名约定、JSON fixture、模板语言乃至 URL 列表索引并渲染成 Story 侧边栏条目。本文以 Storybook 仓库中 main-config-indexers 相关文档 为骨架结合其配套代码片段与 indexer 类型源码完整讲解索引器 API、CSF 转译链路与可复制的实战示例读完即可在自己的.storybook/main.js|ts中落地自定义索引。背景什么是 stories 索引与索引器Storybook 在启动时会扫描项目生成一份story 索引stories index也就是所有 story以及部分元数据id、title、tags等的清单可通过你 Storybook 实例的/index.json路由读取。索引器indexer的职责就是负责把一个个源文件解析成上述索引中的 story 条目。它决定了两件事哪些文件需要被索引test正则每个文件如何被解析为 story 条目createIndex函数。索引器 API 是一个面向高级用户的特性它让你可以自定义 Storybook 如何索引与解析文件从而突破story 只能写在 CSF 文件里的限制获得更多灵活性——包括用哪种语言定义 story以及 story 的来源本地文件、JSON 数据、远程 URL 等。相关概念定义可参见 main-config-indexers.mdx索引的最终产物结构StoryIndex/IndexEntry可在 indexer.ts 中查看。⚠️实验性特性该特性处于实验阶段配置时必须写在StorybookConfig的experimental_indexers属性下见 StorybookConfig 主配置后续版本 API 可能调整。在.storybook/main.js|ts中接入自定义索引器experimental_indexers的类型签名是(existingIndexers: Indexer[]) PromiseIndexer[]它是一个接收当前全部索引器、返回完整索引器列表的函数返回值必须包含existingIndexers中你想保留的项。这样你可以追加一个自定义索引器[...existingIndexers, customIndexer]替换/移除某个默认索引器在返回列表中去掉它即可。下面的配置来自文档配套代码片段 docs/_snippets/main-config-indexers.md演示如何为一种全新的.custom-stories.*文件命名注册索引器。注意一个关键前提被索引的文件必须同时出现在stories配置的 glob 里。export default { // Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. framework: storybook/your-framework, stories: [ ../src/**/*.mdx, ../src/**/*.stories.(js|jsx|mjs|ts|tsx), // Make sure files to index are included in stories ../src/**/*.custom-stories.(js|jsx|ts|tsx), ], experimental_indexers: async (existingIndexers) { const customIndexer { test: /\.custom-stories\.[tj]sx?$/, createIndex: async (fileName) { // See API and examples below... }, }; return [...existingIndexers, customIndexer]; }, };TypeScriptimport type { StorybookConfig } from storybook/your-framework下的等价写法// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import type { StorybookConfig } from storybook/your-framework; 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/**/*.custom-stories.(js|jsx|ts|tsx), ], experimental_indexers: async (existingIndexers) { const customIndexer { test: /\.custom-stories\.[tj]sx?$/, createIndex: async (fileName) { // See API and examples below... }, }; return [...existingIndexers, customIndexer]; }, }; export default config;在文档维护的框架React/Vue3/Angular/Web Components示例中索引器本体完全一致差异仅在CSF Next 风格下使用defineMain配置封装且入口包不同。例如 Vue3 与 Web Componentsimport { defineMain } from storybook/vue3-vite/node; // angular 用 storybook/angular/nodeweb-components 用 storybook/web-components-vite/node export default defineMain({ framework: storybook/vue3-vite, stories: [ ../src/**/*.mdx, ../src/**/*.stories.(js|jsx|mjs|ts|tsx), ../src/**/*.custom-stories.(js|jsx|ts|tsx), ], experimental_indexers: async (existingIndexers) { const customIndexer { test: /\.custom-stories\.[tj]sx?$/, createIndex: async (fileName) { // See API and examples below... }, }; return [...existingIndexers, customIndexer]; }, });如果你的索引器做的只是琐碎的事情例如 按不同命名约定索引 story到这一步就够了否则通常还需要把源文件转译为 CSF见下文Storybook 才能在浏览器中真正读取并渲染它们。索引器 API 详解test与createIndexIndexer的类型定义见 indexer.ts为{ test: RegExp; createIndex: (fileName: string, options: IndexerOptions) PromiseIndexInput[]; }test必填类型RegExp作用对stories配置中扫描到的文件名运行该正则命中所有应由本索引器处理的文件。例如上面的\.custom-stories\.[tj]sx?$、stories\.json$或/\.url\.js$/。createIndex必填类型(fileName: string, options: IndexerOptions) PromiseIndexInput[]作用接收单个被索引的文件返回要加入索引的条目列表每条对应一个 story/docs 入口。fileName类型string含义被用于创建索引条目的 CSF/源文件名。IndexerOptions.makeTitleIndexerOptions目前只包含一个字段{ makeTitle: (userTitle?: string) string; }makeTitle接收一个用户提供的 title返回经过格式化的索引条目标题用于侧边栏展示。如果不传用户 titleStorybook 会根据文件名与路径自动生成标题。关于它如何配合IndexInput.title使用见下文标题一节。IndexInput一个 story 条目的全部字段createIndex返回的每个条目即一个IndexInput它代表一条要被加入 story 索引的 story。类型定义同样位于 indexer.ts。各字段的语义与默认值整理如下字段必填类型默认值说明exportName✅string—索引器会从importPath指向的文件中导入该具名导出作为一条索引条目importPath—string传入createIndex的原始fileName要导入的文件通常是 CSF 文件。若fileName并非 CSF通常需要转译为 CSF后再让浏览器读取type✅story—条目的类型。当前仅支持storydocs 条目通过其他 API 产生subtype—story \| teststory⚠️ 实验性。当type为story时用它把条目标记为test类型rawComponentPath—string—提供meta.component的原始文件路径/包名若存在metaId—string由title自动生成为条目 meta 定义自定义 id。若指定CSF 文件中默认导出的id属性必须与之对应才能正确匹配name—string由exportName自动生成条目的展示名称tags—string[]—用于在 Storybook 及其工具中过滤条目的标签title—string由importPath的 meta默认导出自动生成决定条目在侧边栏中的位置__id—string由title/metaId与exportName自动生成为 story 定义自定义 id。若指定CSF 文件中的 story必须带对应的__id属性parameters.__id才能正确匹配。仅当你需要覆盖自动生成的 id 时才使用关于importPath的 Webpack 限制⚠️自定义importPath只在基于 Vite 的项目中得到支持。在 Webpack 项目中你需要把源文件转译为 CSF并留空importPath让它回落到原始fileName详见 indexer.ts 的注释。关于subtype: test这是对 story 条目的实验性细分当type为story时通过subtype: test可将条目标记为test实验性。未指定时默认为story。标题生成何时手动指定title以及makeTitle的用法绝大多数情况下你不应手动指定title让索引器沿用默认命名行为按文件名/路径生成。如果你确实要指定title则必须通过IndexerOptions中的makeTitle函数来构造这样才仍能套用 Storybook 的默认标题格式化逻辑。下面是文档配套片段 docs/_snippets/main-config-indexers-title.md 中的完整示例一个只把文件名派生的标题追加 Custom 前缀的索引器。import type { StorybookConfig } from storybook/your-framework; import type { Indexer } from storybook/internal/types; const combosIndexer: Indexer { test: /\.stories\.[tj]sx?$/, createIndex: async (fileName, { makeTitle }) { // Grab title from fileName const title fileName.match(/\/(.*)\.stories/)[1]; // Read file and generate entries ... const entries []; return entries.map((entry) ({ type: story, // Use makeTitle to format the title title: ${makeTitle(title)} Custom, importPath: fileName, exportName: entry.name, })); }, }; const config: StorybookConfig { framework: storybook/your-framework, stories: [../src/**/*.mdx, ../src/**/*.stories.(js|jsx|ts|tsx)], experimental_indexers: async (existingIndexers) [...existingIndexers, combosIndexer], }; export default config;要点makeTitle(title)负责对从文件名提取的标题套用 Storybook 的自动标题规则如大小写、路径分段、根目录剥离等随后追加自定义后缀即可得到类似Example Button Custom的侧边栏标题该例子复用importPath: fileName因此只调整了命名未改变文件来源。将非 CSF 源文件转译为 CSFIndexInput.importPath最终必须解析到一个 CSF 文件。但多数自定义索引器之所以存在恰恰是因为输入不是CSF。因此你几乎总要把输入转译为 CSFStorybook 才能在浏览器中读取并渲染你的 story。完整转译链路分为两个阶段整体架构如下借助stories配置Storybook 找出所有匹配索引器test属性的文件Storybook 把每个匹配文件交给索引器的createIndex函数该函数基于文件内容生成并返回一组要加入索引的条目story该索引填充 Storybook UI 中的侧边栏。在 Storybook UI 中用户访问与 story id 对应的 URL浏览器请求索引条目importPath指定的 CSF 文件回到服务端你的构建插件把源文件转译为 CSF 并回传给客户端Storybook UI 读取该 CSF按exportName导入对应 story 并渲染。把自定义源格式转译为 CSF 本身超出了本配置文档的范畴通常应在构建器层完成Vite 和/或 Webpack官方文档推荐用 unplugin 体系为多种构建器同时产出插件。一个最小转译示意先看一份非 CSF 源文件它不导出 story而是导出一个生成器// Button.variants.js|ts import { variantsFromComponent, createStoryFromVariant } from ../utils; import { Button } from ./Button; /** * Returns raw strings representing stories via component props, eg. * export const PrimaryVariant { * args: { * primary: true * }, * }; */ export const generateStories () { const variants variantsFromComponent(Button); return variants.map((variant) createStoryFromVariant(variant)); };构建插件的处理流程是接收并读取该源文件导入其中的generateStories导出运行该函数生成 stories把 stories 写入一个 CSF 文件。最终被 Storybook 索引的虚拟 CSF大致长这样// virtual:Button.variants.js|ts import { Button } from ./Button; export default { component: Button, }; export const Primary { args: { primary: true, }, };实战示例一由 JSON fixture / API 数据动态生成 stories*.stories.json场景是最常被引用的落地范例片段见 docs/_snippets/main-config-indexers-jsonstories.md。索引器负责扫描 JSON 文件并生成条目构建插件负责把 JSON 内容转译为 CSF。第一步在.storybook/main.ts注册索引器同时把*.stories.json追加进storiesglobimport 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;第二步示例输入 JSON以组件为单位描述每个组件的componentPath与其下各 story 的args{ 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 } } } } }第三步构建插件把 JSON 文件转换成标准 CSF。这里给出一个 Vite 插件示例注意CSF 文件中 story 对象的写法与 CSF3 一致即组件参数即 story 参数// 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)} ; }, }; }这个模式的业务价值在于story 定义与组件实现解耦、由数据驱动——当你需要为几十个组件按同一套 fixture 数据批量生成展示用例时只需维护 JSON 数据即可无需手写大量重复的 CSF 文件。实战示例二自定义 story 定义 API概念验证你可以借助自定义索引器 构建插件的组合创造一种你自己的、扩展 CSF 的 story 定义方式。文档中提供了一个完整的概念验证示例含索引器、Vite 插件与 Webpack loader用于动态生成 stories。这类思路适用于团队希望提供领域化 DSL、又不丢失 Storybook 生态能力的场景。实战示例三用非 JavaScript 语言定义 stories自定义索引器还有一个高级用途在任意语言包括模板语言中定义 story再由工具链把文件转译为 CSF。文档中给出的既有实现参考Svelte 模板语法由storybook/addon-svelte-csf项目提供Vue 模板语法由社区storybook-vue-addon项目提供。这也印证了索引器 API 本身不关心源语言只关心createIndex是否能产出合法条目、构建器能否把源文件转成 CSF这一边界划分。实战示例四把一组 URL 变成侧边栏链接索引器 API 足够灵活只要框架工具链能把该内容的导出转成可运行的 story就可以处理任意内容。文档展示了一个进阶示例收集一批 URL从每个页面提取标题与地址渲染成 UI 里的侧边栏链接。示例以 Svelte 实现可迁移到任意框架。第一步创建 URL 集合文件把 URL 作为具名导出索引器会把导出名当作 story 标题导出值当作唯一标识export default {}; export const DesignTokens https://example.com/design-tokens; export const CobaltUI https://example.com/cobalt-ui; export const MiseEnMode https://example.com/mode; export const IndexerAPI https://example.com/indexer-api;第二步在 Vite 配置中加一个配套插件解析.url.js的 AST把每个具名导出改写为一个返回重定向组件的 Svelte storyimport * as acorn from acorn; import * as walk from acorn-walk; import { defineConfig, type Plugin } from vite; import { svelte } from sveltejs/vite-plugin-svelte; function StorybookUrlLinksPlugin(): Plugin { return { name: storybook-url-links, async transform(code: string, id: string) { if (id.endsWith(.url.js)) { const ast acorn.parse(code, { ecmaVersion: 2020, sourceType: module, }); const namedExports: string[] []; let defaultExport export default {};; walk.simple(ast, { // Extracts the named exports, those represent our stories, and for each of them, well return a valid Svelte component. ExportNamedDeclaration(node: acorn.ExportNamedDeclaration) { if (node.declaration node.declaration.type VariableDeclaration) { node.declaration.declarations.forEach((declaration) { if (name in declaration.id) { namedExports.push(declaration.id.name); } }); } }, // Preserve our default export. ExportDefaultDeclaration(node: acorn.ExportDefaultDeclaration) { defaultExport code.slice(node.start, node.end); }, }); return { code: import RedirectBack from ../../.storybook/components/RedirectBack.svelte; ${namedExports .map((name) export const ${name} () new RedirectBack();) .join(\n)} ${defaultExport} , map: null, }; } }, }; } export default defineConfig({ plugins: [StorybookUrlLinksPlugin(), svelte()], });第三步更新.storybook/main.js|ts注册 URL 索引器注意此处把type设为docs、用makeTitle生成可读标题、通过__id覆盖自动 id并用tags: [!autodocs, url]控制其在 UI 中的归类// Replace your-framework with the framework you are using, e.g. sveltekit or svelte-vite import type { StorybookConfig } from storybook/your-framework; import type { Indexer } from storybook/internal/types; const urlIndexer: Indexer { test: /\.url\.js$/, createIndex: async (fileName, { makeTitle }) { const fileData await import(fileName); return Object.entries(fileData) .filter(([key]) key ! default) .map(([name, url]) { return { type: docs, importPath: fileName, exportName: name, title: makeTitle(name) .replace(/([a-z])([A-Z])/g, $1 $2) .trim(), __id: url--${name}--${encodeURIComponent(url as string)}, tags: [!autodocs, url], }; }); }, }; const config: StorybookConfig { stories: [../src/**/*.stories.(js|ts|svelte), ../src/**/*.url.js], framework: { name: storybook/svelte-vite, options: {}, }, experimental_indexers: async (existingIndexers) [urlIndexer, ...existingIndexers], }; export default config;第四步通过.storybook/manager.ts的addons.setConfig自定义侧边栏标签渲染把 URL 条目渲染为真正的链接型 UIimport { addons } from storybook/manager-api; import SidebarLabelWrapper from ./components/SidebarLabelWrapper.tsx; addons.setConfig({ sidebar: { renderLabel: (item) SidebarLabelWrapper({ item }), }, });该示例的核心启发是索引器并不要求文件内容是 story只要构建层能把文件的具名导出翻译成可运行的渲染函数索引器就能把这些导出变成侧边栏中的节点。从源码看索引器的执行位置与顺序理解索引器在 Storybook 运行时中的位置有助于排错内置 CSF 索引器注册默认索引器csfIndexer通过test: STORY_FILE_TEST_REGEXP匹配标准 story 文件并调用loadCsf(...).parse().indexInputs产出索引见 common-preset.ts。它的experimental_indexerspreset 实现把csfIndexer排在最前export const experimental_indexers: PresetPropertyexperimental_indexers (existingIndexers) [csfIndexer].concat(existingIndexers || []);这意味着默认 CSF 索引器始终存在你在main.js|ts中追加的索引器排在它之后。需要提醒当多个索引器的test命中同一文件时命中顺序会影响最终归属因此配置时要确保各索引器test正则互不重叠或使用[customIndexer, ...existingIndexers]这类前置顺序明确覆盖意图URL 示例正是前置自己的索引器。索引生成主流程真正的索引构建在StoryIndexGenerator中完成见 StoryIndexGenerator.ts。它对每个匹配storiesspecifier 的文件调用传入的indexers把结果聚合成StoryIndexdoc 注释明确指出每个文件被当作 stories 或 docs 文件处理stories 文件由传入的 indexer 解析为 story 列表。可验证的单元测试索引器输出结构在 storyIndexer.test.ts 中通过loadCsf(code, { makeTitle, fileName }).parse()等调用被反复验证这说明createIndex返回条目后运行时仍需按 CSF 语义解析文件才能形成最终条目——这也是为什么importPath必须指向可直接被解析成CSF 的虚拟/真实文件。小结与注意事项本文从注册姿势、API 字段语义、标题规则、CSF 转译架构到四类真实场景完整覆盖了experimental_indexers。落地时请重点自查以下三点文件要能被扫到自定义格式必须加进storiesglob否则索引器的test永远不会被触发返回列表要保留默认索引器experimental_indexers返回值即最终生效列表漏掉existingIndexers会禁用默认的.stories.*/.mdx索引不要跳过 CSF 转译除按新命名索引现有 CSF这类琐碎场景外自定义格式都需要配合 Vite/Webpack 层的转译插件否则浏览器无法加载渲染Vite 支持自定义importPathWebpack 场景则需把源文件就地转译为 CSF 并留空importPath。由于该 API 仍处于实验阶段请在使用时固定并留意所依赖的 Storybook 版本并在升级时回归验证你的索引器与构建插件。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考