首页
/ Storybook Preview (Web) 内部机制:选择、渲染阶段状态机与中断恢复原理

Storybook Preview (Web) 内部机制:选择、渲染阶段状态机与中断恢复原理

2026-09-06 16:04:23作者:裘旻烁

本文基于 Storybook 仓库中 @storybook/preview-web 子包的官方 README(README-preview-web.md)展开,结合 code/core/src/preview-api/modules/preview-web/ 下的真实源码,系统讲解 Web 版 Preview 的三大职责(URL 读写、Channel 事件监听与事件发射、故事/文档渲染)、初始化流程、PreviewWeb / StoryRender / DocsRender 三层状态分工、渲染阶段(phase)状态机的完整生命周期,以及故事切换、重新渲染、强制重挂载时的中断(abort)与页面重载兜底策略。读完后,你将能够理解 Storybook 中 args 变更为什么能在 play 函数运行期间即时反映、HMR 时 play 函数如何被取消、以及为什么极端情况下 Storybook 会直接刷新 iframe。

一、Preview (Web) 的定位与三大职责

Storybook 的浏览器端由 Manager(侧边栏、工具栏)和 Preview(画布)两部分组成。Preview (Web) 是 Web 版 Preview 的主 API,其职责在原 README 中被概括为三点:

  1. 读取和更新 URL(经由 URL Store)——即把地址栏里的 ?id= / ?viewMode= 等查询参数解析成"当前选中了哪个故事",并在选择变化时写回地址栏;
  2. 监听 Channel 上的指令,并在事情发生时发射事件——Channel 是 Manager 与 Preview iframe 之间的消息总线;
  3. 把当前选择渲染到 WebView 中,可以是 story 视图,也可以是 docs 视图。

原 README 还交代了它的历史背景:这段代码原本是独立的 @storybook/preview-web 包,现已合并进 storybook 包的 preview-api 模块中(见 preview-api/README.md,该文件列出了 @storybook/addons@storybook/core-client@storybook/preview-web@storybook/store 四个"旧子包"的对应文档)。因此阅读 code/core/src/preview-api/modules/preview-web/ 目录时,实际上读的就是当年的 @storybook/preview-web 实现。

从源码结构看,入口类是 PreviewWeb

export class PreviewWeb<TRenderer extends Renderer> extends PreviewWithSelection<TRenderer> {
  constructor(
    public importFn: ModuleImportFn,
    public getProjectAnnotations: () => MaybePromise<ProjectAnnotations<TRenderer>>
  ) {
    super(importFn, getProjectAnnotations, new UrlStore(), new WebView());
    global.__STORYBOOK_PREVIEW__ = this;
  }
}

构造函数只接收两个依赖——异步 import() 函数和 getProjectAnnotations,然后注入默认的 UrlStore(职责 1)与 WebView(职责 3 的 DOM 视图层),并把自己挂到 global.__STORYBOOK_PREVIEW__ 上供集成方访问。职责 2(Channel 监听)则由基类 PreviewsetupListeners() 完成。

二、初始化:importFn、getProjectAnnotations 与故事索引

README 中"Initialization"一节列出的三个要点,与源码的对应关系如下。

2.1 importFn:异步 import()

importFnModuleImportFn 类型,即模块化的动态 import() 函数。Preview 本身不直接 import 故事文件,而是把导入能力交给构建方(Vite/Webpack builder 会提供一个带缓存、带 HMR 感知能力的导入器)。它被一路传入 StoryStore,最终在 StoryRender.prepare() 中经 this.store.loadStory({ storyId }) 使用(见 StoryRender.tsprepare() 的实现,见下文第四节)。

2.2 getProjectAnnotations:评估 preview.js 与 addon 配置

getProjectAnnotations 是一个评估 preview.js(项目级 annotations)与各 addon 配置文件并合并它们的函数;如果出错,Preview 会把错误显示出来。源码中的实现在 Preview.tsxgetProjectAnnotationsOrRenderError()

  • 先用 composeProjectAnnotationsWithCore 把 core annotations 折叠进用户 annotations(注释说明这是为了让 core 贡献的 beforeAll 钩子——例如注册 core/docgencore/story-docs 服务——在初始化阶段就跑起来);
  • 从结果中取出 renderToCanvas 并赋给 this.renderToCanvas,若缺失则抛出 MissingRenderToCanvasError
  • 捕获异常后调用 renderPreviewEntryError('Error reading preview.js:', err),向 channel 发射 CONFIG_ERROR 事件,这就是 README 所说"If it errors, the Preview will show the error"。

