Read OSS

Preview 渲染管线:从 CSF 文件到渲染完成的 Story

高级

前置知识

  • 第 1 篇:架构总览
  • 第 3 篇:Channel 与事件
  • 了解 Component Story Format (CSF)
  • 具备基本 React 知识(用于理解渲染器示例)

Preview 渲染管线:从 CSF 文件到渲染完成的 Story

Preview iframe 是组件真正运行的地方,也是一切魔法发生的舞台。前几篇文章分别介绍了配置的组合方式(第 2 篇)和事件在不同环境间的流转(第 3 篇)。本篇将聚焦于 iframe 内部的完整管线——从引导整个运行时的入口脚本生成,到 story 的加载与配置合并,再到管理各渲染阶段(从准备到 play 函数执行直至完成)的详细生命周期。

这是本系列技术深度最高的一篇。读完之后,你将彻底理解从「用户点击某个 story」到「像素出现在屏幕上」之间究竟发生了什么。

通过生成代码引导 Preview

Preview iframe 加载的并非一个静态 JavaScript 文件,而是由构建工具(Vite 或 webpack)生成的虚拟入口模块,它负责将所有内容串联在一起:

code/builders/builder-vite/src/codegen-modern-iframe-script.ts#L53-L71

生成代码按照以下固定顺序执行:

flowchart TD
    A["1. import { setup } from 'storybook/internal/preview/runtime'"] --> B["2. import addon setup (virtual)"]
    B --> C["3. setup() — apply globals, error handlers"]
    C --> D["4. import PreviewWeb from 'storybook/preview-api'"]
    D --> E["5. import { importFn } from virtual stories file"]
    E --> F["6. import { getProjectAnnotations } from virtual annotations"]
    F --> G["7. new PreviewWeb(importFn, getProjectAnnotations)"]
    G --> H["8. HMR handler setup"]

setup() 调用的是第 1 篇中介绍的 preview runtime——它将全局包挂载到 window 上(使 storybook/preview-api 的导出可被访问),配置遥测错误处理,并将 iframe 的 inert 状态与 Manager 同步。

code/core/src/preview/runtime.ts#L23-L56

两个关键虚拟模块分别是:

  • importFn — 根据 stories glob 配置生成,按路径懒加载 story 文件的函数
  • getProjectAnnotations — 汇总来自插件和 preview.ts 的所有 preview 注解(decorators、parameters 等)

Preview 类层级结构

Preview 通过两层类结构来运作:

code/core/src/preview-api/modules/preview-web/Preview.tsx#L60-L88

classDiagram
    class Preview~TRenderer~ {
        #storyStoreValue: StoryStore
        +renderToCanvas: RenderToCanvas
        +storyRenders: StoryRender[]
        #importFn: ModuleImportFn
        +getProjectAnnotations()
        +initialize()
        #setupListeners()
        +onStoriesChanged()
    }
    class PreviewWithSelection~TRenderer~ {
        +currentSelection: Selection
        +currentRender: PossibleRender
        +selectionStore: SelectionStore
        +view: View
        +setupListeners()
        -onSetCurrentStory()
        -renderSelection()
    }
    Preview <|-- PreviewWithSelection

Preview(基类)负责:

  • Story 加载与 StoryStore 初始化
  • 监听 args 更新、globals 变更、强制重渲染等 Channel 事件
  • Story 索引的拉取与刷新
  • 项目级注解管理

PreviewWithSelection(浏览器环境中使用的子类)在此基础上增加了:

  • 通过 SET_CURRENT_STORY 事件处理 story 选择
  • URL 同步
  • 渲染生命周期编排(创建和管理 StoryRender 实例)
  • 键盘事件处理

之所以将两者分离,是因为 Preview 可以在没有浏览器选择模型的测试场景(例如 portable stories API)中单独使用。

StoryStore:Story 的加载与准备

StoryStore 是所有 story 数据的核心缓存:

code/core/src/preview-api/modules/store/StoryStore.ts#L51-L97

flowchart TD
    Import["importFn('./Button.stories.tsx')"] --> Process["processCSFFile(exports, importPath)"]
    Process --> CSF["CSFFile { meta, stories[] }"]
    CSF --> Prepare["prepareStory(story, meta, projectAnnotations)"]
    Prepare --> PS["PreparedStory { 
      parameters, args, argTypes,
      decorators, loaders, playFunction,
      applyBeforeEach, applyAfterEach
    }"]
    PS --> Cache["Memoized cache (10,000 stories)"]

当某个 story 被请求时,store 会依次执行:

  1. 导入:通过 importFn(构建时生成的虚拟模块)加载 CSF 模块
  2. 处理:使用 processCSFFile 解析模块,提取 meta(默认导出)和每个具名 story 导出
  3. 准备:调用 prepareStory 合并项目级、组件级和 story 级的配置

processCSFFileprepareStory 均已做了 memoize 处理——前者缓存 1,000 条,后者缓存 10,000 条。这一点至关重要,因为每次 args 变更或 globals 更新都会触发 story 重新准备;如果没有 memoize,性能开销将难以承受。

composeConfigs:合并 Decorators、Parameters 与 Args

负责将多层配置合并为一个整体的算法实现在 composeConfigs 中:

code/core/src/preview-api/modules/store/csf/composeConfigs.ts#L43-L76

该函数接收一个模块导出的数组(来自插件、preview.ts、核心注解),并输出一个 NormalizedProjectAnnotations 对象。每个字段都有其特定的合并策略:

