首页
/ Storybook 自定义索引器(experimental_indexers)实战:用 `.custom-stories`、JSON 与任意源码格式扩展 stories 索引

Storybook 自定义索引器(experimental_indexers)实战:用 `.custom-stories`、JSON 与任意源码格式扩展 stories 索引

2026-09-07 09:11:36作者:邬祺芯Juliet

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(以及部分元数据:idtitletags 等)的清单,可通过你 Storybook 实例的 /index.json 路由读取。

索引器(indexer)的职责就是负责把一个个源文件"解析"成上述索引中的 story 条目。它决定了两件事:

  1. 哪些文件需要被索引(test 正则);
  2. 每个文件如何被解析为 story 条目(createIndex 函数)。

索引器 API 是一个面向高级用户的特性,它让你可以自定义 Storybook 如何索引与解析文件,从而突破"story 只能写在 CSF 文件里"的限制,获得更多灵活性——包括用哪种语言定义 story,以及 story 的来源(本地文件、JSON 数据、远程 URL 等)。相关概念定义可参见 main-config-indexers.mdx;索引的最终产物结构(StoryIndex/IndexEntry)可在 indexer.ts 中查看。

⚠️ 实验性特性:该特性处于实验阶段,配置时必须写在 StorybookConfigexperimental_indexers 属性下(见 StorybookConfig 主配置),后续版本 API 可能调整。

.storybook/main.js|ts 中接入自定义索引器

experimental_indexers 的类型签名是:

(existingIndexers: Indexer[]) => Promise<Indexer[]>

它是一个接收当前全部索引器、返回完整索引器列表的函数,返回值必须包含 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];
  },
};

TypeScript(import 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 Components:

import { defineMain } from '@storybook/vue3-vite/node'; // angular 用 @storybook/angular/node,web-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 详解:testcreateIndex

Indexer 的类型定义(见 indexer.ts)为:

{
  test: RegExp;
  createIndex: (fileName: string, options: IndexerOptions) => Promise<IndexInput[]>;
}

test(必填)

  • 类型:RegExp
  • 作用:对 stories 配置中扫描到的文件名运行该正则,命中所有应由本索引器处理的文件。例如上面的 \.custom-stories\.[tj]sx?$stories\.json$/\.url\.js$/

createIndex(必填)

  • 类型:(fileName: string, options: IndexerOptions) => Promise<IndexInput[]>
  • 作用:接收单个被索引的文件,返回要加入索引的条目列表(每条对应一个 story/docs 入口)。

fileName

  • 类型:string
  • 含义:被用于创建索引条目的 CSF/源文件名。

IndexerOptions.makeTitle

IndexerOptions 目前只包含一个字段:

{
  makeTitle: (userTitle?: string) => string;
}

makeTitle 接收一个用户提供的 title,返回经过格式化的索引条目标题,用于侧边栏展示。如果不传用户 title,Storybook 会根据文件名与路径自动生成标题。关于它如何配合 IndexInput.title 使用,见下文"标题"一节。

IndexInput:一个 story 条目的全部字段

createIndex 返回的每个条目即一个 IndexInput,它代表一条"要被加入 story 索引的 story"。类型定义同样位于 indexer.ts。各字段的语义与默认值整理如下:

字段 必填 类型 默认值 说明
exportName string 索引器会从 importPath 指向的文件中导入该具名导出,作为一条索引条目
importPath string 传入 createIndex 的原始 fileName 要导入的文件(通常是 CSF 文件)。若 fileName 并非 CSF,通常需要转译为 CSF后再让浏览器读取
type 'story' 条目的类型。当前仅支持 'story';docs 条目通过其他 API 产生
subtype 'story' | 'test' 'story' ⚠️ 实验性。当 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/metaIdexportName 自动生成 为 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 源文件转译为 CSF

IndexInput.importPath 最终必须解析到一个 CSF 文件。但多数自定义索引器之所以存在,恰恰是因为输入不是 CSF。因此你几乎总要把输入转译为 CSF,Storybook 才能在浏览器中读取并渲染你的 story。

完整转译链路分为两个阶段,整体架构如下:

自定义索引器如何根据源文件建立 story 索引的架构图

  1. 借助 stories 配置,Storybook 找出所有匹配索引器 test 属性的文件;
  2. Storybook 把每个匹配文件交给索引器的 createIndex 函数,该函数基于文件内容生成并返回一组要加入索引的条目(story);
  3. 该索引填充 Storybook UI 中的侧边栏。

构建插件如何把源文件转换为 CSF 的架构图

  1. 在 Storybook UI 中,用户访问与 story id 对应的 URL,浏览器请求索引条目 importPath 指定的 CSF 文件;
  2. 回到服务端,你的构建插件把源文件转译为 CSF 并回传给客户端;
  3. 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));
};

构建插件的处理流程是:

  1. 接收并读取该源文件;
  2. 导入其中的 generateStories 导出;
  3. 运行该函数生成 stories;
  4. 把 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 追加进 stories glob:

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;

第二步,示例输入 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 story:

import * 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, we'll 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.tsaddons.setConfig 自定义侧边栏标签渲染,把 URL 条目渲染为真正的链接型 UI:

import { 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_indexers preset 实现把 csfIndexer 排在最前
export const experimental_indexers: PresetProperty<'experimental_indexers'> = (existingIndexers) =>
  [csfIndexer].concat(existingIndexers || []);

这意味着默认 CSF 索引器始终存在,你在 main.js|ts 中追加的索引器排在它之后。需要提醒:当多个索引器的 test 命中同一文件时,命中顺序会影响最终归属,因此配置时要确保各索引器 test 正则互不重叠,或使用 [customIndexer, ...existingIndexers] 这类前置顺序明确覆盖意图(URL 示例正是前置自己的索引器)。

  • 索引生成主流程:真正的索引构建在 StoryIndexGenerator 中完成(见 StoryIndexGenerator.ts)。它对每个匹配 stories specifier 的文件调用传入的 indexers,把结果聚合成 StoryIndex;doc 注释明确指出"每个文件被当作 stories 或 docs 文件处理,stories 文件由传入的 indexer 解析为 story 列表"。

  • 可验证的单元测试:索引器输出结构在 storyIndexer.test.ts 中通过 loadCsf(code, { makeTitle, fileName }).parse() 等调用被反复验证,这说明 createIndex 返回条目后,运行时仍需按 CSF 语义解析文件才能形成最终条目——这也是为什么 importPath 必须指向(可直接被解析成)CSF 的虚拟/真实文件。

小结与注意事项

本文从注册姿势、API 字段语义、标题规则、CSF 转译架构到四类真实场景,完整覆盖了 experimental_indexers。落地时请重点自查以下三点:

  1. 文件要能被扫到:自定义格式必须加进 stories glob,否则索引器的 test 永远不会被触发;
  2. 返回列表要保留默认索引器experimental_indexers 返回值即最终生效列表,漏掉 existingIndexers 会禁用默认的 .stories.*/.mdx 索引;
  3. 不要跳过 CSF 转译:除"按新命名索引现有 CSF"这类琐碎场景外,自定义格式都需要配合 Vite/Webpack 层的转译插件,否则浏览器无法加载渲染(Vite 支持自定义 importPath,Webpack 场景则需把源文件就地转译为 CSF 并留空 importPath)。

由于该 API 仍处于实验阶段,请在使用时固定并留意所依赖的 Storybook 版本,并在升级时回归验证你的索引器与构建插件。

登录后查看全文
热门项目推荐
相关项目推荐