先说一个我自己的真实经历之前做中后台系统时用户一直抱怨页面切换总是闪一下白屏数据明明在加载但界面已经跳到新页面了看起来特别廉价。那时我还没把问题想透直到后来负责一个多工作台切换的复杂项目才彻底决定做一个专门解决这类问题的模块——transition_prepare_flow。这个模块的核心思路并不复杂把切换动作和切换前的准备工作彻底解耦。新页面要展示不能像过去那样一换路由就立刻渲染必须先等必要的数据、资源、权限都准备完毕再执行过渡动画和渲染。我用准备——就绪——切换——完成这套状态流来约束整个过程从源头上消除了白屏闪烁、请求竞态、动画时序错乱这些问题。这篇文章把整个模块的设计思路、完整实现和线上踩坑经验都整理出来适合正在做中后台、移动端H5或者任何有复杂视图切换场景的朋友参考。它不是一个官方库而是我沉淀出来的工程实践后面还会配一套可运行的代码思路你可以按需改造。1. 为什么切换前先做准备这个流程值得做成一个模块1.1 旧方案下页面切换的三类典型问题很多前端项目的页面切换路径是万年不变的路由一变组件一挂loading一转。大多数情况下看着还行但一旦页面变重、业务变复杂三个问题就立刻浮出来白屏闪烁目标页面的首屏数据还没回来组件就已经替换挂载了。用户看到的是空荡荡的骨架屏或纯白界面等接口返回数据后界面才一下子蹦出来。这个体验在弱网下尤其要命。动画时序失控新页面的进入动画enter过渡触发时机如果没踩准会和旧页面的离开动画叠在一起界面像打了一顿乱拳一样抖动。Vue的Transition组件看似很好用但如果你在页面数据还没就绪时就让它进场动画结束之后内容才开始填充视觉上依然是先动后乱。并发请求互相覆盖用户快速点击菜单A、B、C三个页面的请求都发出去了。结果C页面的组件先渲染了返回的却是B接口的数据界面数据错乱。这种竞态问题非常隐蔽定位成本也和玄学差不多。这三个问题底层其实是同一个矛盾渲染动作本身是同步的、即时的但准备工作数据、资源、权限、埋点几乎全是异步的。如果让即时去等异步最直接的方案就是做一个预备队列把所有异步工作排进去全部完成后才算合格再放行渲染。1.2 一个生活化的类比别抢后厨的出餐信号我经常用餐厅出餐来理解这件事。过去的做法是服务员接到客人点菜立刻就去上菜——但菜其实还没做好于是桌上一直摆着空盘子后厨一边炒一边上客人看得心里拔凉。过渡到新设计后服务员会等到后厨每个灶台都喊出好了再统一端出去。这个好了的信号就是我要做的prepare完成态。transition_prepare_flow模块里有两个核心阶段prepare阶段异步加载目标页面的关键信息——接口数据、图片资源、权限确认、依赖的子模块代码。这个阶段内部是并行和可编排的。transition阶段真正执行渲染、触发路由切换动画、挂载组件。两个阶段由状态机约束顺序不能倒序、不能跳步。这也就是项目名里transition切换和prepare_flow准备流程的含义。2. prepareFlow的状态机设计六种状态如何约束一次切换的完整生命周期2.1 状态定义与流转规则状态设计是整个模块的地基。我落地时用了六个状态状态含义触发时机idle空闲没有正在进行的切换任务初始化完成或上一次流程被取消并清场后preparing准备中各类异步任务正在执行调用start()进入准备阶段ready准备完成数据与资源已就绪所有关键任务全部resolveswitching切换中路由/渲染已放行动画正在执行ready后调用transition()done完成切换动画结束新页面稳定渲染failed失败关键任务超时/报错且未开启降级流转规则我用代码写死避免从外部乱改const flowChart: RecordPrepareState, PrepareState[] { idle: [preparing], preparing: [ready, failed, idle], ready: [switching, idle], switching: [done, failed, idle], done: [idle], failed: [idle], }注意从任何非终态都可以回到idle——这对应取消场景。比如用户在准备过程中又点了另一个菜单上一次准备必须立刻退场不能让它继续输出副作用。2.2 为什么非要状态机而不是一个Promise加若干回调可能有人会觉得这不就是一个Promise.all再包一层then吗用状态机和裸Promise的区别在项目复杂以后会非常明显第一监听中间态。产品经常要求上报准备阶段耗时拆开成数据加载耗时资源预加载耗时。准备中这样一个具体状态可以让性能埋点很自然地挂在节点上。第二可取消性。裸Promise要么全部resolve要么全部reject想走到一半时被新的切换请求打断然后干净退场几乎不可能。状态机则允许从preparing/shitching强制转回idle同时触发内部清理逻辑比如AbortController中断在途请求。第三超时与降级策略可区分。有些任务失败允许降级进入页面另一些任务关键到失败就直接中断流程。状态机给了每个状态分支处理的机会而不是笼统地整批失败。2.3 数据结构任务注册表与元信息快照状态只是外壳核心数据结构有两块export interface FlowTaskT unknown { key: string handler: (ctx: TaskContext) PromiseT critical?: boolean timeout?: number } export interface TaskContext { signal: AbortSignal meta: Recordstring, unknown state: PrepareState }模块内部维护一个tasks数组和一个meta对象。任务注册时不立即执行而是放到注册表里等start()时统一消费。meta用来跨任务传递上下文——比如A任务把用户权限对象写入metaC任务直接从meta里取避免重复请求。3. 核心实现任务注册、并行控制、超时降级的完整代码拆解3.1 初始化与任务注册实现版本我用TypeScript写核心类约200行。先看基础结构// prepare-flow.ts export type PrepareState | idle | preparing | ready | switching | done | failed interface PrepareFlowOptions { timeout?: number // 整体准备超时 concurrency?: number // 并行任务上限 onStateChange?: (state: PrepareState, meta: Recordstring, unknown) void } export class PrepareFlow { private state: PrepareState idle private tasks: FlowTask[] [] private meta: Recordstring, unknown {} private abortController: AbortController | null null private options: PrepareFlowOptions constructor(options: PrepareFlowOptions {}) { this.options { timeout: 15000, concurrency: 5, ...options, } } use(task: FlowTask) { this.tasks.push(task) return this } private setState(next: PrepareState) { if (next this.state) return // 校验流转合法性 if (!flowChart[this.state].includes(next)) { console.warn([prepare-flow] 非法状态流转: ${this.state} - ${next}) return } this.state next this.options.onStateChange?.(next, this.meta) } }use()把任务追加进注册表注意同一个key只能注册一次重复注册要警告因为会导致后续并发里同一个资源被请求两次。这算是一个通过实践得来的硬性规则。3.2 start()从准备到切换的完整调度start()是整个模块的心脏负责把注册表里的任务按并发上限铺开收集结果后改写状态最后执行真正的渲染动作。async start(transition?: () void | Promisevoid) { if (this.state preparing) { throw new Error(已有正在执行的准备流程请先取消或等待完成) } this.abortController new AbortController() const { signal } this.abortController this.setState(preparing) const runs this.tasks.map((task) { const taskTimeout new Promise((_, reject) { const timer setTimeout(() { reject(new Error(任务 ${task.key} 超时)) }, task.timeout ?? 10000) signal.addEventListener(abort, () { clearTimeout(timer) reject(new Error(任务 ${task.key} 被取消)) }) }) const job Promise.resolve(task.handler({ signal, meta: this.meta })) // 保证单个任务自身或超时/取消都必须尽快把控制权交回调度器 return Promise.race([job, taskTimeout]) }) try { await runWithConcurrency(runs, this.options.concurrency!) this.setState(ready) } catch (err) { this.setState(failed) throw err } // 准备完成后才能真正执行切换动作 if (transition) { this.setState(switching) try { await transition() } catch (err) { this.setState(failed) throw err } } this.setState(done) }这里有几个细节我特别想强调细节一为什么用自实现的runWithConcurrency而不直接Promise.all因为任务一多Promise.all把所有请求一次性打出去前端接口层往往扛不住也不利于对关键任务的优先级控制。自实现一个异步池可以每批只跑N个。如果项目里有现成的p-limit库可以直接用但为了不引额外依赖我手写了一个简版async function runWithConcurrency(tasks: Promiseunknown[], limit: number) { const pool: Promisevoid[] [] for (const task of tasks) { const p task.then( () undefined, (err) { throw err } ) pool.push(p) if (pool.length limit) { await Promise.race(pool) // 清除已结束的条目避免池子无限膨胀 for (let i pool.length - 1; i 0; i--) { if (pool[i] ! p) continue } } } await Promise.all(pool) }细节二超时任务的失败处理。超时不会立刻中断整个流程而是取决于任务的critical标记。默认所有任务是critical的也就是必须成功才能切换。但业务里有些任务属于锦上添花比如预加载热更新模块、提前拉取下一级页面的图片等这种遇到超时应当放行——降级进入ready而不是failed。判断逻辑建议放在任务handler内部由任务自己去catch再用返回结构通知调度器// 任务内部自行降级的写法 use({ key: preload-next-images, critical: false, async handler() { try { await Promise.race([preloadImages(), timeout(4000)]) } catch { return { degraded: true, message: 图片预加载超时已降级 } } }, })细节三transition回调收到什么它收到的是最新meta。切换动作里如果需要拿到准备阶段的数据比如接口返回的首屏配置直接从meta里取不要再发一次请求。3.3 cancel()干净利落地中断一次切换取消是整个模块里最容易被忽略、实际线上出问题最多的地方。我的实现思路是cancel()把状态置回idle并触发AbortController让所有在途任务接到取消信号后自行收尾。cancel() { if (this.state idle || this.state done) return this.abortController?.abort() this.tasks [] this.setState(idle) }这里的关键是任务没法强制退出只能通过signal互相协作。所以注册任务时handler必须遵守一套取消协议数据请求用fetch传入signal即可不是fetch类型的任务在关键await处监听signal的abort事件手动抛出异常所有资源的清理动作清除定时器、解除事件监听都要在finally里做。我见过不少把取消做成标记位的方案就是设个flag然后在任务关键节点检查。这在小流程里可以用但遇到嵌套子流程很容易漏判。AbortController原生、通用而且fetch和大多数浏览器API都支持是最稳妥的选择。4. 在Vue3与React项目里的两种接入写法4.1 Vue3结合Router和Transition组件Vue3项目里的接入路径比较顺——把PrepareFlow放进组合式函数配合路由守卫做先准备后渲染。// usePrepareNavigation.ts import { createRouter } from vue-router import { PrepareFlow } from ./prepare-flow const flow new PrepareFlow({ timeout: 12000, concurrency: 4, onStateChange: (state) { // 可上报给埋点或全局loading }, }) export function usePrepareNavigation() { const prepareTo async (route) { const pageMeta await loadRouteMeta(route) // 获取该路由需要准备的任务 flow.use({ key: page-data, handler: () fetchPageData({ signal: ... }) }) flow.use({ key: permission, handler: () checkPermission({ signal: ... }) }) await flow.start() return true } return { prepareTo } } // 路由守卫里调用 router.beforeEach(async (to) { const { prepareTo } usePrepareNavigation() const ok await prepareTo(to) if (!ok) return false return true })配合Transition组件时有个容易忽略的执行时机transition回调里触发进入动画前必须保证DOM已经完成挂载。Vue的做法是await flow.start(async () { // 先让DOM真正渲染 await nextTick() // 再触发过渡动画 enterAnimation.value true })否则动画和渲染时序还是会对不上。4.2 React用Loader与useTransition消化准备流React侧的接入我走了两条支线一条是数据路由的loader另一条是自定义hook。用React Router的loader方案最干净因为loader天然就是一个渲染前的准备阶段// routes.tsx export const router createBrowserRouter([ { path: /dashboard, loader: async ({ request }) { const flow new PrepareFlow({ timeout: 10000 }) flow.use({ key: user, handler: () getUser({ signal: request.signal }) }) flow.use({ key: stats, handler: () getStats({ signal: request.signal }) }) const ok await flow.start() return ok ? { flowMeta: flow.getMeta() } : null }, element: Dashboard /, }, ])如果不用loader走组件内hook也能实现关键是保证start结束才进入渲染分支function useAsyncView(prepareTasks: FlowTask[], transition?: () void) { const [viewState, setViewState] useStateidle | ready | failed(idle) const flowRef useRefPrepareFlow | null(null) useEffect(() { let cancelled false const flow new PrepareFlow() prepareTasks.forEach((t) flow.use(t)) flowRef.current flow flow .start(transition) .then(() !cancelled setViewState(ready)) .catch(() !cancelled setViewState(failed)) return () { cancelled true flow.cancel() } }, [JSON.stringify(prepareTasks)]) if (viewState ready) return { data: flowRef.current?.getMeta() } if (viewState failed) return { error: true } return { loading: true } }React 18的useTransition我也试过但它主要优化的是不阻塞渲染的过渡更新和切换前的数据准备是两个不同维度的问题。前者调整的是更新优先级后者调整的是更新前的准备工作两者不冲突但别混为一谈。4.3 缓存策略已经done的流程回退时不再重复准备切换前的准备流程跑完后如果用户又切到别的页面再返回大多数情况下并不需要重新走一遍完整流程。缓存策略在这里能显著提速const cache new Mapstring, Recordstring, unknown() async prepareTo(route, flow) { const cacheKey route.name if (cache.has(cacheKey)) { flow.restoreMeta(cache.get(cacheKey)!) return true } // ...正常执行 cache.set(cacheKey, flow.getMeta()) }缓存策略我建议做到任务级而不是页面级。页面级缓存会让退出页面后再进来的数据永远停留在第一次加载的状态非常坑。任务级缓存只缓存不太变化的基础数据比如权限集、应用配置而像列表页这种实时数据还是得重新请求。5. 稳定运行一年后总结的坑时序、竞态与内存问题排查5.1 连续点击导致同一个流程被重复触发这个问题比预想中提早出现了。用户快速双击导航菜单推入两个相同的路由start()被连续调用第一个流程还没结束第二个就又进来了造成重复请求和状态错乱。排查路径其实比较直接先在start()入口打印当前state日志发现两个相邻请求之间state都停在preparing。然后定位到准备中禁止再次start这个校验给调用层补充一个节流阀——但节流阀治标不治本最后在prepareTo层面对相同路由做了去重let preparingRoute null async function prepareTo(route) { if (preparingRoute route.name) { return { skipped: true } } preparingRoute route.name try { // 准备流程 } finally { preparingRoute null } }这类问题最大的教训是凡是暴露给用户的入口默认都应该按并发访问来设计而不是按串行交互来设计。双击、连点、重复提交在前端世界里不是低概率异常而是用户的高频操作。5.2 请求竞态旧页面的fetch结果反超新页面切换到B页面后A页面已发出的请求突然返回而且因为某个共享状态被当时的闭包捕获结果把B页面的数据覆盖了。这类问题单靠prepareFlow内部解决不了因为如果A页面压根没有走当前flow它的请求就不受abortController控制。排查思路可以抽象成一条链路确认前端是否只有唯一的切换入口 - 检查页面卸载时有没有统一中断请求 - 给每个页面维护自增token。我最终采用的方案是所有异步更新操作前先检查当前token是否仍然有效。类似这样const tokenRef { current: 0 } function enterPage(pageId) { const myToken tokenRef.current fetchData().then((data) { if (myToken ! tokenRef.current) return // 已被新页面替换 render(data) }) }进入新页面时先自增token让所有旧请求的then回调自动失效。这个方案配合AbortController双管齐下效果最好。5.3 任务注册表无限增长导致内存泄漏刚开始我把所有flow.use()注册的任务都保存在数组里不做清理。页面切了几十次之后任务handler闭包引用的组件上下文仍然存活内存曲线直线向上非常可怕。修复方案是用完即清start()在进入ready或failed后自动清空tasks数组。如果需要复用任务注册函数改成工厂模式每次都重新创建任务对象async start(transition?: () void | Promisevoid) { // ... await runWithConcurrency(runs, this.options.concurrency!) this.tasks [] // 重点结束后清空任务注册表 // ... }另外还配合了WeakMap做meta快照缓存避免把大量数据长期驻扎在模块内部。5.4 弱网环境下的超时降级别让用户死在loading上超时设计最初是硬失败任何一个关键任务超时整个切换取消。结果在弱网条件下页面根本切换不过去用户点了菜单毫无反应比白屏更让人暴躁。后来加了stoic模式当准备超时且关键任务未完成时不再拦截切换而是记录降级原因放行渲染让页面先出现数据用骨架屏过渡。你一进去就有一个完整页面结构后续数据到了再填充内容。实现上就是在catch分支里加一个选项catch (err) { if (this.options.stoic lastState preparing) { console.warn([prepare-flow] 准备超时降级放行: ${err.message}) // 跳过ready直接进入切换 if (transition) { this.setState(switching) await transition() } this.setState(done) return } this.setState(failed) throw err }这个开关后来成了所有移动端项目的默认配置。可靠不等于不让用户进去而是先进去缺的东西再补。5.5 动画与渲染时序double rAF确保真实帧最开始transition回调里直接等一个nextTick就开始动画但在React里nextTick对应的是setTimeout延迟结果动画偶尔还是会抖动。后来改成double requestAnimationFrame等浏览器真正渲染完一帧后再触发动画function nextFrame(): Promisevoid { return new Promise((resolve) { requestAnimationFrame(() { requestAnimationFrame(() resolve()) }) }) } await flow.start(async () { await nextFrame() triggerEnterAnimation() })这个写的背后逻辑是第一次rAF意味着浏览器已经接收到样式变更并开始准备渲染第二次rAF则保证了那一帧已经绘制完毕。在这个时间点触发的过渡动画起始状态是板上钉钉的不会出现闪烁。最后再分享几个把这个模块用好用顺的思路如果从零再做一遍我会把状态上报和链路追踪做进去。现在这个版本因为最初设计时没留这个口子上线后排查问题全靠临时写console.log。建议每个任务的开始、结束、超时、取消、降级都走一个统一的日志通道附带taskKey和耗时时长这样生产环境一查日志就能定位问题到底出在哪个环节。另一个思路是把任务编排能力升级成DAG。目前的实现是扁平的并发队列没有表达依赖关系的能力。某些场景下B任务依赖A任务的产物就得额外挂个从meta取数据的步骤。如果一开始就支持依赖声明模块的通用性会高很多但复杂度也会上来小团队不建议一上来就上。最后提醒一句准备流程永远isolate好只在切换入口调用避免业务代码到处new PrepareFlow。模块一旦被散落在代码库各处状态被干扰的概率极高一切约束都会失效。全局只保留一个单例或者按页面域隔离出有限几个实例是我跑了一年后认为最舒服的使用方式。