一行 # 的差别:彻底搞懂前端路由的 hash 和 history 模式

0 阅读6分钟

哈士奇下午看到内部的技术文章,发现history模式和hash模式竟然会影响浏览器的SEO,看到自己从没写过hash和history模式的文章,所以补充一下对这方面的了解并且写一篇文章出来给大家品鉴一下。

先看两个 URL,区别只有一个 #

hash 模式:     https://example.com/#/user/123
history 模式:  https://example.com/user/123

就这一个字符的差异,背后是两套完全不同的实现机制,以及一个能让你线上事故的部署坑。这篇把原理、手写实现、踩坑、选型一次讲清楚。

结论先行

  • 后台管理系统 / 内部工具 → 用 hash。零服务端配置,刷新永不 404,省心。
  • toC 官网 / 内容站 → 用 history。URL 干净、SEO 友好,但服务端必须配 fallback

下面解释为什么。

一、肉眼可见的区别:# 后面的东西不发给服务器

这是理解一切的基础。浏览器有个规定:URL 里 # 及其后面的内容(称为 fragment)不会被发送到服务器

所以当你访问 https://example.com/#/user/123 时,服务器实际只收到:

GET / HTTP/1.1
Host: example.com

#/user/123 这部分始终留在浏览器本地,根本没出门。这就是 hash 模式刷新永远不会 404 的根本原因——服务器永远只看到 /,只要首页能返回,刷新就一定能命中。

而 history 模式的 https://example.com/user/123,刷新时浏览器会老老实实把 /user/123 发给服务器:

GET /user/123 HTTP/1.1
Host: example.com

服务器上压根没有 /user/123 这个文件或路由,于是 404。后面会讲怎么解决。

二、手写一个 hash 路由

hash 模式靠的是 window.location.hash + hashchange 事件。改 hash 不会触发页面刷新,但会触发这个事件。

50 行实现一个能跑的 hash 路由:

class HashRouter {
  constructor(routes) {
    this.routes = routes // { '/home': renderFn, '/user': renderFn }
    this.current = ''

    // 监听 hash 变化(前进/后退/手动改地址栏都会触发)
    window.addEventListener('hashchange', () => this.handle())
    // 首次加载也要渲染一次
    window.addEventListener('load', () => this.handle())
  }

  handle() {
    // location.hash 形如 "#/user",去掉 # 号
    this.current = window.location.hash.slice(1) || '/'
    const render = this.routes[this.current]
    if (render) render()
  }

  push(path) {
    // 改 hash 即可,浏览器自动记录历史,不刷新页面
    window.location.hash = path
  }
}

// 使用
const router = new HashRouter({
  '/': () => (app.innerHTML = '首页'),
  '/user': () => (app.innerHTML = '用户页')
})
router.push('/user') // 地址变成 xxx/#/user,页面更新为"用户页"

关键点:

  • location.hash 不会刷新页面,但会往浏览器历史栈里压一条记录,所以前进/后退能用。
  • 监听 hashchange 就能感知所有变化(包括用户点浏览器后退按钮)。
  • 全程不和服务器打交道,这就是它"零配置"的来源。

三、手写一个 history 路由

history 模式靠 HTML5 的 history.pushState() / replaceState(),以及 popstate 事件。

class HistoryRouter {
  constructor(routes) {
    this.routes = routes

    // 监听浏览器前进/后退按钮
    window.addEventListener('popstate', () => this.handle())
    window.addEventListener('load', () => this.handle())
  }

  handle() {
    // 直接用 pathname,没有 # 了
    const path = window.location.pathname
    const render = this.routes[path]
    if (render) render()
  }

  push(path) {
    // pushState(state, title, url):改地址栏但不刷新、不请求服务器
    window.history.pushState(null, '', path)
    this.handle() // 注意:pushState 不会触发 popstate,要手动渲染
  }
}

这里有个容易被忽略的细节pushState 本身不会触发 popstate 事件,所以 push 之后必须手动调一次 handle()。而用户点浏览器的前进/后退按钮时,才会触发 popstate。这和 hash 模式"改 hash 就自动触发 hashchange"不一样。

更关键的问题来了:pushState 只是用 JS 欺骗了地址栏,让它显示 /user/123,但这个 URL 在服务器上并不真实存在。

  • SPA 内部跳转:没问题,JS 拦截了,不发请求。
  • 用户刷新 / 直接输入 / 分享链接打开:浏览器会真的向服务器请求 /user/123 → 服务器没有 → 404。

