从后端视角看前端路由:Hash 与 History 的原理、差异与部署要点
为什么后端也要懂前端路由
在单页应用(SPA)模式下,页面结构与切换逻辑主要在前端完成,但是否能“刷新不 404”、是否“可 SEO”、是否“URL 优雅”、以及“生产环境如何配置服务端”都直接依赖后端。
不了解前端路由机制,常见线上事故包括:用户刷新子路由直接 404、静态资源被错误回退到 index.html
、API 404 被 “吃掉” 等。
注解:SPA 的核心是“前端拦截导航”。URL 会变,但不发起整页请求;前端根据 URL 渲染对应组件。问题在于“刷新/直达”时浏览器仍会向后端请求该路径,这正是 History 路由与后端需配合的根源。
前端路由是什么
传统 MPA:每个页面一个 HTML,URL 变化 => 浏览器发起新请求 => 服务器返回新文档。
SPA:只有一个
index.html
作为壳;URL 变化由前端接管,前端在当前文档内渲染“新页面”。两种实现路径:
- Hash 路由:利用
#
片段(location.hash
),通过hashchange
事件驱动渲染。 - History 路由:利用 HTML5 的
history.pushState/replaceState
与popstate
事件。
- Hash 路由:利用
注解:Hash 变更不会触发浏览器向服务器发请求;History 直达/刷新会请求对应路径,因此 History 需要服务端兜底回
index.html
。
Hash 路由(# 路由)详解
核心原理
- URL 形如:
https://example.com/#/orders/123
- 修改
#
后的片段不会触发整页请求;前端监听hashchange
自行渲染。
极简实现(示意):
const routes = {
'/': () => renderHome(),
'/orders': () => renderOrders(),
'/orders/:id': (id) => renderOrderDetail(id),
};
function parseHash() {
const path = location.hash.slice(1) || '/';
// 省略:简单参数匹配
matchAndRender(routes, path);
}
window.addEventListener('hashchange', parseHash);
window.addEventListener('DOMContentLoaded', parseHash);
优缺点
优点
- 无需后端特殊配置,刷新永不 404(请求始终落到
index.html
)。 - 兼容性强(历史包袱大的环境更稳)。
- 无需后端特殊配置,刷新永不 404(请求始终落到
缺点
- URL 带
#
不够优雅,SEO 普遍较差(片段不参与标准 HTTP 请求与响应)。 - 统计、监控对
#
片段的采集可能需要额外处理。
- URL 带
注解:OAuth Implicit Flow 常把
access_token
放在#
片段里返回(避免泄露给服务端),Hash 模式天然契合这类回调处理。
适用场景
- 内部系统、对 SEO 不敏感、快速上线、后端不想配任何额外规则时。
History 路由(HTML5 路由)详解
核心原理
- URL 形如:
https://example.com/orders/123
- 前端通过
history.pushState()
修改地址栏、拦截点击;浏览器 后退/前进 触发popstate
,页面在前端渲染。 - 刷新/直达:浏览器会向服务端请求
/orders/123
。若服务端无此物理文件且不回退到index.html
,就会 404。
极简实现(示意):
function goto(path) {
history.pushState({}, '', path);
render(path); // pushState 不会触发 popstate,需要主动渲染
}
window.addEventListener('popstate', () => render(location.pathname));
window.addEventListener('DOMContentLoaded', () => render(location.pathname));
// 拦截站内链接点击
document.addEventListener('click', e => {
const a = e.target.closest('a[href^="/"]');
if (!a) return;
e.preventDefault();
goto(a.getAttribute('href'));
});
优缺点
优点
- URL 优雅、更接近传统网站体验;对 SEO 更友好(配合 SSR/预渲染最佳)。
- 与服务端日志、统计、A/B 平台兼容性好(URL 即路径)。
缺点
- 需要后端配置回退到
index.html
(且要避免误伤/api
与静态资源)。 - 需要现代浏览器(IE9+ 基本可用,但历史环境要评估)。
- 需要后端配置回退到
注解:
pushState/replaceState
不会触发popstate
;只有“后退/前进”触发。因此手动导航后要主动渲染。
Hash vs History:对照速览
维度 | Hash 路由 | History 路由 |
---|---|---|
URL 形态 | /#!/users 或 /#/users |
/users |
刷新行为 | 不发起新页面请求 | 会请求 /users (需服务端兜底) |
服务端配置 | 基本不需要 | 必须 try_files ... /index.html |
SEO | 较差 | 较好(配合 SSR/预渲染最佳) |
兼容性 | 旧环境稳 | 现代环境 OK |
统计/监控 | 需处理 # 片段 |
开箱即用 |
OAuth 隐式回调 | 天然适配 | 也可,但更常见在 Hash 场景 |
复杂度 | 低 | 中(服务端+CDN 需要细化规则) |
注解:有些项目“双模”:开发/预发用 Hash(省配置),生产切 History(SEO 友好、URL 优雅),迁移时注意服务端路由与静态资源路径。
生产部署要点
Nginx
server {
listen 80;
server_name example.com;
root /var/www/app; # SPA 构建产物目录,含 index.html
index index.html;
# 先放 API,避免被 SPA 回退“吃掉”
location /api/ {
proxy_pass http://127.0.0.1:8080;
}
# 静态资源:强缓存 + 不回退
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# 仅对 HTML 导航进行回退
location / {
try_files $uri $uri/ /index.html;
}
}
注解:顺序非常重要:
/api/
必须在前;静态资源必须=404
,否则 404 资源会被错误回退到index.html
,导致前端报Unexpected token <
(拿到 HTML 当 JS/CSS 解析)。
进一步精细化(只对 Accept=html 的请求回退)
map $http_accept $is_html {
default 0;
"~*text/html" 1;
}
location / {
if ($is_html) {
try_files $uri $uri/ /index.html;
}
# 非 HTML(如 XHR 请求、文件下载)按正常 404 处理
try_files $uri =404;
}
Caddy(更现代的配置体验)
example.com {
root * /var/www/app
encode zstd gzip
@api path /api/*
reverse_proxy @api http://127.0.0.1:8080
@static {
path *.js *.css *.png *.jpg *.jpeg *.gif *.svg *.ico *.woff *.woff2
}
header @static Cache-Control "public, max-age=31536000, immutable"
file_server
@spa {
not path /api/*
not path *.js *.css *.png *.jpg *.jpeg *.gif *.svg *.ico *.woff *.woff2
}
rewrite @spa /index.html
}
Go 原生 net/http
mux := http.NewServeMux()
// 1) API 放在前面
mux.Handle("/api/", http.StripPrefix("/api", apiHandler()))
// 2) 静态资源:保持原样
fs := http.FileServer(http.Dir("dist"))
mux.Handle("/assets/", fs) // 例如构建产物里的静态目录
// 3) SPA 回退:只对 HTML 导航生效
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 命中真实文件则直接返回
if _, err := os.Stat(filepath.Join("dist", r.URL.Path)); err == nil {
http.ServeFile(w, r, filepath.Join("dist", r.URL.Path))
return
}
// 仅对 GET + Accept: text/html 回退到 index.html
if r.Method == http.MethodGet && strings.Contains(r.Header.Get("Accept"), "text/html") {
http.ServeFile(w, r, "dist/index.html")
return
}
http.NotFound(w, r)
})
log.Fatal(http.ListenAndServe(":80", mux))
Gin
r := gin.Default()
// API
r.Any("/api/*path", apiHandler)
// 静态资源
r.Static("/assets", "./dist/assets")
// SPA 回退
r.NoRoute(func(c *gin.Context) {
accept := c.GetHeader("Accept")
if c.Request.Method == http.MethodGet &&
strings.Contains(accept, "text/html") &&
!strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.File("./dist/index.html")
return
}
c.Status(http.StatusNotFound)
})
r.Run(":80")
GoFrame
s := g.Server()
// API
s.Group("/api", func(g *ghttp.RouterGroup) {
g.ALL("/*", ApiHandler)
})
// 静态资源
s.SetServerRoot("dist")
// SPA 回退(仅对 HTML 导航)
s.BindHookHandler("/*", ghttp.HookBeforeServe, func(r *ghttp.Request) {
p := r.URL.Path
// 命中文件则直接返回
if _, err := os.Stat(filepath.Join("dist", p)); err == nil {
return
}
// 仅对 HTML 导航回退
if r.Method == "GET" &&
strings.Contains(r.Header.Get("Accept"), "text/html") &&
!strings.HasPrefix(p, "/api/") {
r.Response.ServeFile("dist/index.html")
r.ExitAll()
}
})
s.Run()
注解:不要用“全局 404 回 index.html” 的偷懒做法;会把
/api
的真实 404 也吃掉,导致客户端难以发现接口错误。
部署细节与易踩坑
子目录部署与
<base>
标签
若应用部署在子路径/app
,History 模式需在index.html
中设置:<base href="/app/">
并确保服务端回退到
/app/index.html
,静态资源路径也以/app
为前缀。注解:漏配
<base>
会导致相对路径资源 404、路由跳转错位。资源缓存策略
- 构建产物文件名带 hash(如
app.83f2.js
),静态资源可immutable
强缓存。 index.html
不建议长缓存(随发布变更,需要被及时拉取)。
- 构建产物文件名带 hash(如
仅对 HTML 导航回退
- 根据
Accept: text/html
与GET
方法判断是否为“页面导航”。 - 避免把 XHR、
fetch()
、下载等请求回退到 HTML。
- 根据
Service Worker 与路由
- SW 也可拦截导航并回退
index.html
,但建议 先把服务端兜底配好,再引入 SW 以降低复杂度。
- SW 也可拦截导航并回退
滚动与定位
- History 模式可使用
history.scrollRestoration = 'manual'
自行控制滚动。 - Hash 模式的
#id
会触发原生锚点滚动,注意与前端路由滚动逻辑的耦合。
- History 模式可使用
State 体积
pushState
的state
对象会被历史栈保存,不要塞大对象(可能影响性能与稳定)。
选型与决策清单
- 需要优雅 URL、SEO、与日志/风控/埋点天然对齐 → History,但你必须能配好反向代理/网关/CDN 路由与缓存细则。
- 内部系统、赶进度、环境复杂或历史包袱重 → Hash,免配置、可快速上线。
- 分阶段上线:测试/预发用 Hash;生产切 History(逐步完善服务端兜底与监控)。
常见问题
症状 | 可能原因 | 快速定位 | 解决 |
---|---|---|---|
刷新子路由 404 | History 未做兜底 | 直接访问 /users |
按上文配置 try_files ... /index.html |
JS/CSS 报 Unexpected token < |
把静态资源 404 回退到 index.html |
网络面板看返回体是 HTML | 静态资源 try_files $uri =404 ,不要回退 |
API 返回的是 HTML | /api 被 SPA 回退匹配 |
访问 /api/xxx 看返回 |
先匹配 /api 再做 SPA 回退 |
子目录部署资源 404 | 缺少 <base> 或路由前缀 |
查看请求路径前缀 | <base href="/app/"> + 服务端路由前缀 |
SEO 无效果 | 纯 CSR 渲染 | 观察首屏 HTML | 引入 SSR/预渲染(仅 History 真正受益明显) |
对比
维度 | Hash | History |
---|---|---|
服务端改造 | 无 | 必须兜底到 index.html ,并做 API/静态资源排除 |
刷新/直达 | 永不 404 | 会请求真实路径(必须兜底) |
URL 美观 | 一般(含 # ) |
优雅(类传统站点) |
SEO/SSR | 弱 | 强(配 SSR/预渲染) |
风险点 | 统计与回调处理细节 | 配置错误最易出线上事故 |
迁移难度 | 低 | 中(含基础设施协同) |
典型用途 | 内部系统、低成本上线 | 面向 C 端、品牌/SEO 要求高 |
总结
- Hash 路由:前端自给自足,后端几乎零配合;代价是 URL 与 SEO。
- History 路由:用户体验与 SEO 友好,但刷新/直达必须有 服务端兜底,并和 API、静态资源做好“分流”。
- 对 Go 后端来说,掌握本文几段 Nginx/Caddy/Gin/GoFrame 样例配置,就能把前端 History 路由稳定落地,避免 404 与静态资源回退等典型坑。
注解:真正上线前,务必按“直接打开深链 / 刷新 / 后退前进 / 断网重试 / 缓存与版本回滚”五类场景做灰度验证;把静态资源 404 与 API 404 留在它们该在的地方,把
index.html
兜底只用于“HTML 导航”。