字段 策略 说明
parameters 深度合并(combineParameters 后面的值覆盖前面的
decorators 数组拼接 默认按文件逆序排列(最外层在前)
args 对象合并 后面的键值覆盖前面的
argTypes 对象合并 后面的键值覆盖前面的
loaders 数组拼接 按顺序执行
beforeEach / afterEach 数组拼接 生命周期钩子
render 最后一个生效(单例) 只能有一个渲染函数
renderToCanvas 最后一个生效(单例) 由框架提供
tags 数组拼接 追加式

decorator 的排序值得特别关注:在默认模式下(legacyDecoratorFileOrder: false),decorators 会被反转,使文件中第一个 decorator 成为最外层包裹。这与直觉一致——decorators: [withTheme, withRouter] 的含义就是「withTheme 包裹 withRouter 包裹 story」。

提示: 如果你的 decorators 执行顺序不对,检查一下配置中是否设置了 features.legacyDecoratorFileOrder。该默认值在 Storybook 7 中已更改,部分项目仍保留了旧版标志。

StoryRender 生命周期

这是整个渲染管线的核心。当需要渲染某个 story 时,PreviewWithSelection 会创建一个 StoryRender 实例,由它管理多阶段的生命周期:

code/core/src/preview-api/modules/preview-web/render/StoryRender.ts#L36-L48

stateDiagram-v2
    [*] --> preparing
    preparing --> loading: Story loaded from store
    loading --> beforeEach: Loaders resolved
    beforeEach --> rendering: beforeEach callbacks run
    rendering --> playing: Component mounted (if play function)
    rendering --> completing: Component mounted (no play function)
    playing --> played: Play function completed
    played --> completing: No errors
    playing --> errored: Play function threw
    completing --> completed: Animations settled
    completed --> afterEach: STORY_RENDERED emitted
    afterEach --> finished: Cleanup hooks run

    preparing --> aborted: AbortSignal
    loading --> aborted: AbortSignal
    playing --> aborted: AbortSignal

每次阶段切换都会触发 STORY_RENDER_PHASE_CHANGED 事件,让 Manager(以及 Interactions 等插件面板)能够追踪渲染进度。各阶段定义如下:

code/core/src/preview-api/modules/preview-web/render/StoryRender.ts#L105-L116

辅助方法 runPhase(第 105 行)负责更新阶段、发出阶段变更事件、执行阶段函数,并检查是否需要中止。每个阶段都会检测 AbortSignal——如果在当前 story 渲染过程中选择了新的 story,旧的渲染会被干净地中止。

preparing(第 130–139 行):从 StoryStore 加载 story。若在此阶段被中止,立即调用 story 的清理函数。

loading(第 287–289 行):执行 story 的 loaders——这些异步函数负责获取 story 所需的数据,结果会挂载到 context.loaded

beforeEach(第 295–296 行):执行 beforeEach 回调,并收集其返回的清理函数以供后续拆卸使用。

rendering(第 302–304 行):调用 context.mount(),进而委托给框架渲染器的 renderToCanvas。React 的 createRoot().render() 或 Vue 的 createApp().mount() 正是在这里被调用。

playing(第 328–341 行):如果 story 定义了 play 函数且启用了自动播放,则在此执行。play 期间未捕获的错误会被收集并通过 PLAY_FUNCTION_THREW_EXCEPTION 上报。

completing → completed(第 381–391 行):等待 CSS 动画结束(测试环境中会暂停动画),然后触发 STORY_RENDERED

afterEach(第 394–397 行):执行 afterEach 回调,完成清理工作。

finished(图中未展示):发出 STORY_FINISHED 事件,附带成功或失败状态。

渲染器集成与 HMR

框架渲染器实现了 renderToCanvas 函数,StoryRender 生命周期在「rendering」阶段会调用它。这是 composeConfigs 中的单例字段,最后加载的渲染器生效。对于 React,它会创建一个 React root 并渲染 story;对于 Vue,创建 Vue app 实例;对于 Svelte,则挂载 Svelte 组件。

生成的入口脚本同时配置了热模块替换(HMR):

code/builders/builder-vite/src/codegen-modern-iframe-script.ts#L32-L42

sequenceDiagram
    participant Vite as Vite HMR
    participant Entry as Entry Script
    participant Preview as PreviewWeb
    participant Channel as Channel

    Vite->>Entry: hot.accept(VIRTUAL_STORIES_FILE)
    Entry->>Preview: onStoriesChanged({ importFn: newModule.importFn })
    Preview->>Preview: Re-index stories, re-render current
    Vite->>Entry: hot.on('vite:afterUpdate')
    Entry->>Channel: emit(STORY_HOT_UPDATED)

当 story 文件发生变更时,Vite 会触发 HMR 处理器。入口脚本将新的 importFn 传递给 PreviewWeb.onStoriesChanged(),后者重新索引受影响的 story,并在必要时重新渲染当前选中的内容。STORY_HOT_UPDATED 事件则通知 Manager story 可能已发生变化。

Web Components 是个特殊情况——由于与 HMR 不兼容,生成代码会调用 import.meta.hot.decline(),强制触发整页重载。

提示: 如果 story 无法正常热更新,检查该文件是否匹配虚拟模块中配置的 stories glob 规则。不在 glob 范围内的文件不会被 HMR 处理器监听。

下一步

至此,我们已经完整追踪了 Preview iframe 内部的整个路径——从引导启动到渲染完成。浏览器体验的另一半是 Manager UI:它是一个 React 应用,提供侧边栏、工具栏和插件面板。下一篇文章将探讨其由 14 个可组合模块构成的模块化状态架构、插件注册系统,以及支持 Storybook 组合的 ref 系统。