Read OSS

预设系统:Storybook 如何从多个来源组合配置

高级

前置知识

  • 第 1 篇:架构概览
  • 理解 JavaScript 模块解析机制
  • 熟悉函数式组合模式(reduce/fold)

预设系统:Storybook 如何从多个来源组合配置

如果说第一篇中介绍的三环境架构是 Storybook 的骨架,那么预设系统就是它的神经系统。Storybook 中的每一项配置——从 babel 转换、Vite 插件,到侧边栏中显示哪些 stories——都流经同一条可组合的管道。预设本质上是一个导出了若干具名函数的模块。每个函数接收已累积的配置,并返回转换后的结果。将这些函数按正确顺序叠加在一起,就能得到完整的 Storybook 体验。

无论是开发插件、自定义 Storybook 配置,还是参与核心贡献,理解预设系统都是必不可少的。让我们从概念到实现,逐步拆解这套系统。

什么是预设?

最基本的预设,就是一个导出了具名函数或值的 JavaScript 模块。每个导出项对应一个配置键——corestoriesviteFinalfeaturesbabel 等。当 Storybook 需要某个配置键的值时,它会按顺序遍历所有已加载的预设,并将累积值传给每个预设中对应该键的函数。

驱动这一机制的类型定义在核心模块中:

// Simplified from storybook/internal/types
interface LoadedPreset {
  name: string;
  preset: Record<string, any>;  // The module's exports
  options: Record<string, any>; // Options passed to this preset
}

interface Presets {
  apply: (extension: string, config?: any, args?: any) => Promise<any>;
}

apply 方法是唯一的公开接口。调用方无需与单个预设打交道,只需请求一个配置键,就能拿到完整组合后的结果。

两轮加载策略

如第一篇所述,buildDevStandalone() 会加载两次预设。这并非冗余,而是必要之举。第一轮用于确定构建器(Vite 或 webpack),因为构建器可能引入覆盖预设,而这些预设必须在所有其他预设之后加载。

code/core/src/core-server/build-dev.ts#L162-L180

flowchart TD
    Start[buildDevStandalone] --> Pass1["First Pass: loadAllPresets()"]
    Pass1 --> |"isCritical: true"| Determine["Determine builder from core config"]
    Determine --> Resolve["Resolve builder + renderer packages"]
    Resolve --> Pass2["Second Pass: loadAllPresets()"]
    Pass2 --> |"Full preset chain"| Apply["Apply presets for all config keys"]

    subgraph "First Pass Presets"
        FP1[Framework preset]
        FP2[Override preset]
    end

    subgraph "Second Pass Presets"
        SP1[common-preset.ts]
        SP2[Manager builder presets]
        SP3[Preview builder presets]
        SP4[Renderer preset]
        SP5[Framework preset]
        SP6[User main.ts]
        SP7[Override presets]
    end

第一轮会创建一个临时 channel(使用存根传输——若被调用会记录错误),并将 isCritical 设为 true,这意味着预设加载失败时会直接抛出异常,而非仅发出警告。此轮只加载框架预设和通用覆盖预设。

