系统设计 / 路由设计 · 稳定入口与可变目标 / 客户端路由 · push / replace 与 history 栈 待审核 8 / 23

客户端路由 · push / replace 与 history 栈

前几页的路由都发生在服务端:一次请求、匹配一条、回内容或回 3xx。但单页应用(SPA)想要的是不刷新整页就切换视图,于是路由这件事被搬进了浏览器。client router 的本质是自己维护一份「当前 location」状态,并把它和浏览器的 history 栈保持同步,核心动作只有 pushreplacebackforwardgo 这几个。

浏览器 history 就是一列条目加一个当前指针。视图由哪条决定?复用匹配引擎那台引擎,拿当前 url 跑一遍 matchTable 就得到 handler。

图 0-1 · history 栈的增长、替换与指针移动,以及视图如何跟着重渲。可连点几次 push 走到 /cart,再点两次 back 退回(指针下方出现虚线的前进历史),然后改个地址再 push——那段前进历史会立即全部消失。

上面最后那步演示的是 history 栈的一条规则:从中间 push,其后的前进历史全部作废。

1 · History API

浏览器把整摞 history 栈的操作收口在 window.history 上。图 0-1 那些按钮逐一对应它的方法:pushpushStatereplacereplaceStatebackforwardgo 同名。关键在于 pushStatereplaceState 只改 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) 在栈里跳两格、越界则什么都不做。pushStatereplaceState 自己不触发 popstate,这一点单列在 §3 末尾。

2 · push 与 replace 的差别

两者都把当前 location 换成新的,唯一差别在历史栈:push 把新条目叠上去(栈加一),用户能 back 退回来;replace 把当前条目原地顶掉(栈长度不变),back 会跳过它。选哪个取决于该不该让用户退回这个中转页。

判据只有一条:不该让用户 back 退回的中转页,就用 replace。
动作 history 栈 back 行为 典型场景
push 加一条新的 能退回上一页 正常点链接导航,留痕、可回退
replace 不变,顶掉当前 back 跳过当前页 登录后跳转、表单提交后的 PRG、修正非法 url、客户端重定向

这是服务端重定向的客户端镜像。push 像一次留痕、可回退的临时跳转(302 与 307),replace 则对应「不希望 back 退回中转页」的意图,正如 303 See OtherPOST 之后换到结果页、不让退回去重复提交。同一个「要不要留痕」的取舍,服务端用状态码表达、客户端用 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')); }
});

警示 · 最常见的错误是以为 pushStatereplaceState 会触发 popstate。它们不触发。popstate 只在用户点前进或后退、或代码调 gobackforward 时才发,而且回调里拿不到目标 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 要解决的。