四、history 模式的服务端配置

解决办法只有一个思路:让服务器把所有"找不到的路径"都返回 index.html,然后由前端路由接管。

Nginx:

location / {
  # 依次尝试:真实文件 → 真实目录 → 都没有就返回 index.html
  try_files $uri $uri/ /index.html;
}

Node / Express:

const history = require('connect-history-api-fallback')
app.use(history())          // 必须放在静态资源中间件前面
app.use(express.static('dist'))

配好后流程变成:

  1. 用户刷新 /user/123
  2. 服务器匹配不到该路径,按 try_files 规则返回 index.html
  3. index.html 加载 JS,前端路由读取 location.pathname = /user/123
  4. 渲染对应组件

还有个坑:用了 history 模式后,如果应用不是部署在域名根目录(比如部署在 /admin/ 子路径),要同步设置打包工具的 base(Vite 的 base、Webpack 的 publicPath)和路由的 createWebHistory('/admin/'),否则资源路径会错乱。

五、为什么 hash 模式对 SEO 不友好

回到第一节那个核心事实:# 后面的内容不发给服务器。SEO 的问题全都从这里来。

搜索引擎爬虫抓取页面,本质就是对一个 URL 发 HTTP 请求、拿回 HTML。当爬虫去抓 hash 路由时:

爬虫请求:   https://example.com/#/article/vue-tutorial
服务器收到: GET /            ← # 后面的全没了
服务器返回: 永远是同一个首页 index.html

于是 #/article/a#/article/b#/article/c 在爬虫眼里全是同一个 URL(https://example.com/)的不同锚点位置,而不是三个独立页面。结果就是:

  • 整站几百个路由,搜索引擎只认得首页一个 URL,其余内容根本进不了索引库。
  • # 的原始语义本来就是"页内锚点定位",爬虫天然不把它当作不同页面来对待。

补充冷知识:Google 早年搞过 #!(hashbang + _escaped_fragment_)方案,让爬虫能抓 hash 路由,但已在 2015 年正式废弃,现在官方明确推荐 history 模式。所以别再指望靠 hashbang 救 SEO。

反观 history 模式,每个路由都是服务器能看见的真实路径:

爬虫请求:   https://example.com/article/vue-tutorial
服务器收到: GET /article/vue-tutorial   ← 完整路径

这带来两个 SEO 优势:

  1. 每个页面是独立可抓取的 URL,能被分别收录。
  2. 路径本身可以带关键词/article/vue-tutorial/#/p?id=123 对排名友好得多)。

更关键的是,history 模式才有可能做 SSR(服务端渲染)或预渲染——因为服务器能拿到具体路径,才能针对性地吐出这个页面专属的完整 HTML。而 hash 模式下服务器永远只看到 /,连做 SSR 的机会都没有,这是个无法绕过的天花板。

注意:纯 CSR(客户端渲染)的 SPA 即便用了 history 模式,服务器返回的还是同一个空壳 index.html,内容仍靠 JS 渲染。要真正吃满 SEO,得在 history 模式基础上叠加 SSR / 预渲染(如 Nuxt、vite-plugin-prerender)。history 是 SEO 的"必要条件",不是"充分条件"。

六、Vue Router 里的写法

原理搞懂了,框架里就是一行配置的事:

import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'

const router = createRouter({
  // hash 模式
  history: createWebHashHistory(),
  // history 模式
  history: createWebHistory(),
  routes
})

React Router 同理,HashRouter 对应 hash,BrowserRouter 对应 history。

七、对比总结

维度Hash 模式History 模式
URL 形式#/#/user干净(/user
底层 APIlocation.hash + hashchangepushState + popstate
是否请求服务器# 后内容永不发送刷新时会请求完整路径
服务端配置不需要必须配 fallback
刷新 404 风险有(漏配就出事)
SEO差(# 后内容不被索引)
兼容性极好(IE8+)需要 HTML5(IE10+)

八、怎么选

把上面的对比浓缩成一句决策:

  • 不在乎 SEO、不想碰服务端配置 → hash。后台系统、内部工具、Electron 应用、企业中台,闭眼选 hash。
  • 需要 SEO、要求 URL 美观、且能掌控服务端配置 → history。官网、博客、电商、落地页。

一个常见的误区是"history 更高级所以更好"。其实对一个内网管理系统来说,history 带来的 SEO 和美观毫无价值,反而平白多了一个"刷新 404"的线上风险点。技术选型看场景,不看新旧。