12k
All articles

使用 History API 构建客户端路由

构建原生 History API 路由器,涵盖 pushState、popstate、动态参数、SEO 友好 URL,以及刷新 404 和 XSS 防护。

OpenReplay Team
OpenReplay Team
使用 History API 构建客户端路由

客户端路由通过更新 URL 并在 JavaScript 中重新渲染来切换视图,无需与服务器往返通信(只有在首次加载或强制刷新时才会请求服务器)。

如果你曾经交付过单页应用,你一定经历过这样的时刻:本地一切正常,然后队友按下浏览器的「后退」按钮,URL 变了,但页面纹丝不动。一旦知道问题出在哪里,五分钟就能修好,但几乎每个人第一次都会踩这个坑。

诸如 React Router 和 Vue Router 这样的框架把这套行为封装成了组件和 hooks,但它们底层驱动的都是同一个浏览器原语:History API。本文将用大约 50 行代码构建一个最小化、正确且可部署的原生路由器,解释 pushState/popstate 的职责分工,并覆盖两个把玩具和可上线产品区分开来的坑(部署 404 和 XSS 注入风险)。

关键要点

  • 在 History 模式下,history.pushState(state, '', url) 会在不重新加载页面的情况下改变 URL,但它不会触发 popstate 事件。你需要在 pushState 之后自行调用渲染函数,并单独监听 popstate 来处理「后退」和「前进」。
  • History 模式简洁的 /dashboard 式 URL 对 SEO 和分享更友好,但它要求服务器把所有未知路径重写到 index.html,否则直接访问或刷新会返回 404。
  • pushState 的第二个参数是浏览器会忽略的遗留 title 参数;它不能省略,因此始终传入空字符串。
  • innerHTML 注入视图,对任何被插值的不可信数据来说都是 XSS 攻击面,并且会静默丢弃注入标记上的事件监听器。请使用 createElement 构建节点、做转义处理,或使用模板库,并通过事件委托绑定行为。
  • Navigation API 已于 2026 年 1 月达到 Baseline Newly available(新近可用)状态,是这一模式正在崛起的继任者,但 History API 仍然是兼容性最广的基线方案。

hash 模式和 History 模式有什么区别?

客户端路由会在 URL 变化时更新视图,而不触发整页重新加载。在不发生导航的前提下改变 URL 有两种方式:hash 模式History 模式。hash 模式把路由编码在 # 之后(/app#/users)。hash 之后的片段永远不会发送给服务器,因此基于 hash 的导航纯粹发生在客户端,无需任何服务器配置,你只需监听 hashchange 事件。History 模式使用 History API 生成简洁的路径(/users),并监听 popstate

hash 模式History 模式
URL 形态/app#/users/users
变更事件hashchangepopstate
服务器配置无需将所有路径重写到 index.html
刷新 / 深链接始终可用未配置重写时返回 404
SEO / 可分享 URL较弱更简洁,更受推荐

由于 URL 简洁且可被索引,History 模式是默认选择,也是本文将要构建的方案。唯一的代价是它需要服务器支持,下文会讲到。

你真正需要的 History API 原语

支撑一个 History 模式路由器的有三个原语。history.pushState(state, unused, url) 会向会话历史栈中添加一条记录并改变地址栏;history.replaceState 做同样的事,但它覆盖当前记录而不是新增一条。location.pathname 用于读取当前路径,以便你匹配路由。当用户按下「后退」或「前进」时,会触发 popstate 事件。

关键规则:pushStatereplaceState 不会触发 popstate 你必须在每次 pushState 之后自行调用渲染函数,并单独注册一个 popstate 监听器,这样浏览器的「后退」和「前进」才能重新渲染视图。漏掉这个监听器,按下「后退」时 URL 会变而 DOM 保持冻结——这个 bug 在 code review 中看不出来,但当你观看应用的会话回放时会一目了然。

还有两个细节值得注意。中间那个参数是浏览器会忽略的遗留 title 值,而且不能省略,所以传入空字符串。url 必须同源:调用 pushState 时浏览器不会加载它,且如果源与当前页面不同,调用会抛出异常。popstate 本身历史悠久且可靠,自 2015 年 7 月起在各浏览器中均可用。

