【JavaScript】前端两种路由模式,Hash路由,History 路由
从后端视角看前端路由: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.htmlindex 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/appencode 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.htmlif 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 导航”。