使用 History API 构建客户端路由
构建原生 History API 路由器,涵盖 pushState、popstate、动态参数、SEO 友好 URL,以及刷新 404 和 XSS 防护。
客户端路由通过更新 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 |
| 变更事件 | hashchange | popstate |
| 服务器配置 | 无需 | 将所有路径重写到 index.html |
| 刷新 / 深链接 | 始终可用 | 未配置重写时返回 404 |
| SEO / 可分享 URL | 较弱 | 更简洁,更受推荐 |
由于 URL 简洁且可被索引,History 模式是默认选择,也是本文将要构建的方案。唯一的代价是它需要服务器支持,下文会讲到。
你真正需要的 History API 原语
Discover how at OpenReplay.com.
支撑一个 History 模式路由器的有三个原语。history.pushState(state, unused, url) 会向会话历史栈中添加一条记录并改变地址栏;history.replaceState 做同样的事,但它覆盖当前记录而不是新增一条。location.pathname 用于读取当前路径,以便你匹配路由。当用户按下「后退」或「前进」时,会触发 popstate 事件。
关键规则:pushState 和 replaceState 不会触发 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) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 加入了支持),是这一模式正在崛起的继任者。你不再需要分别接好 pushState、popstate 监听器和点击处理器,只需注册一个 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 这类框架至今仍在底层驱动它。学习它是理解任何路由器实际工作原理最清晰的途径。