单页应用(SPA)之所以能在不刷新整页的情况下切换页面,靠的就是前端路由。无论你用 Vue Router 还是 React Router,底层只有两条路:Hash 路由(URL 里带 #)和 History 模式(干净的 /user/profile 路径)。很多新手在部署后遇到刷新 404、白屏、Nginx 配置踩坑,本质都是没吃透这两种模式的原理差异。本文把它们的机制、部署代价和选型一次讲透。
一、前端路由到底解决了什么
在传统的多页应用(MPA)里,每次点链接浏览器都会向服务端请求一个完整 HTML,整页刷新。单页应用(SPA)则只在首次加载时拿到一个 HTML 壳,之后所有“页面切换”都由 JavaScript 拦截、动态替换 DOM 局部内容——这件事的总指挥就是前端路由。它把 URL 和视图绑定起来:URL 变了,渲染对应组件;用户点后退,URL 回退、视图跟着回退。理解前端路由,关键就看两件事:URL 怎么变、谁在监听这个变化。
为什么不用后端渲染、每次都发请求?因为整页刷新意味着重新下载 JS/CSS、重新执行脚本、重新建立状态,体验卡顿且无法保持滚动位置和表单填写进度。前端路由把“视图”这件事搬到了浏览器里,只有数据才走网络,于是切换像原生 App 一样顺滑。代价是:URL 与真实文件之间出现了“映射关系”,而这个映射要么藏在 # 后面(服务端看不见),要么写在干净路径里(服务端必须配合兜底)——这正是 Hash 与 History 两种模式分道扬镳的根本原因。
二、Hash 模式:最省心但 URL 不优雅
Hash 模式把路由信息放在 # 后面,例如 https://example.com/#/user/123。井号本是页面锚点,所以 # 之后的内容不会发给服务器,也不会触发整页刷新。这让它天生“不怕刷新”。
原理:hashchange 事件
浏览器原生提供 hashchange 事件和 location.hash 属性,改变 location.hash 或点击带 # 的链接都会触发 hashchange,路由库据此渲染对应视图。下面 10 行代码就是一个能跑的极简路由:
const routes = {
'/': () => '<h1>首页</h1>',
'/about': () => '<h1>关于</h1>',
};
function render() {
const path = location.hash.slice(1) || '/';
document.getElementById('app').innerHTML =
routes[path] ? routes[path]() : '<h1>404</h1>';
}
window.addEventListener('hashchange', render);
render();
Hash 模式的优缺点
| 维度 | 表现 |
|---|---|
| 部署成本 | 任意静态服务器即可,刷新不 404 |
| URL 美观 | 带 #,观感与 SEO 偏弱 |
| 服务端改造 | 零,最省心 |
| 锚点冲突 | 与页面内锚点共用 #,长页面需注意 |
三、History 模式:干净 URL 背后的代价
History 模式借助 HTML5 History API 的 pushState / replaceState 改写路径,URL 变成 https://example.com/user/123,没有 #,观感和 SEO 都更好,是绝大多数对外产品的首选。
原理:pushState 与 popstate
pushState 改变地址栏但不刷新页面;前进后退触发 popstate,由我们重新渲染。点击链接时要手动阻止默认跳转并 pushState:
这里有个容易忽略的细节:pushState(state, title, url) 的第一个参数 state 可以塞任意对象,前进后退时通过 event.state 取回,用来恢复滚动位置、分页或筛选条件,避免页面“退回去却丢了上下文”。title 目前主流浏览器已忽略,不必纠结。真正要小心的有两点:一是 pushState 只改 URL、不触发 popstate,所以点击导航和浏览器按钮要各自绑定渲染逻辑;二是单页应用只有一个 HTML 入口,所有未知路径都得由服务端兜底到这个入口,否则直接访问深层链接必然 404。
const routes = { '/': home, '/about': about };
document.addEventListener('click', (e) => {
const a = e.target.closest('a[data-link]');
if (!a) return;
e.preventDefault();
history.pushState({}, '', a.pathname);
render(location.pathname);
});
window.addEventListener('popstate', () => render(location.pathname));
function render(path) { /* 按 path 渲染对应组件 */ }
核心痛点:刷新就 404
关键点来了:History 模式下 /user/123 是前端“假”路径,服务端根本没有这个文件。用户直接访问或刷新时,浏览器真的向服务端请求 /user/123,静态服务器找不到就返回 404。这就是部署后白屏、刷新 404 的根因,和代码本身没关系。
服务端兜底:全部回退到 index.html
解决办法是让服务端对“未知路径”一律返回 index.html,再由前端路由接管。Nginx 用 try_files 即可:
location / {
try_files $uri $uri/ /index.html;
}
注意 $uri/ 是为了兼容真实存在的目录资源,最后的 /index.html 才是兜底。把顺序写反,常导致静态资源也被兜底逻辑“吞掉”。完整 server 片段:
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
# API 不走兜底,直连后端
location /api/ {
proxy_pass http://backend;
}
}
| 维度 | 表现 |
|---|---|
| 部署成本 | 需服务端兜底,否则刷新 404 |
| URL 美观 | 干净,利于 SEO |
| 服务端改造 | 需 try_files 或等价配置 |
| 锚点 | 不再占用 #,页面内锚点正常 |
四、两种模式怎么选
| 维度 | Hash | History |
|---|---|---|
| SEO | 弱(带 #) | 强 |
| 部署成本 | 零 | 需兜底 |
| URL 美观 | 带 # | 干净 |
| 老旧内核兼容 | 好 | 个别旧 WebView 有坑 |
| 适用场景 | 内网/快速原型 | 官网/对外产品 |
一句话总结:对外产品、重视 SEO 和品牌感用 History 模式;内部系统、快速验证用 Hash 模式更省心。没有绝对优劣,只有场景适配。
还有一个现实因素:Hash 模式因为 # 后的内容永远不发往服务端,在微信内嵌浏览器、老旧 WebView、以及任何你不方便改服务端配置的环境里都“开箱即用”,这也是为什么大量后台系统和 H5 活动页默认选它。而 History 模式一旦服务端兜底没配好,所有分享出去的深层链接都会变成死链,对拉新和品牌伤害很大。所以选型时请同步评估“我能不能改服务端配置”——能改且对外,果断 History;改不动或纯内部,Hash 更稳。
五、Vue Router / React Router 怎么配
现代框架把两种模式封装好了,切换只是改一行工厂函数。Vue Router 4 用 createWebHashHistory 与 createWebHistory,React Router 6 用 HashRouter 与 BrowserRouter:
// Vue Router 4
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router';
// Hash 模式
const router = createRouter({ history: createWebHashHistory(), routes });
// History 模式
const router = createRouter({ history: createWebHistory(), routes });
// React Router 6
import { BrowserRouter, HashRouter } from 'react-router-dom';
// History 模式
<BrowserRouter><App /></BrowserRouter>
// Hash 模式
<HashRouter><App /></HashRouter>
关键细节:createWebHistory() 在子路径部署时要传 base,例如 createWebHistory('/app/'),否则资源与路由前缀会错乱。React Router 同理需在构建工具里设置 basename。
六、生产部署 Checklist
上线前对照这四项,能避开 90% 的路由部署事故:① 服务端 try_files 兜底到 index.html;② 带哈希文件名的静态资源优先命中,别被兜底逻辑吞掉;③ 子路径部署统一设置 base/basename;④ 监控兜底后的真实路由命中,别让死链静默通过。子路径部署示例:
# 构建侧:vite.config.ts
export default { base: '/app/' }
# Nginx 对应
location /app/ {
try_files $uri $uri/ /app/index.html;
}
七、常见坑与排障
下面这些坑几乎每个前端都在生产环境踩过一遍,按出现频率从高到低列出来。遇到白屏先别怀疑框架,90% 的情况回到“URL 是谁在管、服务端有没有兜底”这两条就能定位。
- 刷新 404:九成是
try_files没配或顺序写反,确认兜底在最后一级。 - base 路径错:History 子路径部署忘了设 base,刷新后资源 404、路由整体错乱。
- SSR 与 History 冲突:Nuxt / Next 自带服务端路由,别再叠加前端路由库,否则双重路由打架。
- 旧内核兼容:极个别老 Android WebView 对
pushState支持不完整,必要时降级 Hash 模式。 - 锚点被吞:History 模式下页面内
#section跳转需自己处理,否则会被当成路由。
八、小结
前端路由是 SPA 的地基。Hash 模式胜在零部署、稳;History 模式胜在干净、利于 SEO,代价是服务端兜底。选型看场景,部署记得 try_files,子路径记得 base。吃透这两点,刷新 404 和白屏就不再是玄学。相关延伸可看 前端缓存策略实战、前端跨域 CORS 完整指南、WebSocket 实时通信实战 与 GraphQL API 实战,把连接、缓存、跨域与路由串成完整的前端工程化认知。



