Read OSS

Storybook 的架构:由 Channel 连接的三世界体系

中级

前置知识

  • Web 开发基础知识(HTML、CSS、JavaScript)
  • 对 Node.js 和 npm/yarn 有基本了解
  • 熟悉 monorepo 的概念
  • 了解 iframe 及跨帧通信机制

Storybook 的架构:由 Channel 连接的三世界体系

Storybook 是目前最广泛采用的 UI 组件隔离开发工具。然而,这个看似只有侧边栏加预览窗口的简单工具,内部实现却出人意料地复杂。在运行时,Storybook 以三个独立的环境协同工作——Node.js 服务端、基于浏览器的 Manager UI,以及沙箱化的 Preview iframe——三者通过事件驱动的 channel 抽象层相互通信。曾经分散在数十个 @storybook/* 包中的代码,现已整合为单一的 storybook 包,对外暴露 40 余个子路径导出,并配备了一套精心设计的构建分类系统。

本文将完整梳理这套架构。我们会从在终端输入 storybook dev 开始,逐步追踪 preset 解析、服务启动到浏览器打开的完整流程。读完之后,你将对 Storybook 各模块之间的协作关系建立清晰的心智模型。

统一的核心包

不久之前,使用 Storybook 意味着要安装一大堆包:@storybook/core-server@storybook/channels@storybook/preview-api@storybook/manager-api 等等。Storybook 团队将这些包全部整合进了单一的 storybook 包,源码位于 code/core

package.json 通过子路径导出暴露了完整的公共 API:

code/core/package.json#L48-L220

每个导出条目遵循统一的规范:types 条件指向声明文件,code 条件指向源码(供工具链使用),default 条件指向编译产物。storybook/preview-apistorybook/manager-api 等公共 API 位于顶层;内部模块则归属于 storybook/internal/*

导出路径 用途 运行环境
storybook/preview-api Story 渲染、decorator、args 浏览器(iframe)
storybook/manager-api Manager 状态、addon hooks 浏览器(父框架)
storybook/test 测试工具(expectfn 浏览器(iframe)
storybook/internal/core-server 开发/构建服务器编排 Node.js
storybook/internal/channels 跨环境通信 两端均有
storybook/internal/core-events 事件名称常量 两端均有

提示: 阅读 Storybook 源码时,来自 storybook/internal/* 的导入属于内部 API,可能在次版本升级中发生变化。没有 internal 前缀的公共 API 才是稳定的契约。

三大环境:Server、Manager 与 Preview

Storybook 的三环境模型并非任意设计,而是源于一个根本性的约束:组件渲染必须与开发工具完全隔离

flowchart TB
    subgraph Node["Node.js Server"]
        CS[Core Server]
        SIG[StoryIndexGenerator]
        PS[Preset System]
    end
    subgraph Browser["Browser Window"]
        subgraph Manager["Manager (Parent Frame)"]
            Sidebar[Sidebar]
            Toolbar[Toolbar]
            Addons[Addon Panels]
        end
        subgraph Preview["Preview (iframe)"]
            Story[Rendered Story]
            Decorators[Decorators]
            PlayFn[Play Functions]
        end
    end
    Node -->|"WebSocket (dev only)"| Manager
    Node -->|"WebSocket (dev only)"| Preview
    Manager <-->|"PostMessage"| Preview

Server(Node.js)负责文件系统访问、story 索引构建、静态文件服务以及构建流水线。它以 Polka 作为 HTTP 服务器,并暴露 WebSocket 端点用于实时通信。

Manager 是一个完整的 React 应用,负责渲染侧边栏、工具栏和 addon 面板。它运行在父级浏览器框架中,由 esbuild 构建。

Preview 运行在沙箱化的 iframe 内,这里才是你的组件真正渲染的地方。它由 Vite 或 webpack 构建,具体取决于你选择的框架。iframe 隔离确保了组件的样式、全局变量和副作用不会泄漏到 Storybook 的 UI 层——反之亦然。

Monorepo 结构

Storybook 仓库以 monorepo 形式组织,代码统一放在 code/ 目录下。核心结构如下:

目录 用途 示例
code/core/ 统一的 storybook 包——所有内容从这里发布 channels、preview-api、manager-api、core-server
code/frameworks/ 框架专属 preset,将 builder 与 renderer 组合在一起 react-viteangularsvelte-vite
code/builders/ 构建工具集成 builder-vitebuilder-webpack5
code/renderers/ 框架专属渲染实现 reactvue3svelte
code/addons/ 官方 addon 包 docsa11ythemesvitest
code/lib/ 独立工具库 cli-storybookcreate-storybookcodemod
scripts/ 构建、检查与沙箱编排脚本 build-package.ts、任务执行器

这里有一个关键认知:框架本质上只是轻量的 preset 封装。以 react-vite 为例,它只是声明了使用哪个 builder 和 renderer:

code/frameworks/react-vite/src/preset.ts#L5-L8

export const core: PresetProperty<'core'> = {
  builder: import.meta.resolve('@storybook/builder-vite'),
  renderer: import.meta.resolve('@storybook/react/preset'),
};

其余所有工作——构建配置、开发服务器搭建、story 索引——都由核心层处理,并通过 preset 系统进行组合。

CLI 分发器

执行 storybook dev 时,入口是位于 code/core/src/bin/dispatcher.ts 的分发器。它的实现简洁优雅:一个小型路由函数,决定将每条命令发送到哪里。

code/core/src/bin/dispatcher.ts#L36-L87

flowchart LR
    CLI["storybook <command>"] --> Check{Command?}
    Check -->|"dev / build / index"| Core["core.js (internal)"]
    Check -->|"init"| Create["create-storybook (npx)"]
    Check -->|"upgrade / doctor / ..."| SBCli["@storybook/cli (npx)"]

分发逻辑分为三条路径:

  1. 核心命令devbuildindex)通过动态 import() 直接从编译后的核心二进制文件加载执行。
  2. init 被路由到 create-storybook 包——如果版本匹配则使用本地安装版本,否则通过 npx 调用。
  3. 其他命令upgradedoctorautomigrate)转发至 @storybook/cli,同样具备版本匹配后回退到 npx 的机制。

在进行任何路由之前,分发器会先校验 Node.js 版本——Storybook 要求 Node 20.19+ 或 22.12+。这是第 24 行处的硬性拦截,而非软性警告。

完整追踪 storybook dev 的执行流程

让我们从 storybook dev 开始,完整追踪到浏览器打开并显示 story 的全过程。

sequenceDiagram
    participant CLI as CLI Dispatcher
    participant BDS as buildDevStandalone
    participant Load as loadAllPresets
    participant Server as Polka Server
    participant MB as Manager Builder (esbuild)
    participant PB as Preview Builder (Vite)
    participant Browser as Browser

    CLI->>BDS: import core.js → buildDevStandalone()
    BDS->>BDS: Resolve port, configDir, outputDir
    BDS->>Load: First pass (determine builder)
    Load-->>BDS: Builder info + core config
    BDS->>BDS: Create WebSocket channel
    BDS->>Load: Second pass (all presets)
    Load-->>BDS: Full options with presets
    BDS->>Server: storybookDevServer(options, server)
    Server->>MB: managerBuilder.start()
    Server->>PB: previewBuilder.start()
    Server->>Server: app.listen(port)
    Server->>Browser: openInBrowser(address)

整个流程从 buildDevStandalone() 开始:

code/core/src/core-server/build-dev.ts#L44-L51

这个函数负责编排整个开发体验。它解析端口(若目标端口被占用则提示用户),加载主配置,校验框架,然后执行关键的两阶段 preset 加载——第一阶段确定 builder,第二阶段加载所有 preset(含 builder 的覆盖 preset)。

HTTP 服务器使用的是 Polka,一个轻量的 Express 替代品:

code/core/src/core-server/dev-server.ts#L28-L34

开发服务器会配置中间件(压缩、host 校验、访问控制、缓存),注册用于提供 story 索引的 index.json 路由,然后并行启动两个 builder。Manager builder 使用 esbuild,Preview builder 则根据你的框架选择使用 Vite 或 webpack。

Node / Browser / Runtime 构建分层

核心包中的 build-config.ts 将每个入口点划分为四类:

code/core/build-config.ts#L22-L210

flowchart TD
    BC[build-config.ts] --> Node[node entries]
    BC --> Browser[browser entries]
    BC --> Runtime[runtime entries]
    BC --> GR[globalizedRuntime entries]

    Node --> N1[core-server]
    Node --> N2[node-logger]
    Node --> N3[telemetry]
    Node --> N4[csf-tools]
    Node --> N5[common utilities]

    Browser --> B1[preview-api]
    Browser --> B2[manager-api]
    Browser --> B3[channels]
    Browser --> B4[theming]
    Browser --> B5[components]

    Runtime --> R1[preview/runtime]
    Runtime --> R2[manager/globals-runtime]
    Runtime --> R3[mocker-runtime]

    GR --> G1[manager/runtime.tsx]

Node entries 是服务端代码,可以使用 fspath 等 Node API,以 Node 为目标平台进行打包。

Browser entries 是运行在浏览器中的客户端代码,以浏览器兼容目标(Chrome 100+、Safari 15+、Firefox 91+)进行打包。

Runtime entries 较为特殊——它们是浏览器端代码,但必须在不做代码分割的情况下打包,因为它们会以独立脚本的形式注入到 Preview iframe 中。

Globalized runtime entries(仅包含 manager runtime)会被包裹处理,将导出内容挂载到 window 对象上,以支持 addon 互操作性。

这套分类不只是构建优化的手段,更是一道安全边界:如果在浏览器代码中引入了 node entry(或反过来),运行时就会出错。构建系统通过为每个分类使用不同的 esbuild 配置,从根本上强制执行这种隔离。

提示: 如果你在为 Storybook 贡献代码并需要添加新模块,首先要做的架构决策之一就是确定它属于哪个分类。问问自己:"这段代码需要访问文件系统吗?它运行在 iframe 里吗?它需要作为全局变量对外暴露吗?"

下一步

我们已经建立了整体架构的认知:三大环境、具备构建分类的统一包,以及将命令分发到两阶段 preset 加载系统的 CLI。但关于配置的实际工作方式,我们只是触及了皮毛。下一篇文章将深入探讨 preset 系统——Storybook 的配置内核,它通过函数式归约链将来自数十个来源的配置组合在一起。