单页应用(SPA)有个神奇的地方:点击导航,页面内容变了、URL 也变了、浏览器前进后退也能用,但整个页面没有刷新。这背后就是"前端路由"。
它的核心任务只有两件事:在不刷新页面的前提下改变 URL,以及监听 URL 变化、渲染对应的视图。实现方式主要有两种:hash 模式和 history 模式。这篇把两者讲透,各手写一个迷你版。
为什么需要前端路由
传统多页应用,每次跳转都是浏览器向服务器请求一个新 HTML,整页刷新。SPA 则是首次加载一个 HTML + 一堆 JS,之后的"页面切换"全靠 JS 在客户端完成:根据 URL 决定渲染哪个组件,而不重新向服务器要页面。
要做到这点,需要解决两个技术问题:
- 怎么改变 URL 但不触发浏览器向服务器发请求?
- 怎么感知 URL 的变化(包括用户点前进/后退),从而切换视图?
hash 和 history 就是这两个问题的两套答案。
hash 模式
URL 里 # 后面的部分叫 hash(锚点)。它有一个关键特性:改变 hash 不会让浏览器向服务器发请求(它本来是用来页面内锚点跳转的)。
https://example.com/#/home
https://example.com/#/about#/home 和 #/about 对服务器来说是同一个地址(https://example.com/),服务器根本看不到 # 后面的内容。所以改 hash 天然就是"改 URL 不刷新"。
而 hash 的变化可以通过 hashchange 事件监听到:
window.addEventListener('hashchange', () => {
console.log('hash 变成了', location.hash)
})手写一个 hash 路由
class HashRouter {
constructor() {
this.routes = {} // path -> 回调
window.addEventListener('hashchange', () => this.handle())
window.addEventListener('load', () => this.handle())
}
// 注册路由
on(path, callback) {
this.routes[path] = callback
return this
}
// 根据当前 hash 渲染
handle() {
const path = location.hash.slice(1) || '/' // 去掉开头的 #
const callback = this.routes[path]
if (callback) callback()
else console.warn('未匹配到路由', path)
}
// 编程式跳转
push(path) {
location.hash = path // 改 hash 会触发 hashchange
}
}用起来:
const router = new HashRouter()
const app = document.getElementById('app')
router
.on('/', () => (app.innerHTML = '<h1>首页</h1>'))
.on('/about', () => (app.innerHTML = '<h1>关于</h1>'))
// 点击导航
document.querySelectorAll('a[data-link]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault()
router.push(a.getAttribute('href'))
})
})hash 模式的优点是简单、不需要服务端任何配合,刷新也不会 404。缺点是 URL 里带个 # 不太美观,而且 hash 本来的"页面内锚点定位"功能会和路由冲突。
history 模式
HTML5 的 History API 提供了 pushState 和 replaceState,可以直接修改 URL 的路径部分(没有 #),而且同样不触发页面刷新:
// 添加一条历史记录,URL 变成 /about,但不刷新
history.pushState({ page: 'about' }, '', '/about')
// 替换当前历史记录(不新增)
history.replaceState({ page: 'home' }, '', '/home')这样 URL 就是干净的 https://example.com/about,和真实路径一样。
监听变化稍微复杂一点。要分两种情况:
- 用户点浏览器的前进/后退:触发
popstate事件。 - 代码主动
pushState/replaceState:不会触发popstate(这是个重要的坑),需要我们自己在调用后手动触发渲染。
window.addEventListener('popstate', () => {
console.log('前进或后退了', location.pathname)
})手写一个 history 路由
class HistoryRouter {
constructor() {
this.routes = {}
// 监听前进/后退
window.addEventListener('popstate', () => this.handle())
}
on(path, callback) {
this.routes[path] = callback
return this
}
handle() {
const path = location.pathname
const callback = this.routes[path]
if (callback) callback()
else console.warn('未匹配到路由', path)
}
push(path) {
history.pushState(null, '', path) // 改 URL,不刷新
this.handle() // pushState 不触发 popstate,必须手动渲染
}
// 启动时渲染当前路径
start() {
this.handle()
}
}用法和 hash 版几乎一样:
const router = new HistoryRouter()
const app = document.getElementById('app')
router
.on('/', () => (app.innerHTML = '<h1>首页</h1>'))
.on('/about', () => (app.innerHTML = '<h1>关于</h1>'))
document.querySelectorAll('a[data-link]').forEach((a) => {
a.addEventListener('click', (e) => {
e.preventDefault()
router.push(a.getAttribute('href'))
})
})
router.start()history 模式的关键坑:需要服务端配合
这是 history 模式最容易踩的坑,也是面试高频点。
hash 模式下,所有 URL 对服务器都是 https://example.com/,服务器永远返回那个 HTML,没问题。
但 history 模式下,URL 是真实的 https://example.com/about。如果用户直接在这个地址刷新(或者把链接发给别人打开),浏览器会真的向服务器请求 /about 这个路径。而服务器上根本没有 /about 这个文件或路由 → 返回 404。
解决办法:配置服务器,把所有未匹配到静态资源的请求,都回退到 index.html。这样不管访问哪个前端路由,服务器都返回那个 SPA 的入口 HTML,再由前端路由接管渲染。
Nginx 的典型配置:
location / {
try_files $uri $uri/ /index.html;
}意思是:先找有没有对应的文件/目录,找不到就返回 index.html。这是 history 模式部署 SPA 的标配。
两种模式对比
| hash 模式 | history 模式 | |
|---|---|---|
| URL 形态 | 带 #,如 /#/about |
干净,如 /about |
| 改 URL 的方式 | location.hash |
pushState / replaceState |
| 监听变化 | hashchange 事件 |
popstate + 手动触发 |
| 刷新会 404 吗 | 不会 | 会,需服务端回退到 index.html |
| 服务端配合 | 不需要 | 需要 |
| SEO | 较差 | 较好(URL 干净) |
框架路由其实也是这套
Vue Router、React Router 这些成熟方案,本质上也是基于上面两种机制,只是封装了更多能力:
- 动态路由匹配(
/user/:id)和参数解析 - 嵌套路由
- 导航守卫(权限拦截、登录校验)
- 路由懒加载(按需加载组件)
- 滚动行为控制
Vue Router 里 createWebHashHistory() 对应 hash 模式,createWebHistory() 对应 history 模式——名字直接对上了上面讲的原理。理解了底层,你就知道为什么 Vue/React 项目部署后"刷新子页面 404",以及该去 Nginx 加那条 try_files。
总结
前端路由的本质是用 JS 在客户端模拟页面跳转,核心是两件事:改 URL 不刷新、监听 URL 渲染视图。
- hash 模式:靠
#不发请求的特性 +hashchange事件,简单、无需服务端配合,但 URL 带#。 - history 模式:靠
pushState改路径 +popstate监听,URL 干净,但刷新会 404,必须配try_files回退到 index.html。
把这两套手写一遍,框架路由对你来说就不再是黑盒,部署时那些"刷新就 404"的诡异问题也会迎刃而解。