Read OSS

构建系统、测试与开发者工作流

中级

前置知识

  • 第 1 篇:架构概览
  • 熟悉构建工具(esbuild、Vite、webpack 相关概念)
  • 了解 monorepo 工具链(Nx、Yarn workspaces)

构建系统、测试与开发者工作流

前五篇文章带我们深入探索了 Storybook 的运行时架构——三个环境、preset 系统、channel 通信、Preview 渲染流水线以及 Manager UI。但这些代码的背后,离不开一套精密的构建与开发基础设施。Storybook 的 monorepo 包含 50 多个 package,它们必须按照依赖顺序编译、在所有支持的框架组合上完成测试,并以统一的错误处理机制对外发布。

本系列的最后一篇,我们来看让这一切运转起来的核心机制:Nx 任务编排、第 1 篇介绍的 build-config.ts 入口分类、驱动侧边栏的 StoryIndexGenerator、用于智能重构建的 ChangeDetectionService、集成测试用的沙箱系统,以及结构化错误体系。

Nx 任务编排

Storybook monorepo 使用 Nx 进行任务编排。nx.json 定义了任务依赖图:

nx.json#L24-L187

flowchart TD
    Compile["compile"] -->|"^compile (dependencies first)"| Check["check"]
    Compile --> Publish["publish"]
    Publish --> RunRegistry["run-registry (verdaccio)"]
    RunRegistry --> Sandbox["sandbox"]
    Sandbox --> Build["build"]
    Sandbox --> Dev["dev"]
    Build --> Serve["serve"]
    Serve --> E2E["e2e-tests"]
    Serve --> TestRunner["test-runner"]
    Build --> Chromatic["chromatic"]

    Compile --> Jest["jest"]
    Compile --> Vitest["vitest"]
    Compile --> PlaywrightCT["playwright-ct"]

核心任务说明如下:

任务 依赖 用途
compile ^compile(package 依赖) 使用 esbuild 编译单个 package
check 所有 package 编译完成 TypeScript 类型检查
publish 所有 package 编译完成 发布到本地 Verdaccio 注册表
sandbox 本地注册表运行中 为某个框架生成测试项目
build 沙箱已创建 构建沙箱的 Storybook
e2e-tests 沙箱已启动服务 针对沙箱运行 Playwright 测试

compile 任务(第 25–35 行)是整个流程的基础。它为当前项目运行 build-package.ts,以 ^compile 作为依赖(即所有上游 package 必须先完成编译),并根据生产环境输入缓存结果。根级别的 parallel: 8 配置允许最多 8 个 package 同时编译。

提示: 在为 Storybook 贡献代码时,可以运行 yarn nx compile storybook 来单独构建 core package。^compile 依赖会确保所有上游 package 提前构建完毕。如果只想构建自上次提交以来变更的内容,使用 yarn nx affected -t compile 即可。

Package 编译流水线

每个 package 的构建由 build-package.ts 驱动:

scripts/build/build-package.ts#L1-L80

flowchart TD
    Start["build-package.ts"] --> ReadPkg["Read package.json"]
    ReadPkg --> FindConfig["Find build-config.ts"]
    FindConfig --> Prebuild{"Has prebuild?"}
    Prebuild -->|Yes| RunPrebuild["Run prebuild script"]
    Prebuild -->|No| Skip
    RunPrebuild --> GenBundle["generateBundle()"]
    Skip --> GenBundle
    GenBundle --> |"For each entry category"| ESBuild["esbuild with platform-specific config"]
    GenBundle --> GenTypes["generateTypesFiles()"]
    GenBundle --> GenPkgJson["generatePackageJsonFile()"]

该脚本读取 package 的 build-config.ts(第 1 篇已介绍),并针对每种入口类别使用不同的 esbuild 配置进行处理:

  • Node 入口platform: 'node',允许使用 Node.js 内置模块
  • Browser 入口platform: 'browser',目标环境为 Chrome 100+/Safari 15+/Firefox 91+
  • Runtime 入口:与 browser 类似,但禁用代码分割(必须是自包含的脚本)
  • Globalized runtime 入口:经过包装,将导出挂载为 window.__STORYBOOK_* 全局变量

