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

发布时间:2026/9/7 18:06:20
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子包的官方 READMEREADME-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实现。从源码结构看入口类是 PreviewWebexport class PreviewWebTRenderer extends Renderer extends PreviewWithSelectionTRenderer { constructor( public importFn: ModuleImportFn, public getProjectAnnotations: () MaybePromiseProjectAnnotationsTRenderer ) { super(importFn, getProjectAnnotations, new UrlStore(), new WebView()); global.__STORYBOOK_PREVIEW__ this; } }构造函数只接收两个依赖——异步import()函数和getProjectAnnotations然后注入默认的UrlStore职责 1与WebView职责 3 的 DOM 视图层并把自己挂到global.__STORYBOOK_PREVIEW__上供集成方访问。职责 2Channel 监听则由基类 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.jsonREADME 原文说不再传入getStoryIndex函数而是 Preview 自己创建一个StoryIndexClient从 Node 端拉取stories.json并监听事件流中的 invalidation 事件。对照当前源码这段描述需要按演进后的理解来读getStoryIndexFromServer()通过fetch(STORY_INDEX_PATH)获取索引其中STORY_INDEX_PATH ./index.json即构建产物中的 index.jsonREADME 时代称为stories.json同时setupListeners()注册了channel.on(STORY_INDEX_INVALIDATED, this.onStoryIndexChanged)——这正是 README 所说的监听事件流中的 invalidation 事件。onStoryIndexChanged()会重新 fetch 索引若 store 已建立则走onStoriesChanged({ storyIndex })更新并触发当前选择的重新渲染。这条链路是 HMR 时故事列表增删能够热更新到画布上的底层依据。三、三层状态分工PreviewWeb / StoryRender / DocsRenderREADME 指出 Preview 被拆分为三个负责状态管理的部分PreviewWeb决定渲染哪个故事接收 channel 事件并视情况变更/重新渲染故事StoryRender导入并准备故事驱动它经历各个渲染阶段DocsRender当故事以 docs 模式渲染时一旦确定就转换成DocsRender。实际源码中这一分工落在 Render.ts 定义的Render接口上其注释解释得很清楚一个 Render 表示把单个 entry 渲染到单个位置。实现类用于两个关键目的追踪渲染在 preparing / rendering / tearing down 之间的状态迁移追踪渲染了什么以便判断一次变更需要重新渲染还是需要 teardown 后重建。接口要求每个 Render 提供renderId、typestory | 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 → completedREADME 列出了一个故事渲染要经历的五个阶段与两个错误状态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?: () Promisevoid) { 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携带renderIdManager 侧的加载指示器、vitest 测试 runner 都依赖它判断当前渲染到哪一步阶段函数执行完毕后再用AbortSignal检查是否需要被中止checkIfAborted()在信号已中止且当前不在终态时把 phase 改写为aborted并再次广播。各阶段对应的具体动作见StoryRender.render()preparingthis.store.loadStory({ storyId })即导入 CSF 文件、应用注解、组装出PreparedStory若 prepare 期间被 abort则执行store.cleanupStory()并抛出PREPARE_ABORTED该哨兵错误定义在 Render.tsloadingcontext.loaded await applyLoaders(context)执行 meta/story 上的loadersrendering默认走context.mount()—— 它调用story.mount(context)(...args)即各 renderer 暴露的挂载函数内部最终调用项目的renderToCanvasmount也可以在 play 函数中解构使用此时 rendering 阶段延迟到 play 内调用mount()时才进入见isMountDestructured分支playing当renderOptions.autoplay为真且存在playFunction时执行运行期间临时禁用键监听disableKeyListeners true并监听window的error/unhandledrejection以收集未处理错误play 结束后进入played或errored若未挂载任何故事则抛NoStoryMountedErrorcompleted发射STORY_RENDERED事件——这就是 addon 侧故事已渲染完成的信号。五、重新渲染与中止UPDATE_STORY_ARGS、UPDATE_GLOBALS、FORCE_RE_RENDER、FORCE_REMOUNTREADME 的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 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后需要检查三件事storyId是否变化viewMode是否变化故事实现是否变化例如发生了 HMR。若上一个故事还在preparing无法判断实现是否变化于是立即中止它的 preparing让新故事接管。对应 PreviewWithSelection.renderSelection()// If the last render is still preparing, lets 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 users 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 havent 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 doesnt respond to the abort event最终window.location.reload()刷新整个 preview iframe用一个永不 resolve 的 promise 挂起后续代码该 promise 会随页面销毁。这解释了实践中偶发的切故事时页面闪一下重载现象。此外PreviewWithSelection还会在真正切换时发射STORY_CHANGED在 story 渲染准备好后发射STORY_PREPARED携带 parameters/initialArgs/argTypes 等与GLOBALS_UPDATEDdocs 渲染则发射DOCS_PREPARED这些都是 Manager 侧同步状态的事件来源。七、Docs 模式从 story 到 DocsRender 的转换README 说如果故事以 docs 模式渲染一旦确定就转换为DocsRender。在 CsfDocsRender.ts 中可以看到这一转换的具体内容prepare()通过store.loadEntry(this.id)加载 entry取主 CSF 文件的第一个故事作为 context 上的当前故事注释说明这是为了模板后向兼容并把该 entry 关联的所有 CSF 文件收集到this.csfFilesdocsContext()创建DocsContext把所有关联 CSF 文件attachCSFFile进去两个引用同一 title 的 CSF 文件会合并为一个带storiesImport的 docs entry并依据是否 MDX entry 设置filterByAutodocsautodocs 页面挑选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/storyIdPATH_REGEX /^\/(story|docs)\/(.)/。文件内注释明确标注?path仅为兼容Preview 的选择应只使用?id/?viewModesetPath也已经只写 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 的layoutpadded/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但runPhaseAbortSignal的中断模型没有变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),仅供参考