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— 根据storiesglob 配置生成,按路径懒加载 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 会依次执行:
- 导入:通过
importFn(构建时生成的虚拟模块)加载 CSF 模块 - 处理:使用
processCSFFile解析模块,提取 meta(默认导出)和每个具名 story 导出 - 准备:调用
prepareStory合并项目级、组件级和 story 级的配置
processCSFFile 和 prepareStory 均已做了 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 系统。