初始化主流程在 initialize() 中依次为:getProjectAnnotationsOrRenderError()runBeforeAllHook()(执行项目 beforeAll)→ initializeWithProjectAnnotations(),成功后向 channel 发射 PREVIEW_INITIALIZED(携带 userAgent)。

2.3 故事索引:从 README 的 stories.json 到当前的 index.json

README 原文说:不再传入 getStoryIndex 函数,而是 Preview 自己创建一个 StoryIndexClient,从 Node 端拉取 stories.json,并监听事件流中的 invalidation 事件。

对照当前源码,这段描述需要按"演进后"的理解来读:getStoryIndexFromServer() 通过 fetch(STORY_INDEX_PATH) 获取索引,其中 STORY_INDEX_PATH = './index.json'(即构建产物中的 index.json,README 时代称为 stories.json);同时 setupListeners() 注册了 channel.on(STORY_INDEX_INVALIDATED, this.onStoryIndexChanged)——这正是 README 所说的"监听事件流中的 invalidation 事件"。onStoryIndexChanged() 会重新 fetch 索引,若 store 已建立则走 onStoriesChanged({ storyIndex }) 更新,并触发当前选择的重新渲染。这条链路是 HMR 时故事列表增删能够热更新到画布上的底层依据。

三、三层状态分工:PreviewWeb / StoryRender / DocsRender

README 指出 Preview 被拆分为三个负责状态管理的部分:

  • PreviewWeb:决定"渲染哪个故事",接收 channel 事件,并(视情况)变更/重新渲染故事;
  • StoryRender:(导入并)准备故事,驱动它经历各个渲染阶段;
  • DocsRender:当故事以 docs 模式渲染时,一旦确定就"转换"成 DocsRender

实际源码中,这一分工落在 Render.ts 定义的 Render 接口上,其注释解释得很清楚:

一个 "Render" 表示把单个 entry 渲染到单个位置。实现类用于两个关键目的:

  • 追踪渲染在 preparing / rendering / tearing down 之间的状态迁移;
  • 追踪"渲染了什么",以便判断一次变更需要重新渲染,还是需要 teardown 后重建。

接口要求每个 Render 提供 renderIdtype'story' | 'docs')、isPreparing()isEqual(other)teardown()renderToElement()。三个实现类分别位于 render/StoryRender.tsrender/CsfDocsRender.tsrender/MdxDocsRender.ts。"故事 → 文档"的转换发生在 PreviewWithSelection.renderSelection():先 await render.prepare()prepare 阶段才知道 entry 是 story 还是 docs),随后依据 entry.type 与是否为 MDX entry 选择 StoryRender / CsfDocsRender / MdxDocsRender(见 PreviewWithSelection.tsx)。

PreviewWeb 层的"接收事件并决定渲染什么"由 PreviewWithSelection.tsxPreview.tsxsetupListeners() 共同完成,注册的事件包括:

Channel 事件 处理函数 作用
SET_CURRENT_STORY onSetCurrentStory 更新选择并重新渲染(见第六节)
UPDATE_QUERY_PARAMS onUpdateQueryParams 同步查询参数到选择存储
PRELOAD_ENTRIES onPreloadStories 预加载指定 id 的故事(Promise.allSettled 容忍失败)
NAVIGATE_URL onNavigateUrl 处理页内 #hash 跳转,如 docs 搜索定位
STORY_INDEX_INVALIDATED onStoryIndexChanged 重新拉取索引并热更新
UPDATE_GLOBALS / UPDATE_STORY_ARGS onUpdateGlobals / onUpdateArgs 全局/参数变更后批量 rerender()
FORCE_RE_RENDER / FORCE_REMOUNT onForceReRender / onForceRemount 强制重渲染/重挂载(见第五节)
STORY_HOT_UPDATED onStoryHotUpdated HMR 时取消所有正在播放的 play 函数

此外,PreviewWithSelection 还接管了键盘事件:globalWindow.onkeydown = this.onKeydown.bind(this),在焦点不在输入框且没有 story 禁用键监听时,把按键事件转发为 PREVIEW_KEYDOWN 发给 Manager(供箭头键切换故事等交互使用)。

四、渲染阶段状态机:preparing → loading → rendering → playing → completed

README 列出了一个故事渲染要经历的五个阶段与两个错误状态:

  • preparing ——(可能异步地)导入故事文件并准备故事函数;
  • loading —— 异步 loaders 正在运行;
  • rendering —— 框架的 renderToCanvas 正在运行;
  • playing —— play 函数正在运行;
  • completed —— 故事完成;
  • aborted —— 故事中途被停止(见下节);
  • errored —— 过程中某处抛出了错误。

