Published on

声明式路由的单驱动原则:为什么"导航真相"只能有一个来源

Authors
  • avatar
    Name
    Jack Qin
    Twitter

声明式路由的核心承诺是:你不再"命令"应用去哪儿,而是"声明"在某个状态下应该呈现哪个屏幕,框架负责把当前状态收敛到对应的视图。Expo Router 的文件系统分组((auth) / (tabs))加上 <Redirect> 就是这套承诺的具体形态——(auth)/_layout 声明"已登录就去 tabs",(tabs)/_layout 声明"未登录就回 login"。只要状态对了,导航自己会发生。

这套模型有一个很容易被忽略、却是它全部正确性所依赖的前提:导航真相必须只有一个来源。声明式 gate 一旦成立,任何其他往同一目标发起的命令式导航都不是"双保险",而是第二个驱动器。本文想拆的不是某次登录闪烁的 bug,而是这条原则背后的机制账:声明式 gate 为什么排斥命令式导航、以及一条更隐蔽的时序前提——派生认证状态会在 await 还没返回时就翻转——如何让"看起来无害的一行 router.replace"变成结构性的竞态。


机制一:声明式 gate 是一个收敛器,不是一次跳转

命令式导航(router.replace("/(tabs)"))的心智模型是"执行一次跳转"——它是一个事件。声明式 gate 的心智模型完全不同:它是一个持续运行的收敛函数。每当 isAuthenticated 变化,挂载着的 _layout 就 re-render,重新求值"我现在该不该重定向",并据此发出或撤销 <Redirect>

// app/(auth)/_layout.tsx —— 已登录就离开 auth 区
if (isAuthenticated) return <Redirect href="/(tabs)" />

// app/(tabs)/_layout.tsx —— 未登录就回到 auth 区
if (!isAuthenticated) return <Redirect href="/(auth)/login" />

关键差别在于:收敛器只要状态对了就会自动把你送到目标,不需要任何人显式触发。这意味着,在 gate 已经接管的前提下,再写一句 router.replace("/(tabs)") 不是"帮它一把",而是引入了一个与收敛器目标相同、但触发时机不受收敛器控制的第二驱动器。两个驱动器打向同一个目标,本身就是竞态的温床——谁先到、framework 如何处理"已经在去往 X 的途中又被命令去 X",都成了未定义行为。


机制二:派生状态在 promise resolve 之前就翻转

如果两个驱动器总是严格先后、永不重叠,问题或许还能侥幸不暴露。真正把它钉成必然的,是第二条时序前提——而它藏在 AuthContext.login() 的内部实现里。

login() / loginWithMicrosoft() 内部调用了 queryClient.fetchQuery(currentUserQueryOptions)。这个调用的副作用很关键:它会在 login() 返回的 promise resolve 之前,就把 ["auth","me"] 这份缓存写好。而 isAuthenticated 是从这份缓存响应式派生出来的——currentUser 来自 useCurrentUserQueryisAuthenticated!!currentUser?.isActive

把这两件事接起来,结论是反直觉的:isAuthenticated 会在 login() 执行到一半、调用方还卡在 await 上时就翻成 true。也就是说,对调用方而言,await login() 这一行的语义不是"登录完成了",而是"登录完成了,而且 gate 早就开始导航了"。

于是竞态成形:

  1. 缓存写入 → isAuthenticatedtrue → 当前挂载的 (auth)/_layout re-render → 发出 <Redirect href="/(tabs)">驱动 #1,由收敛器自动触发);
  2. 这一切发生在那个还在 await 的调用方跑到它自己那行 router.replace("/(tabs)")驱动 #2之前

两个 replace 在同一个 JS tick 里都打向 /(tabs),Expo Router 会重放/打断这次转场——肉眼看到的就是屏幕在落定前闪烁、弹跳好几次。注意这里没有 splash 能盖住它:用户此刻已经在登录表单上,isLoading 早就是 false,没有任何加载态可以借来遮丑。


