返回首页

前端路由原理:hash 与 history 两种模式,手写一个 mini 路由

SPA 切换页面不刷新,URL 还能变、还能前进后退,这是怎么做到的?这篇讲清前端路由的两种实现——hash 模式和 history 模式,并各自手写一个极简路由,最后说清 history 模式为什么需要服务端配合。

25 约 4 分钟 · 5120 字 前端

单页应用(SPA)有个神奇的地方:点击导航,页面内容变了、URL 也变了、浏览器前进后退也能用,但整个页面没有刷新。这背后就是"前端路由"。

它的核心任务只有两件事:在不刷新页面的前提下改变 URL,以及监听 URL 变化、渲染对应的视图。实现方式主要有两种:hash 模式和 history 模式。这篇把两者讲透,各手写一个迷你版。

为什么需要前端路由

传统多页应用,每次跳转都是浏览器向服务器请求一个新 HTML,整页刷新。SPA 则是首次加载一个 HTML + 一堆 JS,之后的"页面切换"全靠 JS 在客户端完成:根据 URL 决定渲染哪个组件,而不重新向服务器要页面。

要做到这点,需要解决两个技术问题:

  1. 怎么改变 URL 但不触发浏览器向服务器发请求?
  2. 怎么感知 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 提供了 pushStatereplaceState,可以直接修改 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"的诡异问题也会迎刃而解。