当前 StoryRender.ts 中的 RenderPhase 类型比 README 更细,是在原五阶段基础上扩充而来的超集:

export type RenderPhase =
  | 'preparing'
  | 'loading'
  | 'beforeEach'
  | 'rendering'
  | 'playing'
  | 'played'
  | 'completing'
  | 'completed'
  | 'afterEach'
  | 'finished'
  | 'aborted'
  | 'errored';

新增的 beforeEach / afterEach 对应 beforeEach / afterEach 钩子阶段,played / completing / finished 则区分了 play 结束、等待动画收尾(waitForAnimations)与最终 STORY_FINISHED 事件发射。原 README 的阶段划分依然是主干,扩展阶段是围绕测试能力(交互测试、钩子)加进去的。

阶段推进的核心是 runPhase()

private async runPhase(signal: AbortSignal, phase: RenderPhase, phaseFn?: () => Promise<void>) {
  this.phase = phase;
  this.channel.emit(STORY_RENDER_PHASE_CHANGED, {
    newPhase: this.phase,
    renderId: this.renderId,
    storyId: this.id,
  });
  if (phaseFn) {
    await phaseFn();
    this.checkIfAborted(signal);
  }
}

每进入一个阶段都会向 channel 广播 STORY_RENDER_PHASE_CHANGED(携带 renderId,Manager 侧的加载指示器、vitest 测试 runner 都依赖它判断"当前渲染到哪一步"),阶段函数执行完毕后再用 AbortSignal 检查是否需要被中止;checkIfAborted() 在信号已中止且当前不在终态时,把 phase 改写为 aborted 并再次广播。

各阶段对应的具体动作(见 StoryRender.render()):

  • preparingthis.store.loadStory({ storyId }),即导入 CSF 文件、应用注解、组装出 PreparedStory;若 prepare 期间被 abort,则执行 store.cleanupStory() 并抛出 PREPARE_ABORTED(该哨兵错误定义在 Render.ts);
  • loadingcontext.loaded = await applyLoaders(context),执行 meta/story 上的 loaders
  • rendering:默认走 context.mount() —— 它调用 story.mount(context)(...args),即各 renderer 暴露的挂载函数,内部最终调用项目的 renderToCanvasmount 也可以在 play 函数中解构使用,此时 rendering 阶段延迟到 play 内调用 mount() 时才进入(见 isMountDestructured 分支);
  • playing:当 renderOptions.autoplay 为真且存在 playFunction 时执行,运行期间临时禁用键监听(disableKeyListeners = true),并监听 windowerror / unhandledrejection 以收集未处理错误;play 结束后进入 playederrored,若未挂载任何故事则抛 NoStoryMountedError
  • completed:发射 STORY_RENDERED 事件——这就是 addon 侧"故事已渲染完成"的信号。

五、重新渲染与中止:UPDATE_STORY_ARGS、UPDATE_GLOBALS、FORCE_RE_RENDER、FORCE_REMOUNT

README 的"Re-rendering and aborting"一节给出了事件与渲染阶段交互的决策规则,逐条对照源码如下。

输入类事件:UPDATE_STORY_ARGS / UPDATE_GLOBALS 基类 Preview.tsx 中:

  • onUpdateGlobals 更新 userGlobals 后,对 this.storyRenders 全量 Promise.all(...rerender())
  • onUpdateArgs 先更新 args 存储,再对匹配 storyId 的渲染实例执行 r.story.usesMount ? r.remount() : r.rerender()(注释解释:只跑 play 函数且带 force remount;当 mount 被解构使用时,渲染发生在 play 函数内部,所以走 remount)。

rerender() 的实现正是 README 规则中"rendering 前留待新 args 被渲染阶段拾取"的编码:

async rerender() {
  if (this.isPending() && this.phase !== 'playing') {
    this.rerenderEnqueued = true;   // loading/beforeEach/rendering/afterEach:排队,当前轮结束后再渲染
  } else {
    return this.render();          // 其余状态:直接用上一轮 loaders 的结果在其上重新渲染
  }
}
  • 若故事处于 preparingloading(广义 pending 且非 playing),不立即重渲染,而是置 rerenderEnqueued = truerender() 末尾会检查该标志并"清空队列再渲染一次",新 args/globals 自然被本轮渲染拾取——对应 README 的第一条规则;
  • 否则(含 playing 阶段),直接用上一次 loaders 的结果在上方覆盖重渲染——对应 README 的第二条规则。注释也明确:playing 期间不排队、立即执行,是为了支持"play 运行中 args 变更的实时渲染"。

FORCE_RE_RENDER(无参)。 onForceReRender() 对所有 storyRenders 执行 rerender(),即"无变化地重新渲染",行为规则同上。