两条前提的交汇

把两段机制叠起来,闪烁就从"偶发 bug"变成了"结构性必然":

声明式 gate 是唯一应有的导航驱动(机制一)→ 但 login() 在 resolve 前就写好缓存、翻转派生的 isAuthenticated(机制二)→ gate 抢先发出 <Redirect> → 调用方随后那句多余的 router.replace 变成第二个打向同一目标的驱动 → 同 tick 双导航 → Expo Router 重放转场 → 可见闪烁。

值得玩味的是,两个驱动单独看都"对":gate 是声明式路由的标准用法;登录后 router.replace("/(tabs)") 在一个没有 gate的命令式应用里是完全正确的写法。错的是它们交汇处那个没人重新审视的隐含前提——router.replace 这行是在"导航由我手动驱动"的世界观下写的,一旦 gate 接管了导航真相,这个前提就失效,多余的那行从"无害冗余"翻转成"竞态的第二驱动"。

这也解释了为什么这类问题排查起来格外迷惑:症状(屏幕闪好几下)看起来像渲染性能问题或动画问题,会把人引向"加 splash 盖住""节流 re-render"这些正交的轴。但真正的故障轴是导航驱动的数量——只要还有两个,盖住症状的补丁都不碰因。


修复,以及它逼出的契约

直接的修复是做减法——删掉调用方那行命令式导航,让 gate 成为唯一驱动:

// LoginFormCard.tsx —— 不再需要 expo-router import
await loginWithMicrosoft()
// app/(auth)/_layout.tsx 里的 gate 是唯一的导航驱动。

如果删掉最后一个 router.* 调用后,import { router } from "expo-router" 成了无用 import,一并删掉——别留孤儿。

但比这几行删除更重要的,是它逼出来的几个判断:

await login() 的语义必须被写成可执行的契约。 这个调用的隐含约定是"resolve 时 isAuthenticated 已是 true,gate 已开始导航"。靠口头约定守不住——下一个人完全可能"出于稳妥"又补一句 router.replace

await login() 之后的状态gate 行为调用方应有的行为
成功(缓存已写,isAuthenticated → true(auth)/_layout 重定向到 /(tabs)什么都别做——不调任何 router.*
失败(login() 抛错,无缓存写入)gate 不变,停在 (auth)渲染错误;不导航

已认证用户落到任何 (auth) 路由,都交给 (auth)/_layout<Redirect>,而不是在屏幕层补重定向。冷启动恢复同理——它由 bootstrap effect + gate 处理,本就不在这条规则的射程内。

"不重复导航"这条约束要落成回归守卫。 登录屏的单元测试(Vitest,用 vi.mock("expo-router")router.replace mock 掉;本项目 renderHook 不可用,改测组件级契约)的成功路径里,核心断言不是"导航成功了",而是 expect(mockedRouterReplace).not.toHaveBeenCalled()——这条"没调命令式导航"的断言,正是单驱动原则的守门人。谁让第二个驱动复活,它立刻红。


可迁移的那一层

抛开 Expo Router 的具体 API,这个案例真正可迁移的认知有两条。

声明式系统里,命令式逃生口是默认的反模式。 当你已经用状态声明了"应该呈现什么",再去命令式地推动同一个结果,几乎总会在某个时序边界上和声明式收敛器撞车。声明式 UI、声明式路由、声明式数据同步——它们的正确性都建立在"真相只有一个来源"之上,加一个命令式驱动就是在破坏这个前提。

异步函数的"完成"边界,未必是你以为的那个边界。 await login() 看起来是"登录这件事结束了",但它内部的缓存写入早就把派生状态翻转、把下游收敛器启动了。当一个异步操作带着会被别处响应式订阅的副作用时,它的 promise resolve 之前就已经"对外可见"。排查这类竞态,与其逐个找谁触发了导航,不如先问:这条链路上到底有几个东西在驱动同一个结果?我把它收敛到一个了吗?