哈士奇下午看到内部的技术文章,发现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'))
配好后流程变成:
- 用户刷新
/user/123 - 服务器匹配不到该路径,按
try_files规则返回index.html index.html加载 JS,前端路由读取location.pathname=/user/123- 渲染对应组件
还有个坑:用了 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 优势:
- 每个页面是独立可抓取的 URL,能被分别收录。
- 路径本身可以带关键词 (
/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"的线上风险点。技术选型看场景,不看新旧。