刷新一下就 404:History 模式的前端路由到底骗了谁

前端路由让 URL 变化只切换视图,History 模式靠 pushState/replaceState 改写地址栏而不发起请求,于是浏览器里每个路径都成了服务器上并不存在的虚拟地址。直接访问或刷新时,浏览器仍按普通导航把路径原样发给服务器,静态服务器找不到对应文件就返回 404,这不是路由库的 bug,而是服务端少了回退规则。另一个常见误解是以为 pushState 会触发 popstate,其实它一个事件都不发,路由状态得自己维护。本文讲清两种模式的事件模型、history.state 的存活范围与软 404 的代价,并给出服务端回退、基路径与滚动恢复的实操建议。

是什么

前端路由指的是:URL 变化时不让浏览器去服务器取新页面,而是由前端代码解析路径、替换视图。整站只有一个 HTML 入口,后续的“页面”都是同一份文档里渲染出来的。它撑起了单页应用(SPA)、后台管理系统、桌面端套壳与移动 WebView 里的大部分跳转。

实现上只有两条路。一条是 hash 模式,地址形如 /index.html#/users/42,井号后面的部分浏览器不会发给服务器;另一条是 history 模式,地址形如 /users/42,靠 history.pushState 与 replaceState 改写地址栏。一句话直觉:hash 是在文档内部换书签,history 是把地址栏那块显示屏上的字换掉,却假装自己换了一台电视。

两种模式最直观的差别在观感,真正的差别在谁负责让这个地址成立。hash 模式把责任全留给了浏览器,history 模式则把一半责任推给了服务端,而很多人只写了前端那一半。

核心原理

浏览器有两条完全不同的导航通道。 第一条是用户导航:点链接、敲地址、按刷新、前进后退,浏览器会把它当成一次真实的资源请求,走网络、拿响应、换文档。第二条是脚本导航:pushState 只往会话历史里压一条记录、改掉地址栏,然后返回。它不发请求、不重载文档,也不会触发 popstate 或 hashchange。replaceState 同理,只是替换当前条目而不是新增。

这套设计的本意是给“同一文档内的状态变化”一个可寻址的入口。问题在于浏览器并不校验你压进去的路径在服务器上是否真的存在。于是 history 模式下的每个 URL 都成了“本地合法、远端非法”的虚拟地址:路由库认得它,服务器不认得它。用户只要不刷新、不直接粘贴地址,一切正常;一旦刷新,浏览器按第一条通道把 /users/42 原样发出去,静态服务器去磁盘上找 users/42 这个文件,找不到,返回 404。

所以 404 不是路由写错了,是约定被打破了。 修法只有一个方向:让服务器对所有“不是静态资源”的路径都吐回 index.html,把解析权交还给前端。Nginx 里就是一句 try_files $uri $uri/ /index.html;,Node 侧的 connect-history-api-fallback、Vite 的 historyApiFallback、各家的 _redirects 或 rewrites 都是同一件事。前提是静态资源与后端 API 的规则要排在回退之前,否则接口 404 会被替换成一段 HTML,前端 res.json() 直接抛解析错误。

怎么判定“该回退”。 直接判断文件存在与否最稳:请求路径能映射到磁盘上的真实文件就返回文件,否则回退。也有按 Accept 头判断的做法——只对 Accept: text/html 的请求回退,图片、脚本、接口请求仍如实返回 404。后者的风险在于某些客户端不发或乱发 Accept,前者则要求回退规则能看懂构建产物目录。无论哪种,都要保证回退是最后一条规则,而不是第一条。

这个模型正在被补丁。 因为 pushState 不发事件、跨文档导航又拦不住,Chromium 从 102 起提供了 Navigation API,用一次 navigate 事件统一拦截链接点击、表单提交、前进后退与 pushState,还能取消导航。它在概念上更接近“导航本身可被监听”,但各浏览器支持进度不一,短期内仍是路由库内部的实现细节,而不是业务代码可以直接依赖的基线。

事件模型是第二个坑。 pushState 和 replaceState 都不触发 popstate,只有用户前进或后退、激活了另一条历史记录时才会触发。hash 模式则靠 hashchange。这意味着路由库必须自己完成闭环:拦截站内链接的点击,调用 pushState,然后手动通知视图更新;在 popstate 回调里只做渲染,绝不能再 pushState 一次,否则每按一次后退就多压一条记录,历史栈越走越深。