FORCE_REMOUNT(携带 storyId)。 onForceRemount() 对匹配实例调用 remount()

async remount() {
  await this.teardown();
  return this.render({ forceRemount: true });
}

对应 README:"重新挂载组件(或等价物)并重新渲染"。其渲染中的两条规则也都能在源码中找到:

  • render({ forceRemount: true }) 开头:this.cancelRender(); this.abortController = new AbortController(); —— 先取消旧渲染(abort 前一次 render),再开新渲染。这即"如果正在 rendering,开始新渲染并随即中止上一次渲染";源码注释也坦承不校验取消是否真正生效,"前一次渲染理论上可能仍在跑"。
  • cancelPlayFunction():仅当 phase === 'playing'abort() 并发射 aborted 阶段事件,即"如果正在 playing,尝试中止上一个 play 函数";onStoryHotUpdated()(HMR 事件)就是对所有渲染实例调用 cancelPlayFunction(),这正是 HMR 时正在跑的 play 函数被停止的机制。

abort 的可靠性边界。 StoryRender 的注释(teardown() 上方)说明:abort 是"尽快停止 loaders/play 函数"的手段,但不能控制用户代码内部的行为,因此"并不万无一失"——由此引出下一节的窗口重载兜底。

六、切换故事:SET_CURRENT_STORY 的三条检查与兜底重载

README 的"Changing story"一节规定,收到 SET_CURRENT_STORY 后需要检查三件事:

  1. storyId 是否变化;
  2. viewMode 是否变化;
  3. 故事实现是否变化(例如发生了 HMR)。

上一个故事还在 preparing,无法判断实现是否变化,于是立即中止它的 preparing,让新故事接管。对应 PreviewWithSelection.renderSelection()

// If the last render is still preparing, let's drop it right now. Either
//   (a) it is a different story, which means we would drop it later, OR
//   (b) it is the *same* story, ... we should just "take over" the rendering.
if (this.currentRender?.isPreparing()) {
  await this.teardownRender(this.currentRender);
}

三项检查的实现同样在这里:storyIdChanged = this.currentSelection?.storyId !== storyIdviewModeChanged = this.currentRender?.type !== entry.type;实现是否变化则由 render.isEqual(lastRender) 判断——StoryRender.isEqual 比较的是 id 相同且 this.story === other.storyPreparedStory 对象引用相等),HMR 会生成新的 prepared 对象,引用不同即"实现变了"。

  • 三者都没变:STORY_UNCHANGED 事件 + view.showMain(),什么都不做("Do nothing");
  • 有变化且旧渲染未完成:await this.teardownRender(lastRender, { viewModeChanged })

兜底重载。 StoryRender.teardown() 的末尾(见 StoryRender.ts):

// If the story is torn down ... we use the controller as a method to abort them, ASAP,
// but this is not foolproof as we cannot control what happens inside the user's code.
...
for (let i = 0; i < 3; i += 1) {
  if (!this.isPending()) { await this.teardownRender(); return; }
  await new Promise((resolve) => setTimeout(resolve, 0));
}
// If we still haven't completed, reload the page (iframe) to ensure we have a clean slate
window?.location?.reload?.();
await new Promise(() => {});  // 等待重载,此 promise 永不 resolve

即:最多等待几个事件循环 tick 让旧渲染响应 abort;若 play 函数对 abort 无响应(README 括号里"e.g. the play function doesn't respond to the abort event"),最终 window.location.reload() 刷新整个 preview iframe,用一个永不 resolve 的 promise 挂起后续代码(该 promise 会随页面销毁)。这解释了实践中偶发的"切故事时页面闪一下重载"现象。

此外,PreviewWithSelection 还会在真正切换时发射 STORY_CHANGED,在 story 渲染准备好后发射 STORY_PREPARED(携带 parameters/initialArgs/argTypes 等)与 GLOBALS_UPDATED,docs 渲染则发射 DOCS_PREPARED,这些都是 Manager 侧同步状态的事件来源。

七、Docs 模式:从 story 到 DocsRender 的"转换"

