系统设计 / 路由设计 · 稳定入口与可变目标 / 软导航 · 守卫、滚动恢复与异步 待审核 10 / 23

软导航 · 守卫、滚动恢复与异步

路由搬进浏览器、导航变成「自己改一份状态」之后(见客户端路由),就多出一批服务端跳转没有的能力——导航不再是「发请求、整页换」的一次性动作,而是一个可以中途插手的过程。本页拆三个最常用的软特性。

1 · 导航守卫

每次 navigate(to) 真正改栈之前,先依次跑一串 guard。每个 guard 拿到 (to, from),可以放行、取消(留在原地)、或改道到别处。第一个取消或改道的 guard 就截断整条链,后面的不再跑。最典型的两种:未登录访问 /admin 改道 /login;有未保存的表单想离开则取消并弹确认。

图 1-1 · 守卫链的逐个执行与短路。可切换登录态与表单脏标记,观察哪个 guard 先表态、整条链在哪里被截断。

守卫链的内核去掉框架细节只剩一个循环:返回 false 即取消导航、返回字符串即改道、其余放行,第一个表态的 guard 就终止整条链。

function navigate(to, from) {
  for (const guard of beforeEach) {
    const r = guard(to, from);
    if (r === false) return;
    if (typeof r === 'string') return navigate(r, from);
  }
  commit(to);
}

beforeEach = [
  (to)       => to.startsWith('/admin') && !loggedIn ? '/login' : true,
  (to, from) => from.startsWith('/edit') && dirty    ? false    : true,
];

guard 的改道是客户端版的重定向:不发 3xx、不往返服务端,直接在导航前把目标换成 /login。

2 · 滚动恢复

整页跳转时浏览器天生会记住每页的滚动位置,后退能回到原处。这套行为由 history.scrollRestoration 控制,取值只有 'auto' 与 'manual' 两个,默认 'auto':后退或前进到一条历史条目时,浏览器把视口滚回离开时的位置;设成 'manual' 则浏览器不再恢复,交给页面自己处理。按 HTML 规范,这个值记在每条 session history 条目上而非整个文档上,pushState 新建的条目沿用当前条目的值,所以 SPA 启动时设一次,此后自己 push 出来的条目都是 'manual'(兼容性据 MDN BCD,2020 年起各大浏览器均已支持,核对于 2026-10)。

SPA 留着 'auto' 并不能得到正确的恢复。浏览器在派发 popstate 的同一步里滚动视口,而 router 的视图更新通常要等之后的 microtask,目标页若还要取数据则更晚。于是浏览器滚的是一份尚未换掉、或刚换成骨架屏的 DOM:内容高度不够,位置被截到当时能滚到的最大值,等数据到位、页面撑高,视口仍停在那个截短的位置上。router 若再自己恢复一次,视口还会先跳一下再被拉回。

所以 client router 普遍先把 scrollRestoration 设成 'manual',再自己接管三件事:离开一条条目时记下位置,新导航滚到页首(目标带 #id 时滚到该锚点),后退或前进时等目标视图渲染完再滚回记下的位置。

history.scrollRestoration = 'manual';

const saved = new Map();                       // 条目 key → 离开时的 scrollY
let current = history.state?.key ?? crypto.randomUUID();
history.replaceState({ ...history.state, key: current }, '');

async function show(path, top) {
  await render(path);                          // 等新视图(含数据)落进 DOM 再滚,否则会被截短
  scrollTo(0, top);
}

function navigate(to) {
  saved.set(current, scrollY);
  current = crypto.randomUUID();
  history.pushState({ key: current }, '', to);
  show(to, 0);
}

addEventListener('popstate', (e) => {
  saved.set(current, scrollY);                 // 条目已切换但视图未换,scrollY 仍属于离开的那页
  current = e.state?.key;
  show(location.pathname, saved.get(current) ?? 0);
});

警示 · 离开位置无法写回离开的那条条目。popstate 触发时当前条目已经切换成目标条目,此刻调 replaceState 改的是目标条目的 state。上面的写法因此给每条条目发一个 key,位置记在以 key 为索引的表里,靠 popstate 时视图尚未更新、scrollY 仍属于旧页这一时间差取到正确的值。Map 在刷新后清空,要在刷新后仍能恢复就得换成 sessionStorage,React Router 的 <ScrollRestoration> 即按 location.key 存在 sessionStorage 里。另一处容易漏的是卸载:微前端或嵌入场景里 router 销毁时应把 scrollRestoration 改回 'auto',否则宿主页面的原生恢复也一并失效。

Navigation API 从接口层面补上了时机这一环:intercept() 的 scroll 默认取 'after-transition',浏览器等 handler 返回的 promise 兑现后才滚动或恢复,位置也由浏览器按条目保存,上面这套 key 与表都不必自己维护。

图 2-1 · 三种滚动恢复策略下的后退结果,目标页先显示骨架、数据延迟到位。可在 A 页滚到中间、访问 B 页、再点后退,切换策略对比恢复落点。

3 · 异步导航

点一个链接,目标视图常常要先取一段数据才能渲染。整页跳转期间浏览器只能白屏等着。client router 不必:它可以先把导航挂起,留在旧视图或显示加载态,等数据 await 回来再一次性提交切过去。这就是 React Router 的 loader 与导航期间的 pending 状态,还能在慢请求时给一条进度反馈而不是一片空白。

图 3-1 · 导航被挂起、数据到位后再提交的过程。可调延迟观察 pending 阶段停在旧视图而非白屏。

三件事的共同点是导航成了过程而非瞬间。服务端跳转里这些都不存在——一个 3xx 发出去就结束了,无从拦截、无从挂起、更不记滚动。把路由搬进客户端、自己持有 location 状态之后,导航的每个阶段都成了可插手的钩子:发生前拦(守卫)、过程中等(异步)、完成后复位(滚动)。代价是这些过去浏览器代劳的事,现在得自己正确实现。