单页应用有个绕不开的前提:浏览器只认文件路径,路由表只存在于 JS 里。本地 npm run dev 刷新 /order/123 一切正常,是因为 Vite 开发服务器把未知路径都回落到 index.html;换成 nginx 或对象存储,这个默认行为就没了,立刻 404。前端路由的部署问题,根子都在这句。
这篇按五步走:两种模式的本质 → 服务端回退 → 与后端路径共存 → 子目录与懒加载 → 守卫顺序与 404。
1. 本质差异:hash 不发请求,history 走服务端
| 维度 | hash 模式 | history 模式 |
|---|---|---|
| URL 形态 | example.com/#/order/123 |
example.com/order/123 |
# 之后的部分 |
不发送给服务器 | 属于正常路径,会发请求 |
刷新 /order/123 |
服务器只收到 /,天然正常 |
服务器收到 /order/123,没有回退就 404 |
| 需要服务端配置 | 不需要 | 必须 |
| SEO | 爬虫对 # 后内容处理不一致 |
URL 干净,可被正常抓取 |
| 与锚点冲突 | #anchor 语义被占用 |
无 |
// 两种模式只在创建 router 时不同
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // 需要 SPA fallback
// history: createWebHashHistory(), // 不需要服务端配合
routes,
scrollBehavior(to, from, saved) {
return saved ?? { top: 0 }
},
})
createWebHistory(base) 的 base 会参与"当前路径属于哪个路由"的判断,传错的症状是地址栏 /admin/order 却匹配到 /order,白屏或落 404。hash 模式靠 /admin/#/order 这种形态工作,BASE_URL 对它没有意义。
2. 服务端回退:try_files 一行解决
server {
listen 443 ssl;
server_name example.com;
root /var/www/app/dist;
# SPA fallback:真实文件优先,找不到就交给 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源可以长缓存,因为文件名带哈希
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
try_files $uri $uri/ /index.html 三步依次尝试:① 文件存在直接返回;② 目录存在返回索引;③ 都不存在则内部重写到 /index.html。第三步是内部 rewrite,不是 HTTP 重定向,地址栏不变。
| 写错的形式 | 后果 |
|---|---|
try_files $uri $uri/ /index.html; |
正确 |
error_page 404 /index.html; |
也能用,但返回码可能仍是 404(除非配 =200),爬虫会判定为死链 |
为什么不能把 fallback 配成 302/301:
# ❌ 反例
location / {
try_files $uri $uri/ /index.html;
# 或更糟:rewrite ^ /index.html redirect;
}
一旦用重定向(rewrite ... redirect、return 302 /index.html),地址栏会真的变成 /index.html:
- 深链丢失:跳完地址变
/index.html,JS 拿到的pathname就是它,匹配不到任何业务路由,用户永远回不到那条订单。 - 重定向循环:
/index.html又命中规则就继续跳,浏览器报ERR_TOO_MANY_REDIRECTS。 - 状态码语义错:301 会被浏览器与 CDN 长期缓存,发新版后旧跳转还赖在缓存里。
正确做法只有一条:内部 rewrite 不改 URL。同理 error_page 404 /index.html 要写成 =200,否则首页会带着 404 状态码返回。
3. 与后端 API 路径共存
前后端同域时,/api/* 不能被 fallback 吃掉。nginx 的 location 前缀匹配取最长者(^~ 强制不再试正则),所以更具体的 /api/ 自然优先于 / ——但还是显式写 ^~ 更稳。
server {
root /var/www/app/dist;
# ① 先匹配 /api/,交给后端,绝不 fallback
location ^~ /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
}
# ② 其余全部走 SPA
location / {
try_files $uri $uri/ /index.html;
}
}
| 冲突来源 | 症状 | 规避方式 |
|---|---|---|
SPA 路由里有 /api 段 |
刷新时被后端接走 | 前端路由避开 /api、/ws |
location /api 漏了结尾斜杠 |
/apixxx 也被代理 |
写 ^~ /api/ |
| WebSocket | 握手失败 | 单独 location /ws/ 配 Upgrade 头 |
4. 子目录部署与 base
// 部署在 https://example.com/admin/ 下
// vite.config.ts
export default defineConfig({
base: '/admin/', // 资源引用前缀
build: { assetsDir: 'assets' },
})
// router/index.ts
createWebHistory('/admin/') // 与 base 一致
location /admin/ {
alias /var/www/app/dist/;
try_files $uri $uri/ /admin/index.html; # 回退路径也要带前缀
}
三处必须一致,漏一处就白屏:vite.base(否则资源引用成 /assets/x.js 而实际在 /admin/assets/)、createWebHistory('/admin/')(否则路由匹配错位)、nginx 回退路径(否则回退到站点根的 index)。另外子目录用 alias 而非 root:alias 替换掉 location 匹配部分,root 是拼接。
5. 懒加载:路由级与组件级
const routes = [
{ path: '/order/:id', name: 'order-detail',
component: () => import('@/views/OrderDetail.vue'),
meta: { requiresAuth: true } },
]
// 组件级:首屏内的大组件按需拉,delay 内加载完就不闪 loading
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
delay: 200,
timeout: 10000,
suspensible: false,
})
粒度选择:路由用 () => import()(每路由一个 chunk),首屏内的大组件用 defineAsyncComponent(组件级),点击后才需要的模块用动态 import()。首屏路由不要懒加载(多一次 RTT 反而更慢);同一路由的子组件别拆太碎,chunk 过多时 HTTP/2 下收益递减;timeout 要小于用户耐心,超时后给重试按钮而不是空白。
6. 守卫执行顺序
完整顺序(Vue Router 4):
| 顺序 | 守卫 | 位置 |
|---|---|---|
| 1 | beforeEach |
全局前置 |
| 2 | beforeRouteUpdate |
组件内(复用的组件才触发) |
| 3 | beforeEnter |
路由独享 |
| 4 | beforeRouteEnter |
组件内(此时 this 还不存在) |
| 5 | 异步组件解析 | — |
| 6 | beforeResolve |
全局解析 |
| 7 | afterEach |
全局后置 |
| 8 | DOM 更新 | — |
| 9 | beforeRouteEnter 的 next(vm => ...) 回调 |
此时可拿到实例 |
router.beforeEach((to) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.token) {
return { name: 'login', query: { redirect: to.fullPath } } // 返回路由即中断导航
}
return true // 每个分支都要显式返回
})
router.afterEach((to) => {
document.title = (to.meta.title as string) ?? 'App' // 后置守卫只做副作用
})
两条易错:beforeRouteEnter 里拿不到 this,要用实例只能走 next(vm => ...) 回调,或改用 onBeforeRouteEnter(需在 setup 内调用);next() 与 return 不要混用,混用会让某条分支永远不 resolve,表现为点击链接没反应。
7. 404 路由与 SEO
const routes = [
// ...业务路由
{ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFound.vue') },
]
纯 SPA 访问未知路径时,HTTP 状态码仍是 200,只是渲染出一个 404 页面——爬虫会把这种"软 404"当成有效页,可能收进索引;预渲染或 SSR 才能返回真正的 404。也就是说,history 模式只解决了"URL 干净",没解决"状态码正确"。只做后台系统可以不在意软 404,但要被收录就得预渲染或 SSR。
按 Vue Router 4,pathMatch 是固定参数名、(.*)* 表示匹配任意多段;写成 path: '*' 会被当字面量路径,永远不匹配。
8. SSR 场景的额外要求
// entry-server.ts
import { createMemoryHistory, createRouter } from 'vue-router'
export function createApp(url: string) {
const router = createRouter({
history: createMemoryHistory(), // 服务端用 memory,不用 web history
routes,
})
router.push(url)
return { router }
}
| 要求 | 原因 |
|---|---|
服务端用 createMemoryHistory() |
服务端无浏览器 history API,且要按请求隔离 |
router.push(url) + await router.isReady() |
不等首次解析完成会渲染出空路由 |
客户端 hydrate 前也 await router.isReady() |
否则水合不匹配 |
| 服务端要能渲染所有路径 | 回退从 nginx 移到 Node 服务,try_files 不再适用 |
SSR 下 nginx 的回退目标从静态 index.html 变成 Node 服务(proxy_pass http://127.0.0.1:3000),但要保留静态资源直出的 location,否则每个 JS 请求都过一遍 Node。
9. 排错:六个现象
| 现象 | 原因 | 处理 |
|---|---|---|
| 首页正常,刷新子路径 404 | 缺 SPA fallback | try_files $uri $uri/ /index.html |
刷新后地址栏变成 /index.html |
用了 301/302 跳转 | 改内部 rewrite |
| 资源全部 404,路径缺了子目录前缀 | vite.base 未设 |
设 base: '/admin/' |
| 点击链接没反应 | 守卫未 return / 混用 next |
所有分支显式返回 |
SPA 路由不生效,path: '*' 落不到 404 |
v4 不再支持 * 字面量 |
用 /:pathMatch(.*)* |
后端返回 HTML,前端 res.json() 报错 |
fallback 覆盖了 /api |
/api/ 加 ^~ 前缀并前置 |
10. 边界:这些情况本就不该用 history 模式
| 场景 | 更合适 | 理由 |
|---|---|---|
| 对象存储/CDN 托管,配不了回退 | hash 模式 | 静态托管通常不支持 rewrite 到 index |
| 内嵌进别人页面的微前端子应用 | hash 或不带路由 | 避免与宿主路径冲突 |
| 需要被收录且有内容页 | 预渲染 / SSR + history | 纯 SPA 拿不到正确状态码 |
一句话选择:能改服务端配置就用 history,改不了就用 hash。这个决定应该在上线前定,而不是等测试反馈"刷新 404"再回头改路由模式——那时候分享出去的链接形态已经定死了。
11. 自测
- hash 模式的
#之后内容为什么不会导致 404? try_files $uri $uri/ /index.html三步各做什么?- 为什么 fallback 不能配成 302?两个具体后果是什么?
- nginx 里怎么保证
/api/*不被 SPA 回退吃掉? - 子目录部署时,
vite.base、createWebHistory的base、nginx 回退路径三者关系是什么? - Vue Router 4 里
path: '*'为什么匹配不到,正确的 404 写法是什么?