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[]) => 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 详解:test 与 createIndex
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/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 源文件转译为 CSF
IndexInput.importPath 最终必须解析到一个 CSF 文件。但多数自定义索引器之所以存在,恰恰是因为输入不是 CSF。因此你几乎总要把输入转译为 CSF,Storybook 才能在浏览器中读取并渲染你的 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 追加进 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.ts 的 addons.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_indexerspreset 实现把csfIndexer排在最前:
export const experimental_indexers: PresetProperty<'experimental_indexers'> = (existingIndexers) =>
[csfIndexer].concat(existingIndexers || []);
这意味着默认 CSF 索引器始终存在,你在 main.js|ts 中追加的索引器排在它之后。需要提醒:当多个索引器的 test 命中同一文件时,命中顺序会影响最终归属,因此配置时要确保各索引器 test 正则互不重叠,或使用 [customIndexer, ...existingIndexers] 这类前置顺序明确覆盖意图(URL 示例正是前置自己的索引器)。
-
索引生成主流程:真正的索引构建在
StoryIndexGenerator中完成(见 StoryIndexGenerator.ts)。它对每个匹配storiesspecifier 的文件调用传入的indexers,把结果聚合成StoryIndex;doc 注释明确指出"每个文件被当作 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 支持自定义
importPath,Webpack 场景则需把源文件就地转译为 CSF 并留空importPath)。
由于该 API 仍处于实验阶段,请在使用时固定并留意所依赖的 Storybook 版本,并在升级时回归验证你的索引器与构建插件。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00