第一轮确定构建器后,第二轮将按以下顺序加载完整的预设链:

  1. common-preset.ts — 基础默认值
  2. Manager 构建器核心预设
  3. Preview 构建器核心预设
  4. Renderer 预设(如 @storybook/react/preset
  5. 框架预设(如 @storybook/react-vite/preset
  6. 用户的 main.ts(Storybook 配置文件)
  7. 覆盖预设(来自构建器与通用覆盖)

code/core/src/core-server/build-dev.ts#L246-L260

这个顺序至关重要:链中越靠后的预设,优先级越高。覆盖预设最后执行,对任何配置值拥有最终决定权。

applyPresets 的 reduce 链

预设系统的核心是 applyPresets()——一个对已加载预设执行的 reduce 操作:

code/core/src/common/presets.ts#L265-L316

flowchart LR
    Init["Initial Value"] --> P1["Preset 1 (common)"]
    P1 --> P2["Preset 2 (builder)"]
    P2 --> P3["Preset 3 (renderer)"]
    P3 --> P4["Preset 4 (framework)"]
    P4 --> P5["Preset 5 (user main.ts)"]
    P5 --> P6["Preset 6 (override)"]
    P6 --> Result["Final Config"]

针对每个预设对某个配置键的贡献,逻辑处理三种情况:

  1. 函数 — 以 (accumulatedValue, combinedOptions) 为参数调用该预设的导出函数,返回值作为新的累积值。这是最常见也最灵活的形式。
  2. 数组 — 与已累积的数组拼接。
  3. 对象 — 与已累积的对象进行浅合并。

来看一个具体示例。当 Storybook 需要 viteFinal(允许修改 Vite 配置的钩子)时,会调用 presets.apply('viteFinal', baseViteConfig),每个导出了 viteFinal 函数的预设都有机会对配置进行转换:

// In react-vite's preset.ts
export const viteFinal = async (config, { presets }) => {
  const plugins = [...(config?.plugins ?? [])];
  // Add docgen plugins...
  return { ...config, plugins };
};

传给每个函数的 combinedOptions 参数,合并了 storybookOptions、全局 args 以及该预设自身的 options。其中还包含一个嵌套的 presets 对象,同样具有 apply 方法,允许预设递归查询其他配置值。

提示: 编写 viteFinalwebpackFinal 钩子时,始终要展开传入的 config({ ...config, ... }),而不是返回一个全新的对象。丢弃已累积的 config 是预设相关 bug 中最常见的根源。

插件解析

在加载预设之前,需要先将插件从包名解析为实际文件路径。resolveAddonName 函数负责处理这一映射:

code/core/src/common/presets.ts#L68-L116

flowchart TD
    Input["Addon name string"] --> IsPreset{Ends with /preset?}
    IsPreset -->|Yes| ReturnPreset["Return as preset type"]
    IsPreset -->|No| Probe["Probe for /preset, /manager, /preview"]
    Probe --> HasFiles{Found any?}
    HasFiles -->|Yes| Virtual["Return as 'virtual' type with:
    - presets[] (from /preset)
    - managerEntries[] (from /manager)  
    - previewAnnotations[] (from /preview)"]
    HasFiles -->|No| TryDirect{Direct resolve?}
    TryDirect -->|Yes| ReturnPreset
    TryDirect -->|No| Undefined["Return undefined (skipped)"]

这就是每个插件三文件的约定。一个插件最多可以提供三个入口:

  • /preset — Node.js 端代码,用于修改 Storybook 的构建配置
  • /manager — 浏览器端代码,在 Manager UI 中注册面板、工具栏和选项卡
  • /preview — 浏览器端代码,向 Preview iframe 添加 decorators、parameters 或 globals

解析结果返回一种"虚拟"插件类型,将以上三者打包为一个统一结构。这正是 main.tsaddons: ['@storybook/addon-docs'] 得以展开为正确的预设和入口文件组合的方式。

通用预设:默认配置

通用预设是基础层——在任何框架、插件或用户配置生效之前,每个 Storybook 实例都从这里出发。

code/core/src/core-server/presets/common-preset.ts#L48-L236

它为多种配置键提供默认值:

配置键 默认值 用途
features 启用 actions、controls、interactions 内置插件的功能开关
typescript react-docgen,不进行类型检查 TypeScript/docgen 行为
babel 针对 story 文件的浏览器目标覆盖 Babel 转换默认值
experimental_indexers CSF 索引器(/(stories|story)\.(m?js|ts)x?$/ Story 文件发现
core channel 选项、遥测设置 核心运行时配置
storyIndexGenerator 异步单例生成器 Story 索引创建

第 205–221 行的 features 预设尤为值得关注——它是内置功能的总开关,包括 actions、controls、backgrounds、viewport 等。这些功能曾经都是需要单独安装的插件,现在已内置,并通过功能标志进行开关控制。

第 271–288 行的 experimental_serverChannel 函数负责初始化所有服务端 channel 处理器——文件搜索、story 创建、ghost stories、在编辑器中打开,以及遥测 channel。

框架作为轻量级预设封装

Storybook 中最简洁的设计决策之一,是框架包的实现方式。以 @storybook/react-vite 为例:

code/frameworks/react-vite/src/preset.ts#L1-L50

整个框架预设只做两件事:

  1. 通过 core 导出声明构建器和 renderer(第 5–8 行)
  2. 通过 viteFinal 自定义 Vite 配置,添加 React 专属插件(docgen 等)
flowchart LR
    FW["@storybook/react-vite"] --> Core["core: { builder: builder-vite, renderer: react }"]
    FW --> VF["viteFinal: adds docgen plugins"]

    subgraph "Resolved Chain"
        BV["@storybook/builder-vite/preset"]
        RR["@storybook/react/preset"]
    end
    Core --> BV
    Core --> RR

这意味着新增一个框架,只需声明要组合哪个构建器和 renderer,再加上框架特定的构建定制即可。预设链会处理其余的一切。

提示: 排查框架相关的构建问题时,首先检查框架的 preset.ts。其中的 viteFinal(或 webpackFinal)钩子,是框架专属 Vite/webpack 插件的注册之处,也是配置缺失或错误最常见的地方。

loadPreset 的递归机制

预设本身可以声明子预设和子插件。loadPreset 函数负责处理这一递归:

code/core/src/common/presets.ts#L156-L243

加载一个预设模块时,其 addonspresets 数组会被递归解析。顺序的设计是有意为之:子预设和子插件在父预设之前加载。这样父预设就能覆盖子级设置的任何内容。你的 main.ts 文件作为预设被加载,由于在链的同级中最后处理,对任何插件或框架配置都拥有最终决定权。

该函数还支持 disabledAddons 列表(来自 build.test.disabledAddons),允许测试构建跳过较重的插件,以加快执行速度。

连接到 Channel

预设系统是 Node.js 端的关注点——它在服务器启动时运行,组合出驱动构建和运行时的配置。但一旦构建完成、浏览器加载完毕,接管的就是另一套组合机制:channel 系统。在下一篇文章中,我们将探讨 Channel 类、PostMessage 传输层和 WebSocket 连接如何让三个环境实现实时通信,以及这套协议如何将整个 Storybook 体验串联在一起。