Storybook Preview (Web) 内部机制:选择、渲染阶段状态机与中断恢复原理
本文基于 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 中被概括为三点:
- 读取和更新 URL(经由 URL Store)——即把地址栏里的
?id=/?viewMode=等查询参数解析成"当前选中了哪个故事",并在选择变化时写回地址栏; - 监听 Channel 上的指令,并在事情发生时发射事件——Channel 是 Manager 与 Preview iframe 之间的消息总线;
- 把当前选择渲染到 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 监听)则由基类 Preview 的 setupListeners() 完成。
二、初始化:importFn、getProjectAnnotations 与故事索引
README 中"Initialization"一节列出的三个要点,与源码的对应关系如下。
2.1 importFn:异步 import()
importFn 是 ModuleImportFn 类型,即模块化的动态 import() 函数。Preview 本身不直接 import 故事文件,而是把导入能力交给构建方(Vite/Webpack builder 会提供一个带缓存、带 HMR 感知能力的导入器)。它被一路传入 StoryStore,最终在 StoryRender.prepare() 中经 this.store.loadStory({ storyId }) 使用(见 StoryRender.ts 中 prepare() 的实现,见下文第四节)。
2.2 getProjectAnnotations:评估 preview.js 与 addon 配置
getProjectAnnotations 是一个评估 preview.js(项目级 annotations)与各 addon 配置文件并合并它们的函数;如果出错,Preview 会把错误显示出来。源码中的实现在 Preview.tsx 的 getProjectAnnotationsOrRenderError():
- 先用
composeProjectAnnotationsWithCore把 core annotations 折叠进用户 annotations(注释说明这是为了让 core 贡献的beforeAll钩子——例如注册core/docgen、core/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 提供 renderId、type('story' | 'docs')、isPreparing()、isEqual(other)、teardown() 与 renderToElement()。三个实现类分别位于 render/StoryRender.ts、render/CsfDocsRender.ts 和 render/MdxDocsRender.ts。"故事 → 文档"的转换发生在 PreviewWithSelection.renderSelection():先 await render.prepare()(prepare 阶段才知道 entry 是 story 还是 docs),随后依据 entry.type 与是否为 MDX entry 选择 StoryRender / CsfDocsRender / MdxDocsRender(见 PreviewWithSelection.tsx)。
PreviewWeb 层的"接收事件并决定渲染什么"由 PreviewWithSelection.tsx 与 Preview.tsx 的 setupListeners() 共同完成,注册的事件包括:
| 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()):
- preparing:
this.store.loadStory({ storyId }),即导入 CSF 文件、应用注解、组装出PreparedStory;若 prepare 期间被 abort,则执行store.cleanupStory()并抛出PREPARE_ABORTED(该哨兵错误定义在 Render.ts); - loading:
context.loaded = await applyLoaders(context),执行 meta/story 上的loaders; - rendering:默认走
context.mount()—— 它调用story.mount(context)(...args),即各 renderer 暴露的挂载函数,内部最终调用项目的renderToCanvas;mount也可以在 play 函数中解构使用,此时 rendering 阶段延迟到 play 内调用mount()时才进入(见isMountDestructured分支); - playing:当
renderOptions.autoplay为真且存在playFunction时执行,运行期间临时禁用键监听(disableKeyListeners = true),并监听window的error/unhandledrejection以收集未处理错误;play 结束后进入played或errored,若未挂载任何故事则抛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 的结果在其上重新渲染
}
}
- 若故事处于
preparing或loading(广义 pending 且非 playing),不立即重渲染,而是置rerenderEnqueued = true;render()末尾会检查该标志并"清空队列再渲染一次",新 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 后需要检查三件事:
storyId是否变化;viewMode是否变化;- 故事实现是否变化(例如发生了 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 !== storyId;viewModeChanged = this.currentRender?.type !== entry.type;实现是否变化则由 render.isEqual(lastRender) 判断——StoryRender.isEqual 比较的是 id 相同且 this.story === other.story(PreparedStory 对象引用相等),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 更新时若当前渲染是 MdxDocsRender 或 CsfDocsRender,就调用 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 移除)。args与globals查询参数还会经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/ 目录下有直接的单元测试与集成测试覆盖:
- PreviewWeb.test.ts 与 PreviewWeb.integration.test.ts:覆盖选择、
SET_CURRENT_STORY处理、HMR 变更后的重渲染路径; - render/StoryRender.test.ts:覆盖阶段迁移、abort 行为与事件发射;
- UrlStore.test.ts:覆盖 id/viewMode/path 三种 URL 形态的解析;
- render/CsfDocsRender.test.ts 与 render/MdxDocsRender.test.ts:覆盖 docs 模式准备与渲染。
小结
@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 相关问题,都能直接定位到对应源码。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0626
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00