做前端开发的人,尤其是用过 Vue Router 或者 React Router 的,肯定绕不过这两个概念:History 模式和 Hash 模式。我刚接触前端路由那会儿,压根没在意这俩的区别,反正npm run dev一把梭,开发环境跑得好好的,直到第一次把项目部署到服务器上,一刷新页面直接报警告“404 Not Found”,这才意识到路由模式这件事没搞清楚是要出大问题的。后来在好几个项目里反复踩坑、翻源码、查文档,才把这两兄弟的脾气摸透。这篇就把 History 模式和 Hash 模式的原理、区别、配置方式,以及我实际部署中遇到的那些坑,一次性给你讲清楚。
这内容适合谁看?如果你正在用 Vue、React 这类 SPA 框架做项目,打算从开发环境走到生产部署;或者你已经在部署时遇到了“刷新就白屏/404”的问题;再或者你只是好奇为什么有的网址带#、有的不带——那这篇就是写给你的。看完不说让你成为路由专家,至少配置、排查、选型这些实操环节,你能心里有数,不用再到处搜零碎答案。
1. 两种模式到底在做什么:核心原理拆解
想要真正理解 History 和 Hash 的区别,光背结论没用,得先把它们的工作机制弄明白。这俩模式本质上是解决同一个问题:如何让一个单页应用(SPA)在不刷新页面的情况下,切换页面内容,同时还能让 URL 跟着变化,并且支持浏览器的前进后退。
1.1 Hash 模式:URL 里的#到底是个什么东西
Hash 模式的核心是 URL 中的#符号,以及它后面的那一串内容。比如https://example.com/#/user/123里的#/user/123就是 hash 部分。这个#在 HTTP 协议里有个特殊身份:它后面的内容是用来定位页面内某个位置的锚点(anchor),浏览器发起请求的时候,压根不会把#后面的内容发送给服务器。也就是说你在地址栏里输入什么#之后的东西,服务器看到的始终是https://example.com/。
那前端路由是怎么利用这个特性的呢?当用户点击页面里的链接,或者执行路由跳转时,路由库会修改window.location.hash,比如从#/user/123改成#/order/456。这个改动会触发浏览器的hashchange事件。前端路由在初始化的时候会监听这个事件,一旦检测到 hash 变了,就解析最新的 hash 值,再根据路由表找到对应的组件去渲染。
这个过程有一个关键点:修改 hash 不会导致浏览器向服务器发送新的 HTTP 请求,也不会重新加载页面,只是地址栏的 URL 变了,然后前端代码自己做出响应。这就是为什么 Hash 模式部署到任何静态服务器上都能直接跑,因为服务器自始至终只收到一个固定的路径请求,根本不需要关心路由规则。
我把 Hash 模式和实体书做个类比:URL 里的#后面的内容就像书里的页码,你可以随意翻到第 10 页、第 20 页,但这始终是在同一本书的范围内操作,不需要跑到图书馆前台去借一本新书。浏览器也一样,它始终停留在同一个 HTML 文档上,只是根据 hash 值的变化切换当前显示的内容块。
1.2 History 模式:去掉了#但要有服务端配合
History 模式之所以能做到 URL 里没有#,背后靠的是 HTML5 新增的 History API,关键方法有三个:pushState()、replaceState()和popstate事件。
pushState()可以往浏览器的历史记录里添加一条新记录,同时改变地址栏的 URL,但这同样不会触发页面刷新,也不会向服务器发请求。这是 SPA 路由能实现的基础:前端代码可以自由地改变地址栏路径,比如从/user/123变成/order/456,然后前端自己根据路径渲染对应的组件。用户看到的效果是 URL 变了、页面内容也变了,但浏览器没有重新向服务器要资源。
问题出在哪?如果用户在/user/123这个路径下点了刷新按钮,或者直接把地址复制给朋友打开,浏览器就会向服务器发起一个真实的 HTTP 请求,请求的路径就是/user/123。服务器会想:“我没这个文件啊”,于是返回 404。这就是 History 模式部署上线后刷新白屏的根源。
所以 History 模式的部署对服务器有一个硬性要求:你需要配置一个 fallback 规则(也叫回退规则),把所有未匹配到真实文件的请求,都返回到应用的入口 HTML 文件(通常是 index.html)。这样浏览器拿到 index.html 后,前端路由再根据当前 URL 去渲染对应组件,一切恢复正常。
接刚才的类比:History 模式相当于你把每个章节改成了一个独立的书名,比如把“第一章”改叫“小明的冒险”,把“第二章”改叫“小红的旅行”。但实体书还是那一本,每次有人想看“小红的旅行”时,你得提前和图书馆管理员说好:不管访客报哪个书名,你都给拿那本实体书。管理员没配合好,访客就会空手而归,对应到前端就是 404。
1.3 两者本质区别就三句话
总结一下,两种模式的核心差别可以浓缩成三句话:
- Hash 模式依靠 URL 中
#后面的内容变化触发前端渲染,浏览器不会额外发请求,所以部署最简单。 - History 模式依靠 History API 修改地址栏路径,刷新或直接访问时浏览器会向服务器发真实请求,所以服务端必须配回退规则。
- Hash 模式多出一个
#,History 模式 URL 更干净;但后者的“干净”是有代价的——必须在服务端做额外配置。
2. History 和 Hash 的差异到底有多大:多维度对比与选型判断
前面讲了原理,可能有人会觉得“看起来就是多个#的事嘛”。等到了具体项目里,两者的差异会实实在在影响你的开发、部署、甚至 SEO。我从五个维度做了对比,每一项都是实际项目中会遇到的问题。
2.1 URL 形态:一眼能看出的不同
最直观的区别就是 URL 长什么样。Hash 模式是https://example.com/#/user/123,History 模式是https://example.com/user/123。
有人可能觉得“多个#而已,没什么大不了”,但在实际业务里这个差别很影响体验。比如你在浏览器地址栏输入一个 History 模式的 URL,回车打开,用户看到的路径和他的浏览位置是吻合的;而 Hash 模式的 URL 复制给同事时,#后面的内容在某些 IM 工具里可能会被截断或者被奇怪地处理,那种带#的地址看起来也总有点“临时页面”的感觉。对追求 URL 整洁度的产品来说,History 模式几乎是必选。
2.2 服务端要求:一个要求你配置,一个完全不用管
这是两者最本质的差异。Hash 模式因为#后面的内容永远不会发到服务器,所以前端项目打包成静态文件后,随便扔到 Nginx、Apache、OSS、GitHub Pages 上都能正常工作。哪怕你直接在本地双击 index.html 文件打开,Hash 模式在一定程度上也能跑(资源路径别配错的话)。
History 模式就不行了。所有静态资源服务器、后端应用服务器,都必须配置“当请求路径找不到对应文件时,返回 index.html”。这个配置不是可选项,而是必选项。如果配置漏了,就会出现“首页打开正常,点进子页面一刷新就 404”的经典事故。
2.3 SEO 能力:History 模式完胜,但不是万能药
SPA 本身就 SEO 不友好,因为页面内容全靠 JavaScript 运行时渲染,搜索引擎爬虫如果只抓 HTML 源码,看到的往往是一个空的<div id="app">。但相对而言,History 模式下每个路由有独立的 URL,爬虫至少能区分不同的页面链接;Hash 模式由于#后面的内容不会提交给服务器,早期的搜索引擎根本不会把 hash 后的路径当作独立页面收录。
现在 Google 的爬虫能执行 JavaScript 了,Bing 也能执行一些,但百度的支持还是有限。所以如果你的应用非常依赖搜索引擎自然流量,并且是用 Vue/React 这类方案做的,通常建议:
- 优先选 History 模式,保证每个页面有干净的 URL。
- 配合 SSR(服务端渲染)或 SSG(静态站点生成)解决内容渲染问题,Vue 生态的 Nuxt、React 生态的 Next.js 就是干这个的。
但如果你是纯前端 SPA,就算用了 History 模式,SEO 问题也没法靠路由模式本身解决,需要额外的预渲染(prerendering)或者 SSR 方案。
2.4 用户体验与兼容性:老浏览器是少数场景的分水岭
从用户体验来说,History 模式 URL 更自然、分享更友好。另外还有一个小细节:Hash 模式如果服务端不支持自动刷新,在某些场景下会有一点“跳跃感”,因为每次路由切换是从#/a变成#/b,浏览器可能会把 hash 变化记入历史记录,产生不必要的浏览记录,干扰用户的后退操作。History 模式用pushState能更精细地控制历史记录栈。
兼容性方面,Hash 模式几乎是全浏览器通吃,包括远古的 IE6。History 模式要求浏览器支持 HTML5 History API,IE9 及以下是不支持的。不过 2024 年了,除非你的用户群体还在大量使用老旧浏览器,这个问题基本可以忽略。如果你实在要兼顾,可以用vue-router之类库内置的能力,在 History 模式不支持时自动 fallback 到 hash 模式。
2.5 选型判断:什么时候用哪个最合适
根据我自己的项目经验,选型时可以按这个思路来判断:
- 内部管理系统、后台管理系统、快速原型项目,用户量小、不需要 SEO、部署环境简单,选 Hash 模式,省心省力。
- 面向公众的内容型站点、商城、企业官网,对 URL 美观有要求、可能需要 SEO,选 History 模式,同时一定要配置好服务端。
- 团队对部署环境有完全控制权(比如 Nginx 是自己维护的),那无脑选 History 模式,URL 干净是长期收益。
- 部署在 GitHub Pages 这类只能放静态文件、没法改服务端规则的平台,推荐 Hash 模式,省得踩平台限制的坑。
我还做过一个容易被人忽略的点:如果你做的应用有一个“分享到微信/钉钉”的需求,URL 里带#的链接在部分客户端里会丢失 hash 后面的内容。比如从https://x.com/#/detail/100分享出去,对方打开后可能会变成https://x.com/,路由直接回到首页。这种情况下,History 模式就是刚需。这个坑让我一次就记住了,后来凡是涉及分享功能的项目,我第一反应就是确认路由模式。
3. 手把手把两种模式配到能跑:前端框架与开发环境配置
原理和选型讲完了,进入实操环节。不同框架、不同构建工具在配置上有细微差别,我按最常见的几种组合来写。
3.1 Vue Router:Vue 2 和 Vue 3 的写法差异
先看 Vue 3 + Vue Router 4。用 Vite 创建的项目,路由一般长这样:
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router' import routes from './routes' // History 模式 const router = createRouter({ history: createWebHistory(), // 替换这一行就能切换模式 routes }) // Hash 模式 // const router = createRouter({ // history: createWebHashHistory(), // routes // })Vue 2 + Vue Router 3 的写法不太一样,是用mode选项区分的:
import VueRouter from 'vue-router' const router = new VueRouter({ mode: 'history', // 'history' 对应 History 模式,'hash' 是默认值,可以省略 routes })这里有个细节值得注意:Vue Router 3 默认就是 hash 模式(mode: 'hash'),很多人根本没设置过就知道“路由能跑”,就是这个原因。到了 Vue Router 4 改成了显式调用工厂函数,其实也是想提醒开发者:你选的模式是什么,自己心里要有数。
3.2 React Router:BrowserRouter 和 HashRouter 的选择
React Router 的配置更直接,不同 Router 组件代表不同模式。v6 版本里:
import { BrowserRouter, HashRouter, Routes, Route } from 'react-router-dom' // History 模式用 BrowserRouter function App() { return ( <BrowserRouter> <Routes> <Route path="/" element={<Home />} /> <Route path="/user" element={<User />} /> </Routes> </BrowserRouter> ) } // Hash 模式用 HashRouter // function App() { // return ( // <HashRouter> // <Routes> // <Route path="/" element={<Home />} /> // <Route path="/user" element={<User />} /> // </Routes> // </HashRouter> // ) // }React Router 旧版本里还有一种<Router history={browserHistory}>的写法,在 v4 之后就不推荐了,现在直接用 BrowserRouter 即可。
3.3 开发环境:为什么 dev server 从来不出事
你可能好奇过:我在开发环境用npm run dev,用 History 模式也没见刷新 404 啊,为什么部署到服务器就崩?
因为现代前端开发服务器早就内置了回退支持。Vite 开发服务器内部就实现了 connect-history-api-fallback 类似的逻辑,webpack-dev-server也有一个配置项叫historyApiFallback,默认就是开启的。也就是说,开发环境的服务器收到一个找不到的路径请求时,自动给你返回 index.html 了,你压根没感知到这个环节。
但生产环境用的 Nginx 并不会替你默认做这件事。所以本地能跑不代表生产能跑,这是最典型的“开发环境骗了你”的情景。
如果你在 Vite 里用的是 history 模式,又确实想关掉这个回退看效果,可以改vite.config.ts:
import { defineConfig } from 'vite' export default defineConfig({ server: { fs: { strict: false } } })不过一般没人会关。真正需要调整的是应用部署在子路径下的情况,比如https://example.com/my-app/。这时 Vite 需要配置base:
export default defineConfig({ base: '/my-app/', // 部署在子路径时必须设置,否则静态资源会 404 })Vue Router 也要对应配置createWebHistory('/my-app/'),和 base 保持一致,否则路由匹配会错乱。
3.4 不同构建工具的配置要点速查
为了方便对照,我把常见构建工具在 History 模式下的开发配置整理成了一张表:
| 构建工具 | 配置项 | 配置值 | 说明 |
|---|---|---|---|
| Vite | server.historyApiFallback | 默认开启 | 需要关闭时可设 false,但很少用 |
| Vite | base | 如/my-app/ | 子路径部署时必设,影响资源加载路径 |
| webpack-dev-server | devServer.historyApiFallback: true | 默认开启 | 支持对象语法,可配置 rewrites 规则 |
| vue-cli(Vue 2) | devServer.historyApiFallback | 默认开启 | 同 webpack |
| Next.js | 无需配置 | 内置支持 | 框架自带服务端渲染和路由处理 |
| Nuxt | 无需配置 | 内置支持 | 同上 |
这里想多说一句,很多人只知道设置historyApiFallback: true,但没意识到这个配置的本质是“把找不到的 GET 请求全部转给 index.html”。如果你在开发环境有自定义的 mock 接口路径,被这个 fallback 吞掉了,请求就会返回 index.html 的 HTML 内容而不是预期的 JSON,接口报错提示也会很怪。这种情况可以在historyApiFallback里用rewrites配置白名单排除掉。
3.5 从零跑通一个 History 模式的 Vue3 项目示例
很多新手卡在“代码写好了怎么验证模式生效”这一步,这里给一个从零起步的最小示例。
第一步,创建项目(node 版本建议 16 以上):
npm create vue@latest my-app cd my-app npm install第二步,打开src/router/index.js,改成使用createWebHistory:
import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: HomeView }, { path: '/about', name: 'about', component: () => import('../views/AboutView.vue') } ] }) export default router注意createWebHistory(import.meta.env.BASE_URL)这个写法很关键,它会把 Vite 的base配置自动拼到路由前缀里,避免子路径部署时出现路由错乱。
第三步,直接访问http://localhost:5173/about,能正常显示 About 组件,说明开发环境已经按 History 模式工作了。
接下来就是验证生产环境。执行npm run build,把dist目录丢给 Nginx,然后在 Nginx 里配好回退规则(规则见下一章)。没有配的话,你访问/about刷新一次,就能亲眼看到那个折磨过无数人的 404 页面了。
4. 上线部署的关键一步:Nginx 与后端服务器的 History 模式配置
这一章是整篇文章的重头戏。前面说过,History 模式部署时服务端必须配置 fallback 规则,否则刷新就 404。下面从最常见的前端部署方案开始,逐个讲。
4.1 Nginx 的 try_files 配置:最经典的解法
假设你的前端打包文件放在/var/www/my-app,Nginx 配置可以这样写:
server { listen 80; server_name example.com; root /var/www/my-app; index index.html; location / { try_files $uri $uri/ /index.html; } }核心就是location /块里的try_files $uri $uri/ /index.html;。这句话要拆开理解:
$uri:先把请求的原始路径当文件找一下,比如有人请求/logo.png,如果root目录下确实有这个文件,就直接返回,不再往下走。$uri/:如果当文件找不到,就尝试当作目录处理,看看有没有对应的索引文件(由index指令决定)。比如有人请求/about,如果/var/www/my-app/about是个目录且有index.html,就直接返回这个页面。/index.html:前两步都失败了,说明这个路径下没有真实文件,那就统一返回应用入口页index.html,让前端路由去解析。
这个配置覆盖了三种情况:静态资源文件存在就直接返回;目录形式能匹配就用目录;否则交给前端路由兜底。非常健壮。
如果你把静态资源放在了 CDN 或者单独的域名下,Nginx 配置要区分开。比如静态资源走static.example.com,那location配置不要用根路径try_files,而是专门划一个location /assets/的规则,否则资源请求也会被 fallback 到 index.html,导致资源加载失败。
4.2 嵌套路径部署:location 前缀和 try_files 的配合
不是所有项目都部署在域名根路径,更多时候是放在某个子路径下,比如https://example.com/admin/。这时 server 块配置要改成:
server { listen 80; server_name example.com; location /admin/ { alias /var/www/my-app/; try_files $uri $uri/ /admin/index.html; } }这里最关键的变化有两个:用了alias而不是root,try_files的最后一项变成了/admin/index.html。如果这两处配错了,会出现两类典型问题:一是资源路径请求变成了/admin/assets/xxx.js,实际文件却在/usr/share/nginx/html/assets/xxx.js,导致图片 CSS 全部 404;二是访问/admin/user/1时 fallback 到了/index.html,页面能开但路径不对,路由匹配混乱。
另外一个常见的坑是,如果你在 Nginx 里配了location /的try_files,但项目里还用到了后端 API 接口,比如/api/user/list,这个路径也会被 fallback 到 index.html。正确的做法是把 API 路径单独拎出来:
location /api { proxy_pass http://127.0.0.1:8080; } location / { try_files $uri $uri/ /index.html; }这条规则能把动态请求和静态资源分隔开,避免接口请求被前端路由吞掉。
4.3 不只是 Nginx:其他服务器和后端语言怎么配
除了 Nginx,前端项目还可能部署在 Apache、Node.js 服务,或者由后端框架直接托管。这里整理了几种常见场景的配置写法。
Apache 需要通过.htaccess或者虚拟主机配置启用mod_rewrite:
<IfModule mod_rewrite.c> RewriteEngine On RewriteBase / RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] </IfModule>这段规则的意思和 Nginx 的try_files是相通的:请求的文件确实存在(!-f表示不是文件)且不是目录(!-d)时,才交给 index.html 处理。注意如果你的站点部署在/admin子路径,RewriteBase要改成/admin/,最后一行也要改成/admin/index.html。
Node.js(Express)场景,通常写在中间件里:
const express = require('express') const path = require('path') const app = express() // 静态资源托管 app.use(express.static(path.join(__dirname, 'dist'))) // SPA fallback:所有非文件请求都返回 index.html app.get('*', (req, res) => { res.sendFile(path.join(__dirname, 'dist', 'index.html')) }) app.listen(3000)注意app.get('*')要放在所有 API 路由定义之后,否则/api/user/list也会被 SPA fallback 拦截,导致接口返回 HTML 而不是 JSON。如果你用的是 Express 5,通配符写法改成了app.get('/*splat'),别记混了。
Spring Boot 托管前端构建产物时,需要实现 WebMvcConfigurer 并添加 view controller:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{spring:\\w+}") .setViewName("forward:/index.html"); registry.addViewController("/**/{spring:\\w+}") .setViewName("forward:/index.html"); registry.addViewController("/{spring:\\w+}/**{spring:?!(\\.js|\\.css)$}") .setViewName("forward:/index.html"); } }这套配置的核心思路是把所有不带静态资源后缀(js、css 等)的路径转发给 index.html,同时绕开资源文件。如果你看到这里觉得麻烦,也可以直接用 Spring 的forward转发方案,或者把前端放到 Nginx,后端只提供 API,职责分离更清爽。
4.4 配置前先想清楚:回退规则这样写才不会出错
我给try_files配置排个优先级,方便新手理解:
- 真实文件请求(图片、JS、CSS、字体)优先级最高,必须能直接命中。
- API 请求次之,应该转发给后端服务,不要被 fallback 吞掉。
- 前端路由路径最后兜底,全部交给 index.html。
如果你发现某个部署场景下静态资源 404、接口 404、或者页面刷新 404,按这个优先级逐层排查,基本都能定位到是哪个环节的规则没写对。
5. 我踩过的坑和排查思路:典型问题实录
这一章全部来自我实际遇到的线上问题,每一个都对应一条血泪教训。我按“现象—原因—解法”的形式整理,后面还有一个速查表,方便你遇到问题时快速对照。
5.1 刷新后 404:最经典也最容易中招
现象:项目用 History 模式部署到 Nginx,首页/能正常打开,点导航跳到/user/123也正常,但只要按一下 F5 刷新,页面就变白,浏览器显示 404。
原因:刷新时浏览器向 Nginx 请求/user/123这个路径,Nginx 去root目录找,发现没有名为user的文件,也没有123目录,于是直接返回 404,压根没走到前端路由那里。
解法:给location /加上try_files $uri $uri/ /index.html;。我见过太多人问这个问题,九成都是 Nginx 没做回退配置。这个解法生效后,Nginx 遇到/user/123找不到文件时,就会把index.html返回给浏览器,前端路由拿到 index.html 后再根据当前 URL 渲染对应的用户页面,一切恢复正常。
排查时怎么快速确定是这个问题?用 curl 试一下:
curl -I https://example.com/user/123如果返回的是404 Not Found,基本就是 Nginx 回退规则没配好;如果返回200 OK,说明 Nginx 已经正确回退到 index.html 了,问题可能出在前端路由配置上。
5.2 页面能打开但静态资源全部 404
现象:配置好try_files后,刷新页面不再 404 了,但页面样式全丢了,控制台里一堆Failed to load resource: the server responded with a status of 404,全是.js、.css文件的请求。
原因:这是资源路径问题。一般是base或publicPath配置缺失导致的。比如项目部署在https://example.com/admin/下,但 Vite 的base没设置,默认是/,打包后的资源引用路径就是/assets/index.js,浏览器会在https://example.com/assets/index.js找资源,当然找不到,因为真实路径是/admin/assets/index.js。
解法:部署在子路径时,把 Vite 的base和 Vue Router 的createWebHistory参数都设置为/admin/:
// vite.config.ts export default defineConfig({ base: '/admin/' })// router/index.js const router = createRouter({ history: createWebHistory('/admin/'), routes })如果是 Webpack 构建,对应改output.publicPath的值。这个坑的隐蔽性在于:本地开发环境一切正常,因为 dev server 就是根路径;一旦放到子路径部署,资源路径就全乱了,而且报错不直观,需要看 Network 面板才能定位。
5.3 Hash 模式的锚点冲突问题
现象:某个内部项目一直用 Hash 模式,页面里有一个“回到顶部”的链接<a href="#top">,点击后发现没有跳到页面顶部,反而把路由从/#/list切到了/#/top,页面直接变成了空白或者跳到了错误路由。
原因:Hash 模式的#就是路由的核心,页面内的锚点定位功能天然会被路由“抢走”。浏览器看到#top时,不管你是想让页面滚动到 id 为 top 的元素,还是想切换路由,它都只触发一个hashchange事件,而前端路由接管了这个事件后,就会尝试匹配top这个路由。
解法:要么避免在 Hash 模式项目里使用传统的#锚点,改用 JS 的scrollIntoView();要么给锚点名称加个前缀,比如#anchor-top,然后在路由拦截器里排除这类 hash,但这实现起来极容易出 bug。最省心的方案还是上文提过的思路:内部系统老老实实用 Hash 模式,但页面内别再用#锚点了。
5.4 History 模式下微信内打开分享链接丢失路径
现象:项目用 History 模式部署,在微信里打开https://example.com/user/123,页面能正常展示,但把链接分享给朋友后,对方打开却回到了首页。
原因:微信内置浏览器的分享逻辑在某些版本里会丢失 URL 路径层级,或者把 URL 截断成https://example.com/。这个行为在不同版本、不同手机上不一致,很不稳定。
解法:这个没有一劳永逸的代码方案。我当时的做法是:在分享出去的链接后面加一个参数作为兜底,比如https://example.com/user/123?from=share,然后在首页读取这个参数,判断是否需要重定向到具体页面。虽然麻烦,但至少分享场景下能把用户带到正确位置。如果你要做社交分享类应用,路由模式的选择要和这个因素一起评估。
5.5 环境正常、部署异常:dev server 和生产环境的配置差异
现象:本地npm run dev怎么刷新都对,放到服务器上就是各种 404。
原因:我在第三章提过,开发服务器内置了 fallback 机制,生产环境的 Nginx、Apache 默认没有。这大概是 History 模式新手最容易踩的坑,因为它形成了“代码肯定没问题”的错觉。
解法:建议把这一条当成固定流程来做:项目第一次部署时,先把生产环境的回退规则配好,再验证刷新行为。别指望开发环境的行为可以代表生产环境。我在部署清单里加了一行“route fallback configured”,每回走完清单再上线,就没再出过这类问题。
5.6 常见问题速查表
| 现象 | 可能原因 | 检查方法 | 解决办法 |
|---|---|---|---|
| 刷新页面 404 | Nginx 未配置 try_files | curl -I 访问子路径 | 在 location / 加 try_files 配置 |
| 页面能打开但 CSS/JS 404 | base/publicPath 配置错误 | 打开 Network 面板看资源请求路径 | 设置正确的 base 和 route base |
| 接口请求返回 HTML | API 路径被 fallback 覆盖 | 看响应头 Content-Type | 将 /api 路径单独配 location |
| 部署在子路径,路由错乱 | base 与路由 base 不一致 | 检查 router 配置和 index.html 资源路径 | 统一设置 base 和 createWebHistory 参数 |
| 点锚点却切换了路由 | Hash 模式与 # 锚点冲突 | 检查链接 href | 用 scrollIntoView 替代 # 锚点 |
| 清除缓存后 404 | 服务端缓存了旧页面或配置未生效 | 查看响应头、重启 Nginx | nginx -s reload,确认缓存策略 |
| localStorage 存了根路径的数据,子路径取不到 | 浏览器对 localStorage 按 origin 隔离 | 检查存储 key | 明确存储策略,必要时用统一 key 前缀 |
里面有一个细节值得展开说一下:localStorage 那条。很多用 Hash 模式的项目,从/#/a跳到/#/b,浏览器始终停留在同一个 origin,localStorage 能正常共享。但如果项目从 Hash 模式换到 History 模式,或者从根路径部署改成子路径部署,localStorage 的访问范围可能发生变化,原本写在代码里的“直接取 localStorage 里某个 key”的逻辑就会拿到 null,间接引发业务报错。这类问题不太容易和路由模式关联起来,但确实见过不止一次。
6. 我的一点实战心得:路由模式这件事值得提前规划
写了这么多,最后聊几句实在的。路由模式这个事,看起来就是个配置项,但它在项目里的影响是“扩散型”的:选型会影响 URL 结构,URL 结构会影响分享和 SEO,服务端配置影响部署方式,部署方式又决定维护复杂度。如果项目上线了才想起要换模式,代价可不小:所有 URL 变,收藏夹失效,重定向规则要写,搜索引擎收录要重新做。
所以我的建议是,项目一开始就花五分钟想清楚这三件事:部署在什么环境,服务器规则能不能改;是否需要 SEO;URL 里能不能容忍#的存在。想清楚了,后面能省一大块返工的时间。
另外如果你正在维护一个老项目,想从 Hash 模式切到 History 模式,一定记得给旧 URL 写重定向规则,比如把/#/user/123301 重定向到/user/123,否则旧链接全失效,外部流量损失得悄无声息。我当时给一个工具站做切换的时候,在 Nginx 里加了一条规则处理这类带#的旧地址,花了一个下午才把所有边界情况想全,但后续基本没收到过用户反馈说链接打不开。
最后分享一个小技巧:如果你临时想快速确认当前项目是哪种路由模式,直接看地址栏就行。有#是 Hash,没#是 History,简单直接,完全不需要翻代码。但如果两种模式混着用(只在某个子路由里用了 hash 做兼容),那就只能以路由配置为准了。