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

客户端路由 · 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 = pushStatereplace = replaceStateback / 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 OtherPOST 之后把你换到结果页、不让你退回去重复提交(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 自己「不」触发 popstatepopstate 只在用户点前进 / 后退、或你调 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 模式——本页改地址用的是干净的 pushState URL(history 模式)。它需要服务端 fallback、hash 模式不需要——该页专门拆这道选择。
  • 本系列 · Navigation API——本页那两处易错点(pushState 不发 popstate<a> 要手动拦)正是新的 Navigation API 要解决的——它用一个统一的 navigate 事件接住一切。
  • MDN · History API · developer.mozilla.org——pushState / replaceState / go / back / forwardpopstate 事件的权威说明。