news 2026/9/7 18:06:16

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Storybook Preview (Web) 内部机制:选择、渲染阶段状态机与中断恢复原理

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

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

本文基于 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 监听)则由基类 Preview 的setupListeners()完成。

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

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

2.1 importFn:异步 import()

importFnModuleImportFn类型,即模块化的动态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/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.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_STORYonSetCurrentStory更新选择并重新渲染(见第六节)
UPDATE_QUERY_PARAMSonUpdateQueryParams同步查询参数到选择存储
PRELOAD_ENTRIESonPreloadStories预加载指定 id 的故事(Promise.allSettled容忍失败)
NAVIGATE_URLonNavigateUrl处理页内#hash跳转,如 docs 搜索定位
STORY_INDEX_INVALIDATEDonStoryIndexChanged重新拉取索引并热更新
UPDATE_GLOBALS/UPDATE_STORY_ARGSonUpdateGlobals/onUpdateArgs全局/参数变更后批量rerender()
FORCE_RE_RENDER/FORCE_REMOUNTonForceReRender/onForceRemount强制重渲染/重挂载(见第五节)
STORY_HOT_UPDATEDonStoryHotUpdatedHMR 时取消所有正在播放的 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/目录下有直接的单元测试与集成测试覆盖:

  • 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 相关问题,都能直接定位到对应源码。

【免费下载链接】storybookStorybook is the industry standard workshop for building, documenting, and testing UI components in isolation项目地址: https://gitcode.com/GitHub_Trending/st/storybook

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 18:05:49

蓝牙音箱PCBA开发周期:揭秘“7天出样”背后的三大隐形耗时坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 18:03:30

Oracle表闪回(Flashback Table)原理、实战操作与常见错误排查

先唠个嗑。干过几年Oracle DBA的&#xff0c;谁手里还没几桩“手滑惨案”&#xff1f;UPDATE忘记带WHERE、 DELETE删错了条件、 TRUNCATE完发现要的是另一张表——那一瞬间的心跳骤停&#xff0c;我太熟了。别问我是怎么知道的&#xff0c;问就是曾在大半夜用表闪回救过一个差点…

作者头像 李华
网站建设 2026/9/7 18:01:06

12V逆变器开机报故障?从工作原理到元件级排查与修复全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 18:00:44

单片机毕设项目:基于 STM32 的室内空气质量预警及蓝牙管控系统设计 基于 STM32 的多源环境数据监测与阈值联动系统设计(010307)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华