core package 有一个 prebuild 步骤(build-config.ts 第 8–21 行),会运行 generate-source-files.ts,在主构建开始前生成版本文件、类型重导出及其他派生源码。

正如第 1 篇所介绍的,core 的 build-config.ts 对入口进行了明确分类:

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

12 个 node 入口、23 个 browser 入口、3 个 runtime 入口和 1 个 globalized runtime 入口,各自使用对应的平台配置进行编译。这套分类机制能在构建阶段就阻断跨环境的错误导入——如果某个 browser 入口试图引入 fs,问题会在构建时暴露,而不是留到运行时才报错。

StoryIndexGenerator

StoryIndexGenerator 是负责发现和索引所有 story 的服务端组件,index.json 的生成以及侧边栏的数据填充都依赖于它:

code/core/src/core-server/utils/StoryIndexGenerator.ts#L101-L127

flowchart TD
    Specifiers["stories: ['../src/**/*.stories.tsx']"] --> Glob["Glob filesystem"]
    Glob --> Files["Matched files"]
    Files --> Indexer{"Match indexer?"}
    Indexer -->|"CSF indexer"| Parse["Parse with loadCsf()"]
    Indexer -->|"MDX indexer"| MDXParse["Parse MDX"]
    Parse --> Entries["Story entries + docs entries"]
    MDXParse --> DocsEntry["Docs entry"]
    Entries --> Cache["SpecifierStoriesCache"]
    DocsEntry --> Cache
    Cache --> Sort["Sort stories (storySortParameter)"]
    Sort --> Index["StoryIndex (index.json)"]

该生成器维护两级缓存:

  1. Specifier → Files 缓存:将每个 stories glob 模式映射到对应的文件集合
  2. File → Entries 缓存:将每个文件映射到其解析出的 story/docs 条目

文件发生变更时,只有该文件对应的缓存条目会被失效。生成器随后合并所有缓存条目,去重(story 优先于 docs),并按用户配置的 storySortParameter 排序,从而重新计算完整索引。

CSF indexer(定义于 common-preset,第 2 篇已介绍)使用 storybook/internal/csf-tools 中的 loadCsf() 解析 story 文件。这是一个基于 AST 的解析器,无需执行文件即可提取 story 元数据——仅通过静态分析便能确定 story 名称、标签和参数。

变更检测与智能重构建

ChangeDetectionService 是一项较新的功能,在开发过程中提供了智能的重构建机制:

code/core/src/core-server/change-detection/ChangeDetectionService.ts#L87-L153

flowchart TD
    Builder["Preview Builder (Vite)"] -->|"onModuleGraphChange"| CDS["ChangeDetectionService"]
    CDS --> Debounce["Debounce 200ms"]
    Debounce --> Git["GitDiffProvider.getChangedFiles()"]
    Git --> Trace["findAffectedStoryFiles(moduleGraph, changedFiles)"]
    Trace --> Status["Compute story statuses:\n    - new\n    - modified\n    - affected"]
    Status --> Store["StatusStore.set()"]
    Store --> UI["Manager sidebar shows change indicators"]

该服务整合了三路输入:

  1. 构建器的模块图更新(Vite 提供依赖信息)
  2. 来自 GitDiffProvider 的 Git diff(相对于基准分支的文件变更)
  3. 来自 StoryIndexGenerator 的 story 索引(story 与文件的映射关系)

通过追踪构建器的模块图,该服务不仅能识别直接被修改的 story 文件,还能找出受到传递性影响的文件——如果某个公共工具函数发生变更,所有导入它的 story 都会被标记为"affected"。状态信息最终写入 StatusStore,Manager 读取后在侧边栏展示对应的变更标记。

