加入直播:美国东部时间周四上午 11 点,Storybook 9 发布及 AMA
文档
Storybook Docs

索引器

(⚠️ 实验性功能)

虽然此功能为实验性功能,但必须通过 StorybookConfigexperimental_indexers 属性来指定。

父级: main.js|ts 配置

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

索引器负责构建 Storybook 的 Story 索引,即所有 Story 的列表及其元数据的子集,如 idtitletags 等。可以在 Storybook 的 /index.json 路由处读取索引。

索引器 API 是一个高级功能,允许您自定义 Storybook 的索引器,这些索引器决定了 Storybook 如何将文件索引并解析为 Story 条目。这为编写 Story 增加了更多灵活性,包括定义 Story 的语言或从何处获取 Story。

它们被定义为一个函数,该函数返回完整的索引器列表,包括现有的索引器。这允许您向列表中添加自己的索引器,或替换现有的索引器

.storybook/main.ts
// 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;

除非您的索引器做的事情相对简单(例如 使用不同的命名约定索引 Story),否则除了索引文件之外,您可能还需要 将其转换为 CSF,以便 Storybook 可以在浏览器中读取它们。

Indexer

类型

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

指定要索引哪些文件以及如何将它们索引为 Story。

test

(必需)

类型: RegExp

一个正则表达式,用于匹配 stories 配置中包含的文件名,应匹配此索引器需要处理的所有文件。

createIndex

(必需)

类型: (fileName: string, options: IndexerOptions) => Promise<IndexInput[]>

一个函数,接受单个 CSF 文件并返回要索引的条目列表。

fileName

类型: string

用于创建索引条目的 CSF 文件名。

IndexerOptions

类型

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

索引文件的选项。

makeTitle

类型: (userTitle?: string) => string

一个函数,接受用户提供的标题,并返回索引条目的格式化标题,该标题用于侧边栏。如果未提供用户标题,则会根据文件名和路径自动生成一个。

参见 IndexInput.title 获取使用示例。

IndexInput

类型

{
  exportName: string;
  importPath: string;
  type: 'story';
  rawComponentPath?: string;
  metaId?: string;
  name?: string;
  tags?: string[];
  title?: string;
  __id?: string;
}

代表要添加到 Story 索引中的 Story 的对象。

exportName

(必需)

类型: string

对于每个 IndexInput,索引器将把此导出(来自 importPath 文件)添加为索引中的一个条目。

importPath

(必需)

类型: string

要导入的文件,例如 CSF 文件。

被索引的 fileName 很可能不是 CSF,在这种情况下,您需要 将其转换为 CSF,以便 Storybook 可以在浏览器中读取它。

type

(必需)

类型: 'story'

条目的类型。

rawComponentPath

类型: string

提供 meta.component 的原始路径/包,如果存在。

metaId

类型: string

默认值: 从 title 自动生成

为条目的 meta 定义自定义 ID。

如果指定,CSF 文件中的默认导出 (meta) 必须有一个相应的 id 属性才能正确匹配。

name

类型: string

默认值: 从 exportName 自动生成

条目的名称。

tags

类型: string[]

用于在 Storybook 及其工具中过滤条目的标签。

title

类型: string

默认值: 从 importPath 的默认导出自动生成

确定条目在侧边栏中的位置。

大多数情况下,您不应指定标题,以便您的索引器将使用默认命名行为。当指定标题时,您必须使用 IndexerOptions 中提供的 makeTitle 函数来使用此行为。例如,这是一个索引器,它只是在从文件名派生的标题前加上“自定义”前缀

.storybook/main.ts
// 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 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;
__id

类型: string

默认值: 从 title/metaIdexportName 自动生成

为条目的 Story 定义自定义 ID。

如果指定,CSF 文件中的 Story 必须有一个相应的 __id 属性才能正确匹配。

仅在需要覆盖自动生成的 ID 时使用。

转换为 CSF

IndexInput 中的 importPath 值必须解析为 CSF 文件。然而,大多数自定义索引器之所以必要,是因为输入不是 CSF。因此,您可能需要将输入转换为 CSF,以便 Storybook 可以在浏览器中读取它并渲染您的 Story。

