ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

transition_prepare_flow:让页面切换告别白屏与等待的完整方案

transition_prepare_flow:让页面切换告别白屏与等待的完整方案 项目标题是transition_prepare_flow刚看到这个名字时我第一反应是这不就是我在好几个项目里反复手写、又反复删掉、最后又老老实实加回来的那套东西么。如果你也遇到过页面切来切去总是白屏一闪、弹窗打开时内容慢半拍、Tab 切换后布局突然跳一下那大概率不是动画库写得不好而是你在动画开始之前少了一个正经八百的准备流程。这篇文章我想把这套流程的完整设计思路、核心实现、踩坑记录和排查套路一次讲透。不吹不黑这是一篇偏实战的经验帖适合正在做前端动效、SPA 路由转场、弹窗系统、或者想在复杂交互里把等待感做掉的同学。看不懂代码也没关系核心思路我会尽量用大白话讲清楚。1. transition_prepare_flow 到底解决什么问题1.1 从一个真实场景说起页面切换为什么会卡顿、闪白先说一个我真实遇到的线上问题。我们当时做的是一款移动端资讯类应用列表页切详情页时产品要求做一个 300ms 的平滑左滑转场。视觉稿看着很美代码写起来也简单无非就是两个视图容器一个滑入一个滑出。但实际上线后用户反馈最多的就是切页面的时候会闪一下白底有时候图片加载到一半就滑进来了特别难看。我一开始以为是动画本身的问题调了半天 ease 曲线、过渡时长甚至换了好几种动画实现方案白闪还是有。后来一步步排查才发现真正的罪魁祸首是动画开始时目标页面压根还没准备好。图片在等网络、数据在等接口、字体还没加载、目标页面的 DOM 布局还没稳定下来。动画只是把什么都没准备好的页面推到了用户眼前。那一刻我意识到一个问题转场动画做得再顺滑它也只是整条流程的最后一公里。真正决定体验好坏的是动画开始之前的那个准备阶段。这也是transition_prepare_flow这套流程的核心出发点把准备动作显式地抽出来做成一个可控、可追踪、可超时、可取消的独立流程而不是把一堆事儿混在动画回调里碰运气。1.2 prepare、transition、settle 三个阶段各管什么这套流程我习惯拆成三段prepare准备、transition过渡、settle稳定。prepare 阶段负责的是把要展示的东西提前弄好。包括但不限于图片预加载、关键接口数据预取、字体文件加载、组件懒加载、DOM 测量、布局锁定。这个阶段的特点是耗时不可控可能 50ms也可能 500ms所以它必须有明确的完成信号和超时兜底。transition 阶段就是我们熟悉的动画阶段。它的前提是 prepare 已经成功此时页面该有的资源都在了可以放心地做位移、透明度、缩放这些视觉变化。这个阶段的重点是别在动画过程中去碰数据加载这类高成本操作保持主线程尽量轻松。settle 阶段是很多人容易忽略的。动画结束不等于事情做完了你还需要移除临时监听器、清掉加载占位、通知埋点系统、把被动画改变过的样式还原。我见过太多项目只做前两步结果动画结束之后页面上还留着 loading 图、滚动位置不对、点击事件重复绑定都是 settle 没做干净。1.3 为什么非要抽出一个prepare阶段可能有人会问我直接在每个页面组件里 onLoad 去加载数据不就完了干嘛要单独搞一个 prepare 流程区别在于分散和统一。页面自己加载数据是每个页面各干各的谁也管不了谁。如果你的动画是全局路由控制的那问题就来了动画不等数据数据也不等动画两者各跑各的最终呈现给用户的就是页面已经滑进来了但内容还在转圈。把 prepare 抽出来本质上是把页面准备好了没有这个判断从各自页面收归到全局流程控制。你在路由切换前调用transition_prepare_flow.prepare()它会去执行一组注册好的准备任务全部完成后才放行动画。这样配合关系就从各干各的变成先准备后表演体验自然可控得多。另外一个更实际的原因是排查问题方便。分散加载的时候一次白屏事故要查好几个页面每个页面的加载逻辑还不一样。统一成 prepare 流程后每个任务有名字、有超时、有成功失败状态问题出在哪一环打点日志里一目了然。2. 核心细节解析与实操要点prepare 阶段最容易翻车的几个细节2.1 资源预加载图片、字体、贴图一个都不能少先说图片。图片是转场体验里最大的变量因为网络加载时间完全不可控。我的做法是在 prepare 里用Image对象做预加载注意一定要提前把src赋上去否则浏览器不知道你要加载它。function preloadImage(url) { return new Promise((resolve, reject) { const img new Image(); img.onload () resolve(url); img.onerror () reject(new Error(图片加载失败: ${url})); img.src url; }); }这段代码看起来简单但有几个细节不注意就会踩坑。第一img.onload和img.src的赋值顺序不能反。如果你先赋src此时onload还没绑定图片可能在缓存命中的瞬间就已经加载完了后续的onload永远等不到。这个坑我踩过不止一次后来养成了先绑事件后赋地址的习惯。字体和图片不同字体不能靠Image对象预加载得用FontFaceAPI 或者直接把字体文件塞进link relpreload。字体加载失败通常不会报错而是静默降级所以如果转场动画里有大字号标题务必把字体也列入准备清单。还有 WebGL 场景里常用的贴图纹理很多人把它当成普通图片处理其实贴图往往还要经过解码、上传 GPU 的过程prepare 阶段真正的完成时机应该是纹理上传完毕而不是img.onload触发的那一刻。2.2 数据预取别把请求放在切换动画里数据预取的第一个原则是能提前就提前不要拖到切换触发那一刻才发请求。比如列表页还在浏览时就可以预测用户最可能点击的下一篇内容提前作者请求到本地缓存。这是体验优化里很有效的策略但它要求你有一套可靠的缓存失效机制否则用户打开详情页看到的可能不是最新数据。如果确实只能在切换时请求那就必须注意请求时序问题。我要提醒你的是竞态假设用户快速点击了 A、B、C 三个详情页三个请求几乎同时发出返回顺序可不一定按 A、B、C 来。如果程序只认最后一个响应的结果那页面可能显示 C 的数据但当前路由已经切到 B 了。这其实是cannot read properties of undefined (reading prepare)这类问题的高发场景之一后面我会详细讲。准备阶段发请求还有一个容易被忽视的点请求失败怎么办绝对不能让网络错误影响转场。我的建议是给数据预取任务设置失败降级策略数据加载失败不阻塞整个 prepare 流程页面正常切换内容区域展示重试入口或缓存兜底。只有这样动画才不会被一个超时接口卡死。2.3 DOM 测量与布局锁定防止过渡时元素乱跑第三种准备工作是 DOM 测量。比如你要做一个从卡片点击位置放大到全屏的转场效果动画的起点坐标、初始尺寸必须在 prepare 阶段测准。这个看似简单实际上非常坑因为真实项目里会有列表复用、图片懒加载、字体加载影响盒模型等一堆变量。我踩过最典型的坑是图片没加载完时卡片高度是撑不开的这时候测量到的矩形只有图片的占位高度图片加载完卡片塌陷了原来测好的坐标和大小全是错的转场动画就是从一个错误的起点开始。解决思路是prepare 阶段先确保关键资源加载完成再统一执行 DOM 测量测量完顺手把元素的状态锁住。锁住的方式可以是给目标元素加上一个固定的宽高、transform-origin或will-change: transform避免后续内容变化导致布局重新计算。但注意锁布局只是权宜之计settle 阶段一定要把锁释放掉否则页面在窄屏设备上可能会被锁出滚动条或布局错乱。我见过有同事加了will-change忘了删高德纳的 GPU 内存直接被打满。2.4 超时、取消与幂等prepare 流程的兜底设计prepare 阶段最理想的情况是全部成功但真实环境里总有接口超时、资源 404、用户中途返回这些破事。所以流程必须内置三样东西超时、取消、幂等。超时好理解每个任务设定一个最大耗时比如 3s。到了时间不管任务有没有完成都按超时失败处理。但我要特别提醒超时不等于把任务终止只是流程不再等它。那个任务后续可能还是会跑完所以任务内部收到超时信号后要自行决定是否中断。比如网络请求可以用AbortController取消图片加载则没有太好的取消手段只能忽略它的回调。function withTimeout(promise, ms, taskName) { let timer; const timeoutPromise new Promise((_, reject) { timer setTimeout(() { reject(new Error(${taskName} 准备超时: ${ms}ms)); }, ms); }); return Promise.race([promise, timeoutPromise]).finally(() { clearTimeout(timer); }); }取消能力就是当用户已经在等准备完成时突然又按了返回键或点了其他 Tab这时候整套流程应该能被打断。一个简单的做法是维护一个AbortSignal对象每次切换就是一次新的会话会话被废弃时所有任务检测到 signal 就会主动中断不再往后执行。幂等性这个概念可能有人不熟我举个例子用户从 A 切到 BB 正在准备还没完成又切回 A再切到 B。第二次切到 B 的时候B 的 prepare 会被再次触发。如果不做幂等处理同一个接口可能被发两次同一个资源被加载两遍。处理方式是任务级别的结果缓存同一会话里相同 key 的任务后一次直接复用前一次的结果。这个细节加上之后整个流程的稳定性会有非常明显的提升。3. 实操过程与核心环节实现一个可直接复用的 transition_prepare_flow3.1 整体流程设计与状态机我建议把整个流程设计成五个状态idle空闲、preparing准备中、prepared准备完成、transitioning过渡中、settled已稳定。每次页面切换都会从idle走一遍完整状态流。用状态机的好处是流程里的每个动作都有明确的触发条件和退出条件出问题也能快速定位是哪个状态没走对。比如你会遇到一种情况动画都播完了页面还停在preparing说明状态流转的某个环节根本没执行。这种问题如果没状态机只能靠打日志猜很被动。流程设计上我推荐把任务注册机制做成插件式的。开发者向流程实例注册一个带名字的 prepare 任务由流程实例统一调度。这样各模块只管往里面加任务不用关心整体调度。3.2 核心代码实现给一套能直接用的骨架下面这套实现是我的基准版去掉了我项目里所有的业务耦合只保留流程本身。它基于 TypeScript跑在浏览器环境。type TaskStatus pending | running | success | failed | timeout | cancelled; interface PrepareTask { name: string; executor: (ctx: FlowContext) Promisevoid; timeout?: number; retry?: number; } interface FlowOptions { defaultTimeout?: number; onError?: (taskName: string, error: Error) void; } class FlowContext { signal: AbortSignal; cache new Mapstring, unknown(); constructor(signal: AbortSignal) { this.signal signal; } } class TransitionPrepareFlow { private tasks: PrepareTask[] []; private flowState: idle | preparing | prepared | transitioning | settled idle; private abortController: AbortController | null null; constructor(private options: FlowOptions {}) {} register(task: PrepareTask) { this.tasks.push(task); } async prepare() { if (this.flowState ! idle) { throw new Error(prepare 不能被重复调用当前状态: ${this.flowState}); } this.flowState preparing; this.abortController new AbortController(); const ctx new FlowContext(this.abortController.signal); const results await Promise.allSettled(this.tasks.map(task { return this.runTaskWithRetry(task, ctx); })); const failed results.filter(r r.status rejected); if (failed.length 0) { this.flowState idle; throw new Error(共有 ${failed.length} 个准备任务失败); } this.flowState prepared; } private async runTaskWithRetry(task: PrepareTask, ctx: FlowContext) { const maxRetry task.retry ?? 0; let lastError: Error | null null; for (let attempt 0; attempt maxRetry; attempt) { if (ctx.signal.aborted) { throw new Error(${task.name} 被取消); } try { const safeExecutor this.withTimeout(task.executor, task.timeout ?? this.options.defaultTimeout ?? 3000, task.name); await safeExecutor(ctx); return; } catch (error) { lastError error as Error; this.options.onError?.(task.name, lastError); } } throw lastError; } private withTimeout(promiseFn: (ctx: FlowContext) Promisevoid, ms: number, name: string) { let timer: ReturnTypetypeof setTimeout | undefined; const timeoutPromise new Promisenever((_, reject) { timer setTimeout(() reject(new Error(${name} 超时 (${ms}ms))), ms); }); return Promise.race([promiseFn(/* ctx */), timeoutPromise]).finally(() { clearTimeout(timer); }); } async transition(animation: () Promisevoid | void) { if (this.flowState ! prepared) { throw new Error(transition 只能在 prepared 之后调用当前状态: ${this.flowState}); } this.flowState transitioning; await animation(); this.flowState settled; } cancel() { this.abortController?.abort(); this.flowState idle; } }这个骨架里有几个设计点是经过反复调优的我说一下。withTimeout使用了Promise.race这是很多人的第一反应但它有个隐患超时后原来的 executor 任务并没有被真正取消它还在后台跑。所以我让FlowContext携带一个AbortSignal任务内部如果做了 signal 监听才能在取消时及时中断。骨架里的runTaskWithRetry也检查了ctx.signal.aborted这让取消信号能传播得更远。Promise.allSettled而不是Promise.all也是刻意的。Promise.all遇到一个任务失败直接退出其他任务的结果被忽略这不太适用于准备流程。我更希望所有任务都尽力跑完最后统一看有多少个失败这样错误信息更完整。还有一个细节我没有在构造器里默认注册任何任务因为流程本身的定位是一个调度容器具体准备什么应该由业务决定。你完全可以在项目里注册几十个不同类型的任务只要每个任务有清晰的名字和合理的超时流程的表现就不会差。3.3 接入场景路由切换、Modal 弹窗、Tab 页签这套流程最常见的接入场景是路由切换。以 Vue Router 为例你可以把 prepare 逻辑放进路由的beforeEach守卫里。要注意的是beforeEach里next()要保证只调用一次而我们的 prepare 流程可能成功可能失败成功的继续走失败的用next(false)拦下来不比用户看到半残页面。router.beforeEach(async (to, from, next) { const flow createFlowForRoute(to); try { await flow.prepare(); next(); } catch (error) { console.warn([transition_prepare_flow] 准备失败已拦截路由, error); next(false); } });React 生态下没有直接的守卫但可以在封装的路由组件里用useEffect加一段 await 逻辑或者在数据请求库层面统一处理。我更推荐的方式是做一个useTransitionPrepared这样的 hook 包装把准备中状态暴露出来在transitioning之前先渲染一个极简的过渡占位层。Modal 弹窗是另一个高频场景。弹窗打开前通常需要拉取弹窗里的数据、加载里面的图表资源如果每次都开到一半转圈体验会很差。做法是把弹窗内容当成一个独立的 prepare 目标弹窗打开前先执行对应的任务组。这里要特别提醒弹窗关闭后的 settle 很关键因为弹窗通常复用一个容器上一次打开时注册的动画残留状态不清干净下一次打开就可能错位。Tab 页签切换相对简单但问题多出在懒加载上。很多 Tab 组件是内容真正激活时才渲染这样 prepare 根本派不上用场。我的建议是让 Tab 内容提前渲染、隐藏加载也就是用visibility: hidden配合display: block提前挂在页面上。用户切过来的时候内容早就准备好了动效只是锦上添花。3.4 关键参数配置与调优建议几个参数我聊聊自己的调优数值。默认超时我一般定在 3000ms这是一个平衡值。太短弱网环境下任务容易误判失败太长用户等待感会明显增强。数据类请求的超时建议和业务接口的超时设置对齐图片加载类任务可以放宽到 5000ms因为大图在弱网下是真的可能加载接近 5 秒。重试次数我建议最多 1 次。准备阶段的重试不是无限重试每重试一次可能增加几百毫秒等待两次失败的请求大概率第三次还是失败没必要再试。值得注意的是重试必须配合指数退避否则多任务同时重试可能会造成突发的高并发请求把后端打懵。退避策略可以简单实现为attempt * 300ms的延迟在线例子甚至可以直接在 executor 里 first wait。还有一个参数往往被忽略就是并发度。如果 prepare 任务里有 20 张图片同时预加载浏览器会有连接数限制超出的图片只能排队反而拖慢整体速度。更好的做法是给图片预加载任务强制一个 6 左右的并发上限八成不会阻塞其他任务的执行。4. 常见问题与排查技巧实录报错、白屏与更新失败的背后4.1 最常见报错cannot read properties of undefined (reading prepare)这个报错在热词里出现了也确实是准备流程里最容易撞见的运行时错误。它对应的场景几乎都是同一个你在代码里调用xxx.prepare()但这个xxx其实还是undefined。我来拆几个典型原因。第一种是流程实例还没有初始化在模块顶层或者构造函数之前被调用。比如一个全局单例业务代码直接transitionPrepareFlow.prepare()调用了但单例的初始化逻辑因为依赖用户权限、路由配置等异步数据而延后了此时变量还是初始的undefined。第二种是项目引入了微前端或动态模块模块加载顺序不确定你在主应用里调用了一个尚未加载完成的子应用暴露出来的方法。第三种也是最隐蔽的方法链过长例如pageManager.currentPage.transition.prepare()currentPage有值但transition这个子对象因为异步赋值还没挂上。排查这种问题我建议先改动一个思路把链式调用拆开一步一步看哪一步断了报错信息就是它的锅。const manager window.pageManager; console.log(manager:, manager); const current manager?.currentPage; console.log(current:, current); const transition current?.transition; console.log(transition:, transition); transition?.prepare();加可选链或者逐层打印是最快的定位法。另外一个治本的办法是建一个流程注册中心所有流程实例先注册后使用调用方拿到的是注册表里的引用永远不会是undefined。这套思路和依赖注入有点像但从工程上能避免一大类时序问题。4.2 更新场景failed to prepare an update: temp directory inside install这个报错其实来自安装包更新的场景不是纯前端会遇到的问题但它和我们聊的 prepare 流程特别像值得展开说。它的完整报错信息一般是 failed to prepare an update: temp directory inside install意思是准备更新的时候在安装目录里准备临时目录失败了。原因通常有四类第一是磁盘空间不足常见于设备存储快满的情况第二是权限问题当前用户没有在安装目录下创建临时目录的权限第三是临时目录残留了上亿次失败产生的锁文件导致进程互相冲突第四是杀毒软件或系统安全机制拦截了临时目录的写入操作。这个问题的处理思路和我们前端 prepare 的兜底策略如出一辙先清理残留、检查空间再重新准备。挪到前端 prepare 流程里就对应着前置检查和失败清理。我在自己项目里也养成了一个习惯prepare 任务一旦失败要把已创建的临时文件、锁住的资源、挂载的 DOM 辅助节点全部清掉绝不能留着影响下一次运行。这叫可恢复性是流程设计中容易被低估的一环。4.3 flow 贴图加载失败与资源路径问题热词里还有个flow 贴图我理解是过渡动画里用到的贴图纹理资源。这类资源大概率是 WebGL、Canvas 或 WebGPU 渲染时用的图片素材。贴图加载失败和普通图片加载失败的表现很不一样它往往不会直接抛错而是渲染一片紫色或马赛克纹理开发时极容易被误认为是渲染 Bug。贴图加载失败的常见原因之一是路径写错。很多人喜欢用相对路径但项目打包后资源的 publicPath 变化了相对路径就找不到了。另一个原因是 CORS贴图要跨域加载时必须配置crossOriginanonymous否则 Canvas 和 WebGL 会拒绝使用这个纹理。还有一个是格式兼容性WebGL 对图片格式支持有限某些 WebP 高压缩格式在旧设备上可能解不出来。维护一个贴图资源清单在 prepare 阶段挨个加载校验失败就及时降级为纯色或低清图这样的方案最稳妥。4.4 通用排查套路先看时序再看状态最后看资源排查任何 transition_prepare_flow 相关的问题我一般按三个层面推进。第一层永远先看时序。准备流程、动画流程、数据请求这三条时间线是不是按预期顺序走的画个时间轴草图把每次的开始结束标出来问题基本能看出七七八八。千万别一上来就钻代码细节时序图会帮你排除一半的灵异事件。第二层看状态。流程实例是不是停在某个状态出不来了比如永远preparing那说明有任务没完成或没超时比如跳到了idle可能被外部取消了。状态机的好处就在这里看一眼当前状态排查范围瞬间缩小。第三层才看资源和报错细节。具体是哪个任务失败了、失败是超时还是业务异常、重试了几次、有没有降级。把这层分析清楚问题往往已经解决了大半。这套排查套路我用了好几年处理过的故障上百起九成问题能在第二层定位。真正需要看源码的反而是少数没有埋点的遗留代码。写在最后的几条心得我实际把这套流程落地到项目之后最大的感受是白屏闪退几乎绝迹但更重要的不是视觉效果而是开发心态变了。以前每次动到转场代码都提心吊胆担心不同页面的资源加载时序互相纠缠现在有一个统一的流程控制每个页面只要往流程里注册自己的准备任务剩下的事儿交给调度器思路清爽很多。即便如此我还是想提醒你prepare 阶段不要做得太贪婪。有些人一尝到甜头就把能加载的全塞进 prepare图片、数据、埋点、字体、甚至其他页面的资源都提前拉。结果主页面首屏被一堆用不上的预加载拖到变慢得不偿失。准备流程要像上菜一样备好该备的别把整个厨房都搬上桌。凡事有度准备流程也一样。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进