前端路由原理与实战:Hash 与 History 模式选型

单页应用(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 或等价配置
锚点不再占用 #,页面内锚点正常

四、两种模式怎么选

维度HashHistory
SEO弱(带 #)
部署成本需兜底
URL 美观带 #干净
老旧内核兼容个别旧 WebView 有坑
适用场景内网/快速原型官网/对外产品

一句话总结:对外产品、重视 SEO 和品牌感用 History 模式;内部系统、快速验证用 Hash 模式更省心。没有绝对优劣,只有场景适配。

还有一个现实因素:Hash 模式因为 # 后的内容永远不发往服务端,在微信内嵌浏览器、老旧 WebView、以及任何你不方便改服务端配置的环境里都“开箱即用”,这也是为什么大量后台系统和 H5 活动页默认选它。而 History 模式一旦服务端兜底没配好,所有分享出去的深层链接都会变成死链,对拉新和品牌伤害很大。所以选型时请同步评估“我能不能改服务端配置”——能改且对外,果断 History;改不动或纯内部,Hash 更稳。

五、Vue Router / React Router 怎么配

现代框架把两种模式封装好了,切换只是改一行工厂函数。Vue Router 4 用 createWebHashHistorycreateWebHistory,React Router 6 用 HashRouterBrowserRouter

// 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 实战,把连接、缓存、跨域与路由串成完整的前端工程化认知。

上一篇 数据库连接池耗尽:HikariCP 连接泄漏排查实录
下一篇 Git 分支模型:Flow 与 Trunk-Based