将自定义源格式转换为 CSF 超出本文档范围。这种转换通常在构建器级别完成(Vite 和/或 Webpack),我们建议使用 unplugin 为多个构建器创建插件。

一般的架构如下

Architecture diagram showing how a custom indexer indexes stories from a source file

  1. 使用 stories 配置,Storybook 查找所有匹配您的索引器 test 属性的文件
  2. Storybook 将每个匹配的文件传递给您索引器的 createIndex 函数,该函数使用文件内容生成并返回要添加到索引的索引条目(Story)列表
  3. 索引填充 Storybook UI 中的侧边栏

Architecture diagram showing how a build plugin transforms a source file into CSF

  1. 在 Storybook UI 中,用户导航到与 Story ID 匹配的 URL,并且浏览器请求由索引条目 importPath 属性指定 CSF 文件
  2. 回到服务器端,您的构建器插件将源文件转换为 CSF,并将其提供给客户端
  3. Storybook UI 读取 CSF 文件,导入由 exportName 指定的 Story,并将其渲染

让我们看一个这种方式如何工作的例子。

首先,这是一个非 CSF 源文件的例子

// 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. 运行该函数生成 Story
  4. 将 Story 写入 CSF 文件

然后 Storybook 将索引生成 CSF 文件。它看起来像这样

// virtual:Button.variants.js|ts
 
import { Button } from './Button';
 
export default {
  component: Button,
};
 
export const Primary = {
  args: {
    primary: true,
  },
};

示例

一些自定义索引器的示例用法包括

从模拟数据或 API 端点动态生成 Story

此索引器根据 JSON 模拟数据为组件生成 Story。它在项目中查找 *.stories.json 文件,将它们添加到索引中,并单独将其内容转换为 CSF。

.storybook/main.ts
// 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 type { Indexer } from 'storybook/internal/types';
 
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 文件可能如下所示

{
  "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 插件完成

// 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`)}
      `;
    },
  };
}
使用另一种 API 生成 Story

您可以使用自定义索引器和构建器插件来创建自己的 API,以扩展 CSF 格式来定义 Story。要了解更多信息,请参阅以下 概念验证,了解如何设置自定义索引器来动态生成 Story。它包含支持此功能所需的一切,包括索引器、Vite 插件和 Webpack loader。

使用非 JavaScript 语言定义 Story

自定义索引器可用于一个高级用途:使用任何语言(包括模板语言)定义 Story,并将文件转换为 CSF。要查看实际示例,您可以参考 Svelte 模板语法 @storybook/addon-svelte-csf 和 Vue 模板语法 storybook-vue-addon

从 URL 集合添加侧边栏链接

索引器 API 非常灵活,允许您处理任意内容,只要您的框架工具可以将该内容中的导出转换为实际可以运行的 Story。这个高级示例演示了如何创建一个自定义索引器来处理 URL 集合,从每个页面中提取标题和 URL,并将它们在 UI 中渲染为侧边栏链接。使用 Svelte 实现,它可以适用于任何框架。

首先创建 URL 集合文件(即 src/MyLinks.url.js),其中包含以命名导出形式列出的 URL 列表。索引器将使用导出名称作为 Story 标题,并使用值作为唯一标识符。

MyLinks.url.js
export default {};
 
export const DesignTokens = 'https://www.designtokens.org/';
export const CobaltUI = 'https://cobalt-ui.pages.dev/';
export const MiseEnMode = 'https://mode.place/';
export const IndexerAPI = 'https://github.com/storybookjs/storybook/discussions/23176';

调整您的 Vite 配置文件,使其包含一个补充索引器的自定义插件。这将允许 Storybook 将 URL 集合文件处理并导入为 Story。

vite.config.ts
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 配置(即 .storybook/main.js|ts)以包含自定义索引器。

.storybook/main.js|ts
// 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 UI 配置文件(即 .storybook/manager.js|ts)以便在 UI 中将索引的 URL 渲染为侧边栏链接

.storybook/manager.ts
import { addons } from 'storybook/manager-api';
 
import SidebarLabelWrapper from './components/SidebarLabelWrapper.tsx';
 
addons.setConfig({
    sidebar: {
      renderLabel: (item) => SidebarLabelWrapper({ item }),
    },
});

此示例的代码和在线演示可在 StackBlitz 上获取。