该服务通过 features.changeDetection 进行功能开关控制(在 common preset 中默认关闭),并需要构建器支持模块图上报能力。

沙箱系统

针对 Storybook 所支持框架的集成测试,依托一套沙箱生成系统来完成。Nx 任务图清晰地描述了这一流程:

compile → publish → run-registry → sandbox → build → serve → e2e-tests

每个沙箱都是为特定框架组合(如 react-vite/default-tsangular/default-tssvelte-vite/default-ts)生成的真实项目。整个流程如下:

  1. Publish:将所有 package 发布到本地 Verdaccio npm 注册表
  2. Sandbox:从模板生成项目,从本地注册表安装 package,并完成 Storybook 配置
  3. Build:构建沙箱的 Storybook,验证构建流水线
  4. E2E:针对已启动的沙箱运行 Playwright 测试

这种方式能捕获单元测试难以发现的集成问题——框架专属的 Vite plugin、preset 组合顺序、HMR 行为以及 renderer 兼容性,都会在真实项目中得到验证。

nx.json 将这些任务定义为可缓存的 target,并使用 ^production 作为输入,这意味着任意依赖中的生产源文件发生变更时,缓存都会自动失效。

结构化错误系统

Storybook 基于抽象类 StorybookError 构建了一套结构化的错误继承体系:

code/core/src/storybook-error.ts#L25-L135

classDiagram
    class StorybookError {
        <<abstract>>
        +category: string
        +code: number
        +documentation: boolean|string|string[]
        +fromStorybook: true
        +isHandledError: boolean
        +subErrors: StorybookError[]
        +fullErrorCode: string
        +name: string
    }
    class ServerError {
        category = "SERVER"
    }
    class PreviewError {
        category = "PREVIEW_API"
    }
    class ManagerError {
        category = "MANAGER_UI"
    }
    StorybookError <|-- ServerError
    StorybookError <|-- PreviewError
    StorybookError <|-- ManagerError

每个错误包含以下信息:

  • category(如 SERVERPREVIEW_APIMANAGER_UI):标识所属环境
  • code(数字,补零至 4 位):唯一标识该错误
  • 可选的 documentation 链接

fullErrorCode getter(第 59 行)生成形如 SB_SERVER_0001SB_PREVIEW_API_0003 的字符串。name getter 则格式化为 SB_SERVER_0001 (MissingBuilderError)

错误定义按三个环境拆分到三个文件中:

  • server-errors.ts — Node.js 服务端错误
  • preview-errors.ts — Preview iframe 错误
  • manager-errors.ts — Manager UI 错误

fromStorybook: true 标志(第 51 行)让 Preview 运行时的错误处理器(第 1 篇已介绍)能够区分 Storybook 自身的错误与用户代码错误,并将前者路由至遥测上报。

subErrors 数组(第 92 行)支持错误聚合——父错误可以携带多个相关的子错误。发送至遥测时,父错误和每个子错误都会作为独立事件上报。

提示: 在为 Storybook 贡献代码时,新建错误应始终继承自对应环境的错误文件,并分配唯一的 code 编号。这种结构化格式便于自动化错误追踪,也方便将用户直接引导至相关文档。

系列总结

经过六篇文章,我们从最外层的整体结构一路深入到最底层的实现细节,完整梳理了 Storybook 的架构:

  1. 架构概览:三个环境、统一 package、CLI 分发机制
  2. Preset 系统:通过 reduce 链实现配置组合
  3. Channel 与事件:跨环境通信协议
  4. Preview 渲染:CSF 处理、StoryRender 生命周期、框架 renderer
  5. Manager UI:模块化状态管理、addon 集成、组合机制
  6. 构建系统:Nx 编排、story 索引、变更检测、结构化错误

贯穿始终的核心理念是组合:preset 组合配置,channel 组合通信,module 组合状态,构建系统组合 package。Storybook 的强大之处——以及它的复杂性——都源于同一个设计思想:让每个部分都能独立组合,同时保持整体的一致性。