2437 字
约 8 分钟
16
S14-X06 · 前端路由两种模式与部署回退配置

单页应用有个绕不开的前提:浏览器只认文件路径,路由表只存在于 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:

  1. 深链丢失:跳完地址变 /index.html,JS 拿到的 pathname 就是它,匹配不到任何业务路由,用户永远回不到那条订单。
  2. 重定向循环:/index.html 又命中规则就继续跳,浏览器报 ERR_TOO_MANY_REDIRECTS。
  3. 状态码语义错: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. 自测

  1. hash 模式的 # 之后内容为什么不会导致 404?
  2. try_files $uri $uri/ /index.html 三步各做什么?
  3. 为什么 fallback 不能配成 302?两个具体后果是什么?
  4. nginx 里怎么保证 /api/* 不被 SPA 回退吃掉?
  5. 子目录部署时,vite.base、createWebHistory 的 base、nginx 回退路径三者关系是什么?
  6. Vue Router 4 里 path: '*' 为什么匹配不到,正确的 404 写法是什么?
S14-X06 · 前端路由两种模式与部署回退配置
https://www.clxhxhhr.top/posts/5716/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。