客户端路由 · push / replace 与 history 栈
前几页的路由都发生在服务端:一次请求、匹配一条、回内容或回 3xx。但单页应用(SPA)想要的是不刷新整页就切换视图,于是路由这件事被搬进了浏览器。client router 的本质是自己维护一份「当前 location」状态,并把它和浏览器的 history 栈保持同步,核心动作只有
push、replace、back、forward、go 这几个。
浏览器 history 就是一列条目加一个当前指针。视图由哪条决定?复用匹配引擎那台引擎,拿当前 url 跑一遍 matchTable 就得到 handler。
/cart,再点两次 back 退回(指针下方出现虚线的前进历史),然后改个地址再 push——那段前进历史会立即全部消失。上面最后那步演示的是 history 栈的一条规则:从中间 push,其后的前进历史全部作废。
1 · History API
浏览器把整摞 history 栈的操作收口在 window.history 上。图 0-1 那些按钮逐一对应它的方法:push 是 pushState、replace 是 replaceState,back、forward、go 同名。关键在于 pushState 与
replaceState 只改 URL 与栈,不发任何请求、不刷新页面,这是 SPA「换地址但不重载」的基础。
history.pushState(state, '', '/products/42');
history.replaceState(state, '', '/login');
history.back();
history.forward();
history.go(-2);
addEventListener('popstate', (e) => {
render(location.pathname + location.search);
});
pushState 推一条新历史(栈 +1、指针前移),replaceState 原地替换当前条目(栈长度不变),go(-2) 在栈里跳两格、越界则什么都不做。pushState 与 replaceState 自己不触发 popstate,这一点单列在 §3 末尾。
2 · push 与 replace 的差别
两者都把当前 location 换成新的,唯一差别在历史栈:push 把新条目叠上去(栈加一),用户能 back 退回来;replace 把当前条目原地顶掉(栈长度不变),back 会跳过它。选哪个取决于该不该让用户退回这个中转页。
| 动作 | history 栈 | back 行为 | 典型场景 |
|---|---|---|---|
push |
加一条新的 | 能退回上一页 | 正常点链接导航,留痕、可回退 |
replace |
不变,顶掉当前 | back 跳过当前页 | 登录后跳转、表单提交后的 PRG、修正非法 url、客户端重定向 |
这是服务端重定向的客户端镜像。push 像一次留痕、可回退的临时跳转(302 与 307),replace 则对应「不希望 back 退回中转页」的意图,正如 303 See Other 在 POST 之后换到结果页、不让退回去重复提交。同一个「要不要留痕」的取舍,服务端用状态码表达、客户端用 push 与
replace 表达,见 HTTP 重定向。
3 · router 的内核循环
单独的 pushState 只改了地址栏,屏幕不会变。要让视图跟着动,得自己补上「改栈、重新匹配、渲染」这个闭环,再处理两个外部入口:用户点前进或后退时浏览器发来的 popstate,以及点站内 <a> 时要拦下来避免整页跳转。一台 client router 的内核就这么大,视图解析直接复用匹配引擎的 matchTable。
const router = {
routes,
navigate(to, { replace = false } = {}) {
replace ? history.replaceState(null, '', to)
: history.pushState(null, '', to);
this.render(to);
},
render(url) {
const { winner } = matchTable(this.routes, url);
const { params } = matchPattern(this.routes[winner], url);
mount(this.routes[winner], params);
},
};
addEventListener('popstate', () => router.render(location.pathname));
addEventListener('click', (e) => {
const a = e.target.closest('a[href^="/"]');
if (a) { e.preventDefault(); router.navigate(a.getAttribute('href')); }
});
警示 · 最常见的错误是以为 pushState 与 replaceState 会触发 popstate。它们不触发。popstate 只在用户点前进或后退、或代码调 go、back、forward 时才发,而且回调里拿不到目标 url,只能从
location 读当前地址。所以 router 在 navigate() 里改完栈之后必须自己再调一次 render,指望 popstate 来通知是收不到的。
4 · App 里的同一套栈
这套「条目加指针」的模型不是浏览器独有的。原生 App 的导航栈(Android 侧叫 back stack)是同一个东西:进入一个页面即 push 一个 screen,系统返回键即 back,登录成功后把登录页换成主页即 replace,这样按返回不会退回登录页。deep link 把用户直接送进 App 某个页面之后,落点正是叠进这个栈;React Native 与 Flutter 的 navigator API 名字也几乎一样。
本页改地址用的是干净的 pushState URL,即 history 模式,它需要服务端 fallback 而 hash 模式不需要,两种模式专门拆这道选择。而本页那两处易错点——pushState 不发 popstate、<a> 要手动拦——正是
Navigation API 要解决的。