README 说"如果故事以 docs 模式渲染,一旦确定就转换为 DocsRender"。在 CsfDocsRender.ts 中可以看到这一转换的具体内容:

  • prepare() 通过 store.loadEntry(this.id) 加载 entry,取主 CSF 文件的第一个故事作为 context 上的"当前故事"(注释说明这是为了模板后向兼容),并把该 entry 关联的所有 CSF 文件收集到 this.csfFiles
  • docsContext() 创建 DocsContext,把所有关联 CSF 文件 attachCSFFile 进去(两个引用同一 title 的 CSF 文件会合并为一个带 storiesImport 的 docs entry),并依据是否 MDX entry 设置 filterByAutodocs(autodocs 页面挑选 <Primary /> 故事时只保留带 autodocs 标签的故事);
  • 渲染 docs 页内嵌的 story 时,走基类 Preview.renderStoryToElement(story, element, callbacks, options):它创建一个 viewMode: 'docs'StoryRender短路 prepare 阶段(构造时直接传入已 prepared 的 story,见 StoryRender 构造函数中的 if (story) { ... this.phase = 'preparing'; } 分支)。

MDX entry 则由 MdxDocsRender 处理,PreviewWithSelection.onUpdateGlobals 中也体现了两者的对等地位:globals 更新时若当前渲染是 MdxDocsRenderCsfDocsRender,就调用 currentRender.rerender()

八、URL Store:把选择持久化到地址栏

README 职责第一条"通过 URL Store 读取和更新 URL",当前实现是 UrlStore.ts,它是 SelectionStore 接口(定义于 SelectionStore.ts)的 Web 实现,接口本身只要求 selectionSpecifier / selection / setSelection / setQueryParams 四项,使"选择"与"存储介质"解耦(非 Web 环境可用内存实现)。

两个关键函数:

  • setPath(selection)picoquery{ id: storyId, viewMode } 拼进查询串(保留其余参数),history.replaceState 更新地址栏,并同步 document.title = storyId
  • getSelectionSpecifierFromPath() 解析 ?id=?viewMode=,以及遗留的 manager 风格 ?path=/<viewMode>/<storyId>PATH_REGEX = /^\/(story|docs)\/(.+)/)。文件内注释明确标注:?path= 仅为兼容,Preview 的选择"应只使用 ?id= / ?viewMode=",setPath 也已经只写 id/viewMode 形式(TODO 注明将在 SB11 移除)。argsglobals 查询参数还会经 parseArgsParam 解析,用于深链接携带初始参数。

URL 是"选择"的持久化层:刷新页面后 PreviewWithSelection.selectSpecifiedStory()selectionStore.selectionSpecifier 取回 storySpecifier,在故事索引中定位 entry,然后走与 SET_CURRENT_STORY 相同的渲染路径;找不到时发射 STORY_MISSING,索引为空时渲染 EmptyIndexError

九、WebView:视图层如何配合渲染阶段

职责三("渲染到 web view")由 WebView.ts 实现,它以 body 上的 CSS class 切换五种显示模式:sb-show-main / sb-show-nopreview / sb-show-preparing-story / sb-show-preparing-docs / sb-show-errordisplay。与本文主题直接相关的细节:

  • showPreparingStory({ immediate }) 有 100ms 的 PREPARING_DELAY 延迟——快速切换时避免 spinner 闪烁;视图模式变化(story↔docs)时传 immediate: true 立即显示,与 renderSelection()this.view.showPreparingStory({ immediate: viewModeChanged }) 的调用点一一对应;
  • prepareForStory() 返回 #storybook-root 元素并应用 story 的 layout(padded/centered/fullscreen)与 htmlLang 参数;prepareForDocs() 返回 #storybook-docs,并在 storyId/viewMode 变化时才重置滚动位置(避免 docs 页 HMR 时跳回顶部);
  • 错误展示经 ansi-to-html 转义后写入 #error-message / #error-stack,对应 renderSelection()renderStoryLoadingException / renderError / renderException 三个错误出口(分别对应加载失败、用户错误如 story 返回类型不对、渲染期未捕获异常)。

十、测试佐证

上述机制在 code/core/src/preview-api/modules/preview-web/ 目录下有直接的单元测试与集成测试覆盖:

小结

@storybook/preview-web 的设计可以浓缩为一句话:PreviewWeb 管"选什么",StoryRender/DocsRender 管"渲染到哪一步",WebView 管"页面上显示什么",三者以 Channel 事件为总线协作。README 中五个渲染阶段 + 两个错误状态在今天的源码中已扩展为十二个 RenderPhase,但 runPhase + AbortSignal 的中断模型没有变:args/globals 变更在 pending 阶段排队、在 playing 阶段立即重渲染;HMR 与强制重挂载通过 AbortController 中止旧渲染;而当用户代码不响应 abort 时,teardown() 以 iframe 重载作为最终一致性兜底。理解这套机制,对于排查"play 函数跑一半被打断""切换故事时页面重载""STORY_RENDERED 事件时机"等 Preview 相关问题,都能直接定位到对应源码。

登录后查看全文
热门项目推荐
相关项目推荐