文档
Storybook 文档

索引器

(⚠️ 实验性功能)

虽然此功能处于实验阶段,但必须通过 experimental_indexers 属性指定 StorybookConfig

父级:main.js|ts 配置

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

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

索引器 API 是一项高级功能,允许您自定义 Storybook 的索引器,这些索引器决定 Storybook 如何将文件索引和解析为故事条目。这为编写故事的方式增加了更多灵活性,包括定义故事的语言或获取故事的位置。

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

.storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
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: // See API and examples below...
    };
    return [...existingIndexers, customIndexer];
  },
};
 
export default config

除非您的索引器执行相对简单的事情(例如 使用不同的命名约定索引故事),除了索引文件外,您可能还需要 将其编译成 CSF,以便 Storybook 可以在浏览器中读取它们。

Indexer

类型

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

指定要索引的文件以及如何将其作为故事进行索引。

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;
}

表示要添加到故事索引中的故事的对象。

exportName

(必填)

类型:string

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

importPath

(必填)

类型:string

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

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

type

(必填)

类型:'story'

条目的类型。

rawComponentPath

类型:string

提供 meta.component 的文件(如果存在)的原始路径/包。

metaId

类型:string

默认值:从 title 自动生成

定义条目的元数据的自定义 ID。

如果指定,则 CSF 文件中的导出默认值(元数据)**必须**具有相应的 id 属性,才能正确匹配。

name

类型:string

默认值:从 exportName 自动生成

条目的名称。

tags

类型:string[]

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

title

类型:string

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

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

大多数情况下,您**不应该**指定标题,以便您的索引器使用默认命名行为。在指定标题时,您**必须**使用 makeTitle 函数(在 IndexerOptions 中提供)来也使用此行为。例如,以下索引器只是在从文件名派生的标题前添加“Custom”前缀

.storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
import type { Indexer } from '@storybook/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 ...
 
    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 自动生成

定义条目的故事的自定义 ID。

如果指定,则 CSF 文件中的故事**必须**具有相应的 __id 属性,才能正确匹配。

仅当您需要覆盖自动生成的 ID 时才使用此选项。

转换为 CSF

IndexInput 中,importPath 的值必须解析为 CSF 文件。但是,大多数自定义索引器之所以存在,仅仅是因为输入**不是** CSF。因此,您可能需要将输入转换为 CSF,以便 Storybook 可以在浏览器中读取它并呈现您的故事。

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

一般的架构如下所示

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

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

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

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

让我们看一个关于其工作原理的示例。

首先,这是一个非 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. 运行该函数以生成故事
  4. 将故事写入 CSF 文件

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

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

示例

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

从固定装置数据或 API 端点动态生成故事

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

.storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
import type { Indexer } from '@storybook/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 生成故事

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

在非 JavaScript 语言中定义故事

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