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

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

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

bash 复制代码
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 时,服务器实际只收到:

vbnet 复制代码
GET / HTTP/1.1
Host: example.com

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

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

sql 复制代码
GET /user/123 HTTP/1.1
Host: example.com

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

二、手写一个 hash 路由

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

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

js 复制代码
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 事件。

js 复制代码
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:

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

Node / Express:

js 复制代码
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 路由时:

bash 复制代码
爬虫请求:   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 模式,每个路由都是服务器能看见的真实路径:

bash 复制代码
爬虫请求:   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 里的写法

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

js 复制代码
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
底层 API location.hash + hashchange pushState + popstate
是否请求服务器 # 后内容永不发送 刷新时会请求完整路径
服务端配置 不需要 必须配 fallback
刷新 404 风险 有(漏配就出事)
SEO 差(# 后内容不被索引)
兼容性 极好(IE8+) 需要 HTML5(IE10+)

八、怎么选

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

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

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

相关推荐
羊羊小栈1 小时前
非物质文化宣传系统(基于前后端Web开发)
前端·人工智能·毕业设计·大作业
环信1 小时前
从SLA到弱网对抗-环信即时通讯云的可靠性工程
前端
半个落月1 小时前
前端工程化第一步:BEM 国际命名规范与 CSS Reset 实战
前端·css
kyriewen1 小时前
开源|Image Harvest v1.0.5:AI 智能标签 + Eagle 导出,设计师和开发者的图片工作流神器
前端·javascript·ai编程
wuhen_n2 小时前
LangChain Memory 详解:实现 AI 连续对话不丢失上下文
前端·langchain·ai编程
wuhen_n2 小时前
LangChain Function Call 实战:让 AI 调用自定义工具
前端·langchain·ai编程
DyLatte2 小时前
很多人把坚持,误以为成长
前端·后端·程序员
yingyima2 小时前
凌晨3点的警报声:定时任务监控与告警的最佳实践
前端
zach2 小时前
React中的兄弟通讯之发布订阅模式
前端·react.js