客户端路由 · push / replace 与 history 栈
前面几页的路由都发生在服务端:一次请求、匹配一条、回内容或回 3xx。但单页应用(SPA)想要的是不刷新整页就切换视图——于是路由这件事被搬到了浏览器里。client router 的本质,是自己维护一份「当前 location」状态,并把它和浏览器的
history 栈保持同步。它的核心动作就这几个:push / replace / back / forward / go。
浏览器 history 就是「一列条目 + 一个当前指针」。下面这台简化版 router 把这个栈画出来:改地址、点动作,看栈怎么增长 / 替换、指针怎么移、当前视图怎么跟着重渲。视图怎么定?复用匹配引擎页那台引擎——拿当前 url 跑一遍
matchTable 就得到 handler。
试一段「截断前进历史」的经典操作序列:连点几次 push 走到 /cart → 点两次 back 退回去(注意指针下面出现虚线的前进历史,forward 还能回去)→ 现在改个地址点
push——那段虚线的前进历史立即全部消失。这就是 history 栈的规则:从中间 push,其后的前进历史全部作废。
1 · 这几个动作对应的原生接口:History API
浏览器把整摞 history 栈的操作收口在一个 window.history 对象上。上面那些按钮,逐一对应它的方法——push = pushState、replace = replaceState、back / forward / go 同名。关键是 pushState /
replaceState 只改 URL 与栈、不发任何请求、不刷新页面,这正是 SPA「换地址但不重载」的基础。留意 popstate 的触发条件:
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:它只在用户点前进 / 后退、或代码调
go / back / forward 时才发,而且回调里拿不到目标 url,只能从 location 读当前地址。
2 · push 还是 replace?差在「能不能 back 回来」
两个都把当前 location 换成新的,唯一的差别在历史栈:push 把新条目叠上去(栈 +1),用户能 back 退回来;replace 把当前条目原地顶掉(栈长度不变),back
会跳过它。选哪个,取决于「你想不想让用户能退回这个中转页」。
| 动作 | history 栈 | back 行为 | 典型场景 |
|---|---|---|---|
| push | +1(叠一条新的) | 能退回上一页 | 正常点链接导航——留痕、可回退 |
| replace | 不变(顶掉当前) | back 跳过当前页 | 登录后跳转 · 表单提交后 PRG · 修正非法 url · 客户端重定向 |
规律:不想让用户 back 退回「那个不该再回去的中转页」,就用 replace。
这其实是服务端重定向的客户端镜像。push 像一次留痕、可回退的临时跳转(302 / 307);replace 则对应「不希望 back 退回中转页」的意图——正如 303 See Other 在
POST 之后把你换到结果页、不让你退回去重复提交(PRG)。同一个「稳定入口 ↔ 可变目标 + 要不要留痕」的取舍,服务端用状态码表达,客户端用 push / replace 表达。详见 HTTP 重定向页。
3 · 把它们串成一台 router:内核循环
单独的 pushState 只改了地址栏,屏幕不会变。要让视图跟着动,得自己补上「改栈 → 重新匹配 → 渲染」这个闭环,再处理两个外部入口:用户点前进 / 后退(浏览器发 popstate)、以及点站内 <a>(得拦下来避免整页跳转)。一台 client router 的内核就这么大——视图解析直接复用匹配引擎页的 matchTable。点上面 viewport 里的链接,走的就是这条「拦截 → navigate」的路。
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 时才发。所以 router 在 navigate() 里改完栈后,必须自己再调一次 render——指望 popstate 来通知是收不到的。
4 · 同一套栈,也在 App 里:navigation / back stack
这套「条目 + 指针」的模型不是浏览器独有的。原生 App 的导航栈(navigation stack / Android 的 back stack)是同一个东西:进入一个页面 = push 一个 screen、系统返回键 = back(pop)、登录成功后把登录页换成主页 =
replace(这样按返回不会退回登录页)。所以 deep link 页把用户「直接送进 App 某个页面」之后,落点正是叠进这个栈;React Native / Flutter 的 navigator API 名字几乎一样。路由的状态管理,浏览器和 App 共用一套心智模型。
5 · 相关页
- 本系列 · 路由匹配引擎——本页 viewport 显示哪个视图,就是把当前 url 丢给那台
matchTable跑出来的——client router 复用同一台引擎。 - 本系列 · history 模式 vs hash 模式——本页改地址用的是干净的
pushStateURL(history 模式)。它需要服务端 fallback、hash 模式不需要——该页专门拆这道选择。 - 本系列 · Navigation API——本页那两处易错点(
pushState不发popstate、<a>要手动拦)正是新的 Navigation API 要解决的——它用一个统一的navigate事件接住一切。 - MDN · History API · developer.mozilla.org——
pushState/replaceState/go/back/forward与popstate事件的权威说明。