const routes = { '/': Home, '/about': About };
const render = () => {
  const view = routes[location.pathname] || NotFound;
  document.querySelector('#app').innerHTML = view();
};
document.addEventListener('click', (e) => {
  const a = e.target.closest('a[data-link]');
  if (!a || e.metaKey || e.ctrlKey) return;
  e.preventDefault();
  history.pushState({}, '', a.href);
  render();
});
addEventListener('popstate', render);
render();

状态存在哪里。 pushState(state, '', url) 的第一个参数会被结构化克隆后存进当前历史条目,刷新时浏览器会把它一并带回来,所以它可以放筛选条件、滚动位置这类可序列化的小对象,但不能放函数、DOM 节点,也不该放令牌之类的敏感数据——它就在用户可查看、可篡改的会话历史里。滚动位置浏览器默认会尝试恢复,SPA 里常常不准,通常把 history.scrollRestoration 设成 manual 自己管。

常见考点与误区

  • 误区一:“pushState 会触发 popstate,所以监听它就够了。” 这是最高频的错答。pushState 与 replaceState 既不触发 popstate 也不触发 hashchange,首次进入页面同样不会触发 popstate。补监听 popstate 只能覆盖前进后退,站内点击那一路必须由路由库自己调用渲染。
  • 误区二:“刷新 404 是前端路由的 bug,换 hash 模式就好了。” 换 hash 确实能绕过,因为井号后的内容不进 HTTP 请求。但根因是服务端缺少回退规则,正确做法是补配置,而不是为了部署方便牺牲 URL 形态。
  • 误区三:“hash 模式除了丑没有缺点。” 它免服务端配置、file:// 下也能跑、兼容老环境,这是优点;代价是 hash 不进服务端日志、不利于按路径做缓存与埋点,且与页面内锚点语义冲突。history 模式则要求服务端配合,并且要处理基路径。
  • 考点四:“history.state 刷新后会丢吗?” 不会。同一条历史记录刷新后 history.state 仍在,这是浏览器持久化会话历史的一部分;会丢的是你只存在内存里的变量。反过来,别把“刷新还在”误解成“可以当本地存储用”,它随标签页生命周期消亡。
  • 考点五:“为什么 SPA 全站回退到 index.html 会伤 SEO?” 因为未知路径也返回 200,形成软 404,爬虫会把这些地址当成有效页面收录。较稳妥的做法是服务端识别已知路由前缀,未知路径仍返回 404 状态码但响应体给入口 HTML,再由前端渲染兜底页。
  • 考点六:“history.length 能判断有没有上一页吗?” 不能。它只是当前会话的历史条目总数,包含当前页,且受浏览器实现影响,不能用来决定“返回”按钮是否可用。

实践建议

  • 回退规则按优先级写。 先匹配 /api、/assets、带扩展名的静态文件,最后才回退到 index.html;本地开发服务器、预发、生产三套环境用同一份规则,别只在 Nginx 上配了、到对象存储或 CDN 上又漏掉。
  • 站内跳转统一走路由组件。 不要裸写 <a href> 再全局拦截一切点击;外链、target="_blank"、download、带修饰键的点击都要放行,否则用户在新标签页打开的习惯会被打断。
  • 基路径要成对配置。 部署在子目录时,构建的 base、路由的 base、服务端回退的前缀必须一致,任何一处漏了都会表现成“首页正常、刷新或深链接资源全 404”。
  • 每次路由切换补三件事。 更新 document.title、把焦点移到主标题或主区域(键盘与读屏用户否则会停留在旧位置)、按需滚到顶部或恢复位置。这三件事不做,功能没坏,体验是残的。
  • 把 404 做成可观测的。 前端记录未匹配的路由,服务端统计回退到入口 HTML 的请求占比;比例突然升高往往意味着旧链接、爬虫或某次改版漏了重定向。

一句话总结:History 模式把地址的解释权分了一半给服务端,前端改得了地址栏,改不了服务器上有没有这个文件。

相关阅读

← 返回文章志