软导航 · 守卫、滚动恢复与异步
路由搬进浏览器、导航变成「自己改一份状态」之后(见客户端路由),就多出一批服务端跳转没有的能力——导航不再是「发请求、整页换」的一次性动作,而是一个可以中途插手的过程。本页拆三个最常用的软特性。
1 · 导航守卫
每次 navigate(to) 真正改栈之前,先依次跑一串 guard。每个 guard 拿到 (to, from),可以放行、取消(留在原地)、或改道到别处。第一个取消或改道的 guard 就截断整条链,后面的不再跑。最典型的两种:未登录访问 /admin 改道
/login;有未保存的表单想离开则取消并弹确认。
守卫链的内核去掉框架细节只剩一个循环:返回 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 与表都不必自己维护。
3 · 异步导航
点一个链接,目标视图常常要先取一段数据才能渲染。整页跳转期间浏览器只能白屏等着。client router 不必:它可以先把导航挂起,留在旧视图或显示加载态,等数据 await 回来再一次性提交切过去。这就是 React Router 的 loader 与导航期间的 pending
状态,还能在慢请求时给一条进度反馈而不是一片空白。
三件事的共同点是导航成了过程而非瞬间。服务端跳转里这些都不存在——一个 3xx 发出去就结束了,无从拦截、无从挂起、更不记滚动。把路由搬进客户端、自己持有 location
状态之后,导航的每个阶段都成了可插手的钩子:发生前拦(守卫)、过程中等(异步)、完成后复位(滚动)。代价是这些过去浏览器代劳的事,现在得自己正确实现。