history 模式与 hash 模式
SPA 把「当前路由」写进地址栏有两套写法。它们看起来只是 URL 长得不同,真正的分水岭是一个底层事实:URL 里 # 后面那段(fragment,也叫 hash)不会被发送给服务端。这一个事实决定了两种模式在「直接打开或刷新一个深层 url」时命运完全不同。
/products/42)、右为 hash 模式(/#/products/42)。可切换路由并按「此刻刷新」,看服务端分别收到什么请求、结果是 200 还是 404,也可勾 fallback 开关看 history 模式如何恢复 200。hash 模式刷新任何深层 url,浏览器实际只向服务端要 GET /,永远命中 index.html、永远 200,深层路由全在前端读 location.hash 自己解。history 模式则把 /products/42 原样发给服务端,而服务端路由表里根本没有这条,不配 fallback 就是 404。
1 · history 模式的代价
干净的 /products/42 对可读性、SEO 与分享都更友好,所以现代 SPA 默认都用 history 模式。代价只有一条:服务端必须配一句 fallback rewrite,任何没被 /api/* 等真实路由接住的 path 一律 rewrite 到 index.html,把路由判断交还给前端 router。这正是分享入口讲 SPA 接入时的第三条服务端路由。
前端两种模式读「当前路由」的差别就在这几行:history 用 pathname 加 popstate,hash 用 location.hash 加 hashchange。
// history 模式: 路由写在 path 里, 用 pushState 改, 监听 popstate
function currentRoute() { return location.pathname + location.search; }
addEventListener('popstate', () => router.render(currentRoute()));
router.navigate = (to) => { history.pushState(null, '', to); router.render(to); };
// hash 模式: 路由写在 # 后面, 改 location.hash 即可, 监听 hashchange
function currentRoute() { return location.hash.slice(1) || '/'; } // 去掉开头的 #
addEventListener('hashchange', () => router.render(currentRoute()));
router.navigate = (to) => { location.hash = to; }; // 浏览器自动发 hashchange
服务端这边,history 模式必须的就是那句 fallback(以 nginx try_files 为例):
# history 模式: 真实接口照常走, 其余「文件不存在」的请求一律回 index.html
# —— 把路由判断交还给前端 router (这就是 SPA fallback)
location /api/ { proxy_pass http://backend; } # 真实接口: 反代给后端
location / {
try_files $uri $uri/ /index.html; # 找不到对应文件 → 回 index.html
}
# hash 模式不需要这句: 浏览器永远只请求 "/", 服务端给 index.html 就够了。
建议 · hash 模式在没有服务端、或不想配置服务端时反而是对的:纯静态托管(GitHub Pages、对象存储)、Electron 或本地 file:// 打开、嵌在别人页面里的小工具。这些场景下让服务端给每条深链都回 index.html 要么做不到、要么不划算,而 hash
模式零服务端配置就能跑,这是它今天唯一但很实在的存活理由。
history 模式改地址用的就是客户端路由那个 pushState,本页只是追问改完之后刷新一下会怎样。至于 #fragment 为什么不上服务端,URL Anatomy 系列把 fragment 的「只在客户端」语义讲得最透。