如何构建一个最小化路由器?

一个可运行的 History 模式路由器需要五个部分:一张路由表、一个读取 location.pathname 并匹配路由(带 404 兜底)的 resolve 函数、基于 data-link 属性的点击委托、一个 popstate 监听器,以及一次初始渲染。完整文件如下:

function escapeHtml(str) {
  return String(str).replace(/[&<>"']/g, (c) =>
    ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
}

const routes = {
  '/':          { view: () => '<h1>Home</h1><a href="/users/42" data-link>User 42</a>', title: 'Home' },
  '/users/:id': { view: (p) => `<h1>User ${escapeHtml(p.id)}</h1>`, title: 'User' },
  '/404':       { view: () => '<h1>404 — Not found</h1>', title: 'Not found' },
};

const app = document.getElementById('app');

function match(pathname) {
  for (const pattern of Object.keys(routes)) {
    const pParts = pattern.split('/');
    const uParts = pathname.split('/');
    if (pParts.length !== uParts.length) continue;
    const params = {};
    const ok = pParts.every((part, i) => {
      if (part.startsWith(':')) { params[part.slice(1)] = decodeURIComponent(uParts[i]); return true; }
      return part === uParts[i];
    });
    if (ok) return { route: routes[pattern], params };
  }
  return { route: routes['/404'], params: {} };
}

function resolve() {
  const { route, params } = match(location.pathname);
  app.innerHTML = route.view(params);
  document.title = route.title;
}

function navigate(url) {
  history.pushState({}, '', url);   // '' is the ignored legacy title
  resolve();                        // pushState does NOT fire popstate — render manually
}

document.addEventListener('click', (e) => {
  const link = e.target.closest('[data-link]');   // robust: works on nested markup
  if (!link) return;
  e.preventDefault();
  navigate(link.getAttribute('href'));
});

window.addEventListener('popstate', resolve);      // Back / Forward

history.replaceState({}, '', location.pathname);    // seed the initial entry
resolve();                                          // render on first paint

通过 e.target.closest('[data-link]') 做事件委托是有意为之的。它能应对点击子节点(例如链接内部的图标)的情况,并且在视图被重新渲染后依然有效;相比之下,给每个元素单独绑定监听器,或者读取 e.target.attributes[0](依赖属性顺序,遇到嵌套标记就会失效)都做不到这一点。

进阶:动态参数、标题与初始历史记录

上面的 match 函数已经能处理动态片段了。像 /users/:id 这样的模式会被拆分成若干部分;任何以 : 开头的片段都会把对应的路径片段捕获到 params 对象中,因此 /users/42 解析结果为 { id: '42' }。非 : 片段必须完全匹配,长度不一致则跳过该模式,这样 /users 就不会匹配到 /users/42。在 resolve 内部设置 document.title 可以在每次导航时更新标签页标题和历史记录标签。

路由器里还需要再补一处修正。浏览器的第一条历史记录来自一次普通的页面加载,所以上面没有存储任何状态,MDN 关于使用 History API 的指南建议在启动时调用 history.replaceState(),把状态附加到那条记录上。这样做之后,第一次按下「后退」就能恢复你的初始视图。这就是路由器代码最后那行 replaceState 的作用。

把玩具和真实路由器区分开的两个坑

部署。 History 模式简洁的 URL 要求服务器把所有未知路径重写到 index.html,否则直接访问或刷新 /users/42 会返回 404。这没有 JavaScript 层面的绕行方案,因为请求在你的 bundle 加载之前就已经到达服务器了。每种宿主环境只需配置一次重写规则即可。Express 5 改变了它的路径匹配语法:现在每个通配符都必须命名,因此旧的全捕获写法 app.get('*') 会在启动时抛出 “Missing parameter name” 错误。请使用带花括号的命名通配符,它既能匹配根路径,也能匹配其下的所有路径:

// Express 5.x
app.get('/{*splat}', (req, res) => res.sendFile(__dirname + '/public/index.html'));
// Express 4.x used: app.get('*', ...)
# Nginx
location / { try_files $uri $uri/ /index.html; }
# Netlify — _redirects
/*  /index.html  200
// Vercel — vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }

分享出去的深链接在强制刷新时返回 404,是另一类在代码中看起来毫无问题、但当你亲眼看到真实会话落在空白页面上时就暴露无遗的故障。

安全。 只要有不可信数据被插值(上面的 ${p.id} 就直接来自 URL),用 innerHTML 注入视图就是一个 XSS 攻击面,而且它会静默丢弃注入标记上的事件监听器。请对插值内容做转义(如上面的 escapeHtml 调用)、用 document.createElement 构建节点,或者使用诸如 lit-html 这样的模板库,并通过在稳定的父元素上做事件委托来绑定行为,而不是绑定在被注入的节点上。由开发者编写、不含插值的静态模板字符串本身并不构成注入;风险来自你拼接进去的不可信数据。

平台的发展方向:Navigation API

Navigation API 于 2026 年 1 月达到 Baseline Newly available 状态(同月 Firefox 147 加入了支持),是这一模式正在崛起的继任者。你不再需要分别接好 pushStatepopstate 监听器和点击处理器,只需注册一个 navigate 监听器。它会在页面可感知的每一次导航时运行,无论导航由什么发起;在该监听器内部调用 event.intercept() 就能把地址栏和历史栈交给浏览器处理。它所解决的短板之一,正是 popstate 不会在编程式 pushState/replaceState 时触发——也正是本文这个路由器所要绕开的那个摩擦点。在它成为你目标浏览器的兼容性底线之前,History API 仍然是支持面最广的基线方案,也是理解路由器实际工作原理最清晰的途径。

现在你已经有了一个可运行的 History 模式路由器:路由表、参数匹配、委托点击、正确的 popstate 处理、初始化的首条历史记录,以及两项生产环境修正。下一个具体步骤是在部署之前,为你的宿主环境配置好服务器重写规则,让深链接能在刷新后依然可用。

常见问题

为什么在我的 SPA 中,按下「后退」按钮 URL 变了但页面没变?

因为 pushState 和 replaceState 不会触发 popstate 事件。如果你只在点击处理器内部渲染,而从未注册 popstate 监听器,那么「后退」和「前进」只会更新地址栏,不会重新渲染。解决办法是单独添加 window.addEventListener('popstate', resolve),让浏览器在历史记录间移动时都运行你的渲染函数。观看一段会话回放,你会看到 URL 发生变化而 DOM 毫无变化。

pushState 和 replaceState 有什么区别?

pushState 会向会话历史栈中新增一条记录,因此之前的视图仍可通过「后退」按钮到达。replaceState 则覆盖当前记录而不新增,因此不会产生新的「后退」目标。普通导航使用 pushState;在启动时初始化首页记录,或在不污染历史的前提下修正当前 URL,则使用 replaceState。两者共享相同的 (state, unused, url) 签名,且都不会触发 popstate。

hash 模式路由需要任何服务器配置吗?

不需要。hash 之后的片段,例如 '/app#/users' 中的 '/users',永远不会发送给服务器,因此基于 hash 的导航纯粹发生在客户端,在任何静态托管上都能工作,无需任何重写规则。刷新和深链接始终可以正常解析,因为服务器看到的永远只是 '/app'。History 模式则是一种取舍:它生成更简洁的 URL,但要求服务器把所有未知路径重写到 index.html,否则刷新会返回 404。

既然 Navigation API 已进入 Baseline,我还需要学 History API 吗?

需要。Navigation API 于 2026 年 1 月达到 Baseline Newly available 状态,是正在崛起的继任者,它用单个 navigate 事件和 event.intercept() 取代了手动的 pushState、popstate 和点击拦截。但 History API 仍然是兼容性最广的基线方案,能在 Navigation API 不支持的旧浏览器中工作,而且 React Router、Vue Router 这类框架至今仍在底层驱动它。学习它是理解任何路由器实际工作原理最清晰的途径。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.