Vue Router 深度解析 — 从浏览器导航模型到 SPA 路由工程

写在前面

"路由"是 Web 开发中被最频繁使用、却最少被深入理解的概念之一。大多数人会配置 routes 数组、加几行守卫代码,但对"URL 变化是如何触发组件渲染的""守卫为什么要按那个顺序执行""为什么 History 模式刷新会 404"这些问题知之甚少。更深层的问题是:浏览器的导航模型是什么?前端路由本质上在做什么?与后端路由有何本质区别?

本篇从浏览器规范层面出发,经过框架实现细节,再到工程实践,尝试建立一个完整的路由认知体系。只有把这些底层原理想清楚,你在面对"路由守卫死循环""动态路由 404""History 模式 Nginx 配置"这些常见问题时,才能从容应对,而不是靠试错凑出来的。

参考资料:HTML Living Standard - Session historyVue Router 官方文档WHATWG Navigation API


目录

  • [1. 宏观视角:路由是什么,解决什么问题](#1. 宏观视角:路由是什么,解决什么问题)
  • [2. 浏览器导航模型:规范层面的理解](#2. 浏览器导航模型:规范层面的理解)
  • [3. 两种路由模式的底层机制](#3. 两种路由模式的底层机制)
  • [4. 前端路由 vs 后端路由:本质差异](#4. 前端路由 vs 后端路由:本质差异)
  • [5. Vue Router 的核心架构与源码思路](#5. Vue Router 的核心架构与源码思路)
  • [6. 路由匹配算法:优先级打分体系](#6. 路由匹配算法:优先级打分体系)
  • [7. 导航守卫:一个状态机的完整执行](#7. 导航守卫:一个状态机的完整执行)
  • [8. 动态路由参数与响应式的深层关系](#8. 动态路由参数与响应式的深层关系)
  • [9. 路由懒加载与代码分割策略](#9. 路由懒加载与代码分割策略)
  • [10. Vue Router 4 的设计演进](#10. Vue Router 4 的设计演进)
  • [11. 跨框架视角:React Router 与 Angular Router 对比](#11. 跨框架视角:React Router 与 Angular Router 对比)
  • [12. 项目配置实战:完整工程实现](#12. 项目配置实战:完整工程实现)
  • [13. 高级模式:路由与状态的深度集成](#13. 高级模式:路由与状态的深度集成)
  • [14. SSR 场景下的路由特殊处理](#14. SSR 场景下的路由特殊处理)
  • [15. 常见坑深度解析](#15. 常见坑深度解析)
  • 小结

1. 宏观视角:路由是什么,解决什么问题

从"超链接"到"前端路由"的演变

Web 诞生之初,Tim Berners-Lee 设计了 HTTP + HTML + URL 这套体系。其中 URL(Uniform Resource Locator) 不只是一个地址,它是整个 Web 的核心抽象------任何资源都有一个唯一标识符,可以被链接、收藏、分享。

早期的 Web 是完全无状态的:每个 URL 对应服务器上的一个文件或一个后端路由处理器,用户点击链接就是向服务器发一次新请求,服务器返回完整的 HTML 页面,浏览器完全重新渲染。

这种模式在 Web 应用复杂化后产生了根本性矛盾:

复制代码
Web 应用的演进:
  静态网站(HTML 文件 → 服务器直接返回)
      ↓
  服务端动态页面(PHP/JSP/Rails,每次请求服务器渲染 HTML)
      ↓
  AJAX 时代(局部刷新,但 URL 不变,无法收藏/分享当前状态)
      ↓
  SPA + 前端路由(URL 随内容变化,但不向服务器重新请求 HTML)

前端路由的本质:在不触发服务器请求的前提下,改变浏览器地址栏的 URL,并根据 URL 决定渲染哪些组件。

这里有一个核心矛盾需要解决:浏览器的设计是"URL 变化 = 发新请求",而前端路由要做的恰恰是"URL 变化但不发请求"。解决这个矛盾的技术路径,就是 Hash 和 History 两种模式的根本来源。

URL 的四层含义

理解路由,先理解 URL 对用户的价值:

复制代码
URL 的四层价值:

1. 定位(Location):标识当前看的是什么内容
2. 导航(Navigation):浏览器的前进/后退依赖 URL 历史
3. 分享(Sharing):把 URL 发给别人,对方看到相同页面
4. 收藏(Bookmarking):下次打开回到相同状态

前端路由要完整支持这四层价值,这是它复杂性的根本来源。


2. 浏览器导航模型:规范层面的理解

这是大多数教程跳过的部分,但它是理解一切路由行为的基础。

Session History 与 History Stack

WHATWG 规范定义了每个浏览器标签页都有一个 Session History (会话历史),它是一个有序的历史条目列表 ,加上一个当前指针

复制代码
History Stack:[entry0, entry1, entry2, entry3]
                                     ↑
                              current pointer

entry = {
  url: 'https://example.com/user',
  state: { scrollY: 300 },  // pushState 时传入的状态对象
  title: '用户管理',
  document: <document snapshot>
}

浏览器导航 API 实际上是在操作这个列表:

  • history.pushState(state, title, url):在当前位置后插入新 entry,指针前移
  • history.replaceState(state, title, url):替换当前 entry,指针不动
  • history.go(n):移动指针(前进/后退),go(-1) = 后退
  • history.back():等同于 go(-1)
  • history.forward():等同于 go(1)

关键约束pushState/replaceState 只能改变同源(same-origin)的 URL,不能跳到其他域名。这是浏览器的安全策略。

popstate 事件的触发时机

这是很多人误解的地方:

javascript 复制代码
// popstate 只在以下情况触发:
// 1. 用户点击浏览器前进/后退按钮
// 2. 调用 history.go()、history.back()、history.forward()

// 以下情况【不触发】popstate:
// 1. 调用 history.pushState()
// 2. 调用 history.replaceState()
// 3. 修改 location.hash

// 所以 Vue Router 需要两步:
// 1. 调用 pushState 改变 URL
// 2. 手动触发自己的路由匹配逻辑(因为 popstate 不会触发)

Vue Router 内部正是这样做的:push() 调用后,既更新 URL,也主动执行路由切换逻辑,而不依赖事件。

hashchange 事件

Hash 模式依赖的 hashchange 事件行为不同:

javascript 复制代码
// hashchange 在以下情况触发:
// 1. 修改 location.hash(window.location.hash = '/new-path')
// 2. 用户点击带 # 的链接(<a href="#/new-path">)
// 3. 浏览器前进/后退到 hash 不同的历史记录

// hashchange 【不触发】服务器请求!
// 因为 # 后面的内容在 HTTP 规范中被定义为"文档内部的片段标识符"
// 浏览器不会把 # 后面的内容发送给服务器

这正是 Hash 模式不需要服务器配置的根本原因------从 HTTP 协议层面,服务器根本就不知道 # 后面有什么。


3. 两种路由模式的底层机制

Hash 模式:利用 URL Fragment

复制代码
URL 结构:
scheme://host:port/path?query#fragment
                              ↑
                         Vue Router Hash 模式利用的就是这个 fragment 部分

规范依据 :RFC 3986 明确定义,fragment(# 后面的部分)是"次级资源的标识符",用于在文档内部导航(如 #section-2 滚动到锚点)。HTTP 请求时,浏览器会自动去掉 fragment,服务器永远只收到 # 之前的部分。

Vue Router 的 Hash 模式利用了这个特性:把整个前端路由路径放进 fragment。

javascript 复制代码
// vue-router 内部 HashHistory 简化实现
class HashHistory {
  // 监听 hash 变化
  listen() {
    window.addEventListener('hashchange', this.handleHashChange.bind(this))
  }
  
  // 获取当前路径(去掉 # 号)
  getCurrentLocation() {
    const href = window.location.href
    const index = href.indexOf('#')
    return index < 0 ? '/' : href.slice(index + 1) || '/'
  }
  
  // 导航
  push(path) {
    window.location.hash = path  // 触发 hashchange
    // 注意:hashchange 事件会触发 handleHashChange,不需要手动调用
  }
  
  replace(path) {
    const url = this.getUrl(path)
    window.location.replace(url)
  }
}

History 模式:利用 HTML5 History API

javascript 复制代码
// vue-router 内部 HTML5History 简化实现
class HTML5History {
  constructor() {
    // 监听浏览器前进/后退(注意:pushState 不会触发 popstate)
    window.addEventListener('popstate', this.handlePopState.bind(this))
  }
  
  push(to) {
    // 1. 调用 pushState 改变 URL(不触发服务器请求,不触发 popstate)
    window.history.pushState(
      { current: to.fullPath },  // state 对象(刷新后可恢复)
      '',
      to.fullPath
    )
    // 2. 手动触发路由切换(因为 pushState 不触发事件)
    this.handleCurrentRouteChange()
  }
  
  handlePopState(event) {
    // 用户点击前进/后退时触发
    const location = event.state?.current || window.location.pathname
    this.handleCurrentRouteChange(location)
  }
}

History 模式的深层问题pushState 可以把 URL 改成任何同源路径,比如从 / 改成 /user/list。但这个改变只存在于 JavaScript 内存中,服务器不知道。当用户刷新页面 时,浏览器会发起真实的 HTTP 请求 GET /user/list,而服务器通常没有对应的路由处理器,返回 404。

这不是 bug,是 History API 的设计决策------它把"URL 看起来是什么"和"服务器实际怎么响应"解耦了,但也因此要求开发者必须在服务端做相应配置。

Memory 模式:服务端渲染与测试

Vue Router 4 还提供了第三种模式 createMemoryHistory()

javascript 复制代码
import { createRouter, createMemoryHistory } from 'vue-router'

const router = createRouter({
  history: createMemoryHistory(),
  routes: [...]
})

Memory 模式不依赖浏览器 API,URL 存储在内存中,不会改变浏览器地址栏。用于:

  • SSR(服务端渲染):服务器环境没有浏览器 API
  • 单元测试:测试路由逻辑时不需要真实浏览器
  • Capacitor/Electron 等非 Web 环境的 Vue 应用

4. 前端路由 vs 后端路由:本质差异

理解两者的本质差异,有助于做出更好的架构决策。

后端路由(以 Koa/Express 为例)

javascript 复制代码
// 后端路由:URL → 处理函数的映射
app.get('/users', async (ctx) => {
  const users = await db.find('users')
  ctx.body = users  // 返回 JSON 数据
})

app.get('/users/:id', async (ctx) => {
  const user = await db.findById(ctx.params.id)
  ctx.body = user
})

后端路由的特点:

  • 每个 URL 对应一个服务端处理器
  • 处理器通常查数据库、处理业务逻辑、返回数据
  • 每次请求都是无状态的(HTTP 是无状态协议)
  • 路由是权威的------URL 对应什么处理器,由服务器说了算

前端路由(Vue Router)

javascript 复制代码
// 前端路由:URL → 组件的映射
const routes = [
  { path: '/users', component: UserList },
  { path: '/users/:id', component: UserDetail }
]

前端路由的特点:

  • 每个 URL 对应一个Vue 组件(视图)
  • 组件决定自己要显示什么、要请求什么接口
  • 路由是有状态的(组件状态在页面切换间可以保留)
  • 路由是局部的 ------只改变页面的某个区域(<router-view>),不重新渲染整个页面

深层对比:谁是"真相的来源"

维度 后端路由 前端路由
URL 的解释权 服务器 浏览器 JS
URL 对应什么 处理器函数 UI 组件树
每次请求 服务器重新处理 JS 本地切换
页面状态 无状态(除 Session) 有状态(JS 内存)
SEO 友好性 天然友好 需要 SSR/预渲染
权限控制 服务器强制执行 仅 UI 层(需配合后端)

关键洞察 :前端路由是后端路由的"影子"------它在 UI 层模拟后端路由的导航体验,但不能替代后端路由做安全控制。前端路由隐藏了菜单,但用户仍然可以直接调用 API。权威的权限控制始终在服务端。


5. Vue Router 的核心架构与源码思路

三个核心抽象

Vue Router 4 的内部由三个核心抽象组成:

① History 实例:封装浏览器历史记录 API,提供统一接口

javascript 复制代码
interface RouterHistory {
  readonly location: string
  push(to: string, data?: HistoryState): void
  replace(to: string, data?: HistoryState): void
  go(delta: number, triggerListeners?: boolean): void
  listen(callback: NavigationCallback): () => void
}

② Matcher(路由匹配器):把 routes 配置编译为高效的匹配树

javascript 复制代码
// 路由匹配器的核心职责:
// 1. 把 path 字符串('/user/:id')编译为正则表达式
// 2. 给每个路由计算"优先级分数"
// 3. 提供 resolve(location) 方法,输入 URL,输出匹配的 RouteRecord

③ Router 实例:整合以上两者,管理当前路由状态,执行守卫链

<router-link> 不只是带了样式的 <a> 标签,它:

javascript 复制代码
// RouterLink 简化实现
const RouterLink = defineComponent({
  props: {
    to: { type: [String, Object], required: true },
    replace: Boolean,
    activeClass: String,
    exactActiveClass: String
  },
  setup(props) {
    const router = inject(routerKey)
    const route = inject(currentRouteKey)
    
    // 解析目标路由
    const link = useLink({ to: props.to, replace: props.replace })
    
    return () => h('a', {
      href: link.href.value,
      // 阻止默认的浏览器导航,改用 Vue Router 的导航
      onClick: (e) => {
        e.preventDefault()
        link.navigate()
      },
      // 根据当前路由是否匹配添加 active class
      class: {
        [props.activeClass || 'router-link-active']: link.isActive.value,
        [props.exactActiveClass || 'router-link-exact-active']: link.isExactActive.value
      }
    }, slots.default?.())
  }
})

为什么要阻止默认行为<a> 标签的默认行为是向服务器发新请求。e.preventDefault() 阻止了这个行为,改由 Vue Router 接管导航,实现无刷新跳转。

RouterView 的渲染层级机制

javascript 复制代码
// RouterView 的核心:根据当前路由匹配深度,渲染对应层级的组件
const RouterView = defineComponent({
  setup(props, { slots }) {
    // 从父 RouterView 继承深度(嵌套路由的关键)
    const injectedDepth = inject(viewDepthKey, 0)
    const depth = computed(() => injectedDepth + (props.name ? 0 : 1))
    
    const matchedRoute = inject(routerViewLocationKey)
    
    // depth=0 渲染 matched[0],depth=1 渲染 matched[1],以此类推
    const ViewComponent = computed(() => {
      const matched = matchedRoute.value.matched
      const record = matched[depth.value - 1]
      return record?.components?.[props.name || 'default']
    })
    
    // 把 depth 提供给子 RouterView(嵌套路由的递归机制)
    provide(viewDepthKey, depth)
    
    return () => {
      if (!ViewComponent.value) return null
      return h(ViewComponent.value, /* keepAlive 和 transition 逻辑 */)
    }
  }
})

嵌套路由的渲染原理matched 数组包含从根到叶子的所有匹配的路由记录。每个 <router-view> 消费 matched 数组的一个层级(由 depth 控制)。嵌套 <router-view> 通过 provide/inject 机制传递 depth,实现无限层级的嵌套。


6. 路由匹配算法:优先级打分体系

Vue Router 4 使用了一套精密的打分排名算法来决定路由优先级,这比 Vue Router 3 的"先定义先匹配"规则更加合理和可预测。

打分规则

javascript 复制代码
// 每个路由段(path segment,以 / 分割)的得分:

// 静态段:'/user'  → 40分(最高)
// 参数段:'/:id'  → 20分
// 可选参数:'/:id?' → 8分(比必填参数低,因为更宽泛)
// 通配符:'(.*)'  → 0分(最低,永远兜底)
// 末尾斜杠严格模式:+5 或 -5

// 总分 = 所有段的分数之和
// 分数高的优先匹配

// 例子:
// /user/list    → 40 + 40 = 80分(两个静态段)
// /user/:id     → 40 + 20 = 60分(一个静态 + 一个参数)
// /:type/:id    → 20 + 20 = 40分(两个参数)
// /:catchAll(.*)→ 0分(通配符)

这套打分体系解决了"路由歧义"问题:多个路由都能匹配同一 URL 时,按分数高低决定谁来处理,无需担心定义顺序。

实际场景验证

javascript 复制代码
const routes = [
  { path: '/user/profile', component: UserProfile },  // 80分
  { path: '/user/:id', component: UserDetail },        // 60分
  { path: '/:any', component: GenericPage },           // 20分
]

// 访问 /user/profile:
//   /user/profile 匹配 → 80分
//   /user/:id 也匹配(id='profile')→ 60分
//   /:any 也匹配(any='user/profile')→ 20分
//   优先级:80 > 60 > 20,渲染 UserProfile

// 访问 /user/123:
//   /user/profile 不匹配('123' ≠ 'profile')
//   /user/:id 匹配(id='123')→ 60分
//   /:any 也匹配 → 20分
//   优先级:60 > 20,渲染 UserDetail

7. 导航守卫:一个状态机的完整执行

把守卫链理解为状态机

导航过程可以理解为一个状态机:从"当前路由(from)"出发,经过一系列"验证器(守卫)",最终到达"目标路由(to)",或在某个环节被"拒绝"。

复制代码
START: 触发导航(push('/user') / 点击链接 / 前进后退)
  ↓
[STATE: pending] 导航进入挂起状态
  ↓
[GATE 1] 失活组件的 beforeRouteLeave
  ↓ 通过(return true / next())
[GATE 2] 全局 beforeEach 钩子(可能有多个,按注册顺序)
  ↓ 通过
[GATE 3] 复用组件的 beforeRouteUpdate(参数变化时)
  ↓ 通过
[GATE 4] 路由独享 beforeEnter(路由配置中定义)
  ↓ 通过
[GATE 5] 解析异步路由组件(懒加载 import())
  ↓ 加载完成
[GATE 6] 激活组件的 beforeRouteEnter
  ↓ 通过
[GATE 7] 全局 beforeResolve(此时所有组件已解析)
  ↓
[STATE: confirmed] 导航确认,提交到 History
  ↓
DOM 更新(Vue 响应式更新组件树)
  ↓
[POST] 全局 afterEach 钩子
  ↓
[POST] beforeRouteEnter 中传给 next 的回调(可拿到组件实例)
  ↓
END: 导航完成

任何一个 GATE 返回 false 或抛出错误,导航就在那个阶段被终止,状态机回到 from 的状态。

各守卫的设计意图

理解为什么有这些守卫,比记住"怎么用"更重要:

beforeRouteLeave(在失活组件中):让即将离开的组件有机会"打扫干净"或"确认离开"。典型场景:表单未保存时弹窗确认。

javascript 复制代码
// 为什么需要这个守卫?
// 如果没有 beforeRouteLeave,路由切换是强制的。
// 用户在填写一个长表单,不小心点了菜单,所有输入就消失了。
// 这个守卫让组件自己决定"现在能不能离开"。
onBeforeRouteLeave((to, from) => {
  if (isDirty.value) {
    return window.confirm('有未保存的内容,确定离开?')
  }
})

全局 beforeEach:应用级别的统一把关。登录验证是它最典型的用途。注意:每次导航都会执行,性能敏感的逻辑要注意缓存。

beforeRouteUpdate (在复用组件中):处理"同一组件,不同参数"的场景。如果没有这个守卫,从 /user/1/user/2,组件的 onMounted 不会重新执行,数据不会更新。

beforeEnter(路由独享):某个路由的特殊前置逻辑,不应该放在全局 beforeEach 里(否则全局守卫会越来越复杂)。

beforeResolve(全局):在所有组件已解析、即将执行 DOM 更新之前的最后时机。适合需要确保组件已加载后才执行的逻辑(如获取组件内部定义的数据)。

afterEach(全局):导航完成后的钩子,不影响导航结果(不能取消导航)。适合:进度条完成、页面标题更新、埋点记录。

守卫的返回值语义

Vue Router 4 用返回值取代了 Vue Router 3 的 next() 回调,语义更清晰:

javascript 复制代码
// undefined / true:放行(继续导航)
return undefined
return true

// false:取消导航(留在当前路由)
return false

// 路由地址:重定向
return '/login'
return { name: 'Login', query: { redirect: to.fullPath } }

// Error 对象:导航失败(会触发 router.onError 回调)
return new Error('Navigation failed')

// 注意:在守卫中 throw 也会终止导航
throw new Error('Something went wrong')

8. 动态路由参数与响应式的深层关系

Vue 的组件复用策略

理解这个"坑"需要先理解 Vue 的 Virtual DOM diffing 策略:

当从 /user/1 导航到 /user/2 时,Vue 发现路由配置的 component 字段没变(都是 UserDetail),它会复用组件实例 (不销毁重建),只更新必要的 props(如 route.params)。

这是性能优化的正确决策:销毁重建组件比更新组件开销大得多。但这带来了一个问题:onMounted 只在组件首次挂载时执行,组件复用时不会再触发

javascript 复制代码
// 问题场景
const UserDetail = {
  async setup() {
    const route = useRoute()
    
    // onMounted 只执行一次!
    // /user/1 → /user/2 时,这里不会重新执行
    onMounted(async () => {
      const user = await fetchUser(route.params.id)
      // ...
    })
  }
}

四种应对方案的权衡

方案 1:watch route.params(推荐)

javascript 复制代码
const route = useRoute()

watch(
  () => route.params.id,
  async (newId, oldId) => {
    if (newId !== oldId) {
      await fetchUser(newId)
    }
  },
  { immediate: true }  // immediate: true 让初次挂载时也执行
)

优点 :精准响应参数变化,性能好,不会重建组件
缺点 :需要记得用 immediate: true 处理初始加载

方案 2:router-view 添加 key

html 复制代码
<router-view :key="$route.fullPath" />

优点 :简单直接,参数变化时组件强制重建
缺点:每次导航都销毁重建组件,有性能开销;如果组件内有复杂的动画/状态,体验不好

方案 3:onBeforeRouteUpdate

javascript 复制代码
import { onBeforeRouteUpdate } from 'vue-router'

onBeforeRouteUpdate(async (to, from) => {
  if (to.params.id !== from.params.id) {
    await fetchUser(to.params.id)
  }
})

优点 :在 DOM 更新前执行,可以减少闪烁
缺点:初次加载需要另外处理(onMounted)

方案 4:路由元信息控制缓存策略

javascript 复制代码
// 路由配置
const routes = [
  {
    path: '/user/:id',
    component: UserDetail,
    meta: { forceReCreate: true }  // 自定义元信息
  }
]

// App.vue
<router-view v-slot="{ Component, route }">
  <component
    :is="Component"
    :key="route.meta.forceReCreate ? route.fullPath : undefined"
  />
</router-view>

优点 :细粒度控制,按路由决定是否复用
缺点:配置项增多,需要维护


9. 路由懒加载与代码分割策略

为什么懒加载是必须项,而不是可选项

复制代码
没有懒加载的应用:
  所有页面组件 → 打包为 app.js(2-5MB)
  首屏:必须下载全量 JS 才能渲染任何页面
  
有懒加载的应用:
  核心框架代码 → app.js (200KB)
  用户管理页    → user.chunk.js (50KB)
  订单页        → order.chunk.js (80KB)
  报表页        → report.chunk.js (300KB)  ← 大页面单独分块
  
  首屏:只需下载 app.js
  其他页面:访问时才下载,并行加载

在移动网络(4G/5G)环境下,下载 2MB JS 大约需要 2-4 秒(网络延迟 + 传输时间)。懒加载把首屏加载从 4 秒优化到 0.5 秒,差距是 8 倍。对于商业应用,每 100ms 的延迟会导致约 1% 的用户流失(Amazon 内部研究数据)。

Vite 的动态 import 与 Rollup chunk 策略

javascript 复制代码
// 方式1:每个路由独立 chunk(默认行为)
{ path: '/user', component: () => import('@/views/User.vue') }
// 产物:User-abc123.js

// 方式2:相关路由分组到同一 chunk(减少请求次数)
// vite.config.js
build: {
  rollupOptions: {
    output: {
      manualChunks(id) {
        // 把 user 模块的所有页面打包到一个 chunk
        if (id.includes('/views/user/')) return 'user-module'
        if (id.includes('/views/order/')) return 'order-module'
        // 第三方库分离
        if (id.includes('node_modules/echarts')) return 'echarts'
        if (id.includes('node_modules/')) return 'vendor'
      }
    }
  }
}

预加载策略:让"即将访问"提前就绪

javascript 复制代码
// Vite 的 import() 预加载注释(实验性)
component: () => import(/* @vite-prefetch: true */ '@/views/Dashboard.vue')

// 手动实现预加载:在用户悬停菜单项时触发
function prefetchRoute(path) {
  // 查找路由配置,触发懒加载(但不导航)
  const matched = router.resolve(path)
  matched.matched.forEach(record => {
    const component = record.components?.default
    if (typeof component === 'function') {
      component()  // 触发 import(),浏览器会预下载
    }
  })
}

// 在菜单项 hover 时预加载
<el-menu-item
  @mouseenter="prefetchRoute(item.path)"
>

10. Vue Router 4 的设计演进

为什么要彻底重写(Vue Router 4 vs 3)

Vue Router 4 不是渐进式升级,而是结合 Vue 3 的新特性做了完整重写。核心驱动力:

1. Composition API 优先

Vue Router 3 中,访问路由信息依赖 this.$routerthis.$route,这在 Composition API 的 setup() 中非常不方便。Vue Router 4 提供了 useRouter()useRoute(),让路由成为纯粹的响应式数据。

2. TypeScript 原生支持

Vue Router 4 完全用 TypeScript 重写,API 设计从一开始就考虑了类型推断。Vue Router 3 的 TypeScript 支持是后来补充的,存在很多类型不准确的问题。

3. 更好的 Tree Shaking

Vue Router 4 采用模块化设计,每个功能都是独立的,打包时可以只包含用到的部分。

4. 统一的守卫 API

Vue Router 3 的守卫混用 next() 回调和返回值两种风格,容易误用。Vue Router 4 统一为返回值语义,更符合函数式编程直觉。

关键 API 变更深度分析

javascript 复制代码
// 通配符路由变更
// Vue Router 3:
{ path: '*', component: NotFound }

// Vue Router 4:
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound }
// 原因:新的路由匹配算法基于 path-to-regexp v6,不再支持裸 * 通配符
//      /:pathMatch(.*)*  语义更明确:pathMatch 是捕获所有的命名参数
//      参数名 pathMatch 可以在组件中通过 route.params.pathMatch 访问

// 导航守卫:next() → 返回值
// Vue Router 3:
router.beforeEach((to, from, next) => {
  if (!isLoggedIn) next('/login')
  else next()
})

// Vue Router 4:
router.beforeEach((to, from) => {
  if (!isLoggedIn) return '/login'
  // undefined 或 true 表示放行,不需要显式返回
})
// 原因:next() 风格要求必须调用 next,否则导航卡住。
//       返回值风格让意图更清晰,undefined 也是合法的"放行"。

11. 跨框架视角:React Router 与 Angular Router 对比

理解不同框架的路由设计,能加深对"路由本质"的理解,也能在团队技术选型时做出有依据的判断。

React Router v6

jsx 复制代码
// React Router v6 的设计哲学:路由是组件
import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'

function App() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        <Route index element={<Dashboard />} />
        <Route path="user" element={<UserList />} />
        <Route path="user/:id" element={<UserDetail />} />
      </Route>
      <Route path="login" element={<Login />} />
      <Route path="*" element={<Navigate to="/404" />} />
    </Routes>
  )
}

// 守卫用组件实现(而非钩子)
function PrivateRoute({ children }) {
  const isLoggedIn = useAuth()
  return isLoggedIn ? children : <Navigate to="/login" />
}

React Router 的设计理念:路由是 UI 的一部分,用 JSX 声明路由是自然的。守卫用包装组件(Higher-Order Component)实现,符合 React 的"一切皆组件"哲学。

vs Vue Router :Vue Router 把路由配置与组件树分离,统一在 routes 数组中声明,守卫用专门的钩子实现。两种风格各有利弊:React Router 的组件化路由更灵活(路由本身可以有动态逻辑),Vue Router 的集中配置更清晰(所有路由一目了然)。

Angular Router

Angular Router 最大的特点是模块化路由懒加载模块的深度集成:

typescript 复制代码
// Angular Router:路由与 NgModule 深度绑定
const routes: Routes = [
  {
    path: 'user',
    loadChildren: () => import('./user/user.module').then(m => m.UserModule)
    // 整个功能模块(包含组件、服务、路由)都懒加载
  }
]

Angular Router 的特色

  • 懒加载粒度是"模块",而不只是组件
  • 每个懒加载模块有自己的路由配置(路由的层次化组织)
  • 内置了基于路由的Code Splitting和依赖注入作用域管理

对比总结

特性 Vue Router 4 React Router 6 Angular Router
配置风格 集中式配置数组 JSX 声明式 TS 数组 + 模块
守卫实现 专用 hook 组件包装 CanActivate 接口
懒加载粒度 组件 组件/路由 模块
TypeScript 好(原生TS写的) 极好(Angular原生TS)
学习曲线 中(需要理解JSX路由) 高(与框架深耦合)

12. 项目配置实战:完整工程实现

目录结构

复制代码
src/router/
├── index.ts            # 路由实例创建与导出
├── guards.ts           # 全局守卫
├── routes/
│   ├── constant.ts     # 固定路由(不需要权限)
│   └── modules/        # 按业务模块拆分(用于大型项目)
│       ├── user.ts
│       └── order.ts
└── types.ts            # 路由相关类型扩展

types.ts(类型扩展)

typescript 复制代码
// router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    title?: string           // 页面标题(用于 document.title 和面包屑)
    icon?: string            // 菜单图标
    hidden?: boolean         // 是否在菜单中隐藏
    keepAlive?: boolean      // 是否启用 <keep-alive> 缓存
    permissions?: string[]   // 访问所需的权限码
    noAuth?: boolean         // true 表示不需要登录(白名单)
    breadcrumb?: string[]    // 自定义面包屑路径(可选,不设则自动生成)
    affix?: boolean          // 是否固定在 TabsView 中(不可关闭)
    transition?: string      // 页面切换动画名称
  }
}

index.ts

typescript 复制代码
// router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import { constantRoutes } from './routes/constant'
import { setupGuards } from './guards'

const router = createRouter({
  history: createWebHashHistory(import.meta.env.BASE_URL),
  routes: constantRoutes as RouteRecordRaw[],
  scrollBehavior(to, from, savedPosition) {
    // 浏览器前进/后退时,恢复滚动位置
    if (savedPosition) return savedPosition
    // 有 hash 锚点时,滚动到锚点
    if (to.hash) return { el: to.hash, behavior: 'smooth' }
    // 默认:切换路由时滚动到顶部
    return { top: 0, behavior: 'smooth' }
  }
})

setupGuards(router)

export default router

guards.ts(带 NProgress 进度条)

typescript 复制代码
// router/guards.ts
import type { Router } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { useUserStore } from '@/store/user'
import { usePermissionStore } from '@/store/permission'

NProgress.configure({ showSpinner: false })

export function setupGuards(router: Router) {
  router.beforeEach(async (to, from) => {
    NProgress.start()
    
    const userStore = useUserStore()
    const permStore = usePermissionStore()
    
    // 不需要登录的页面直接放行
    if (to.meta.noAuth) return true
    
    // 未登录:跳转登录,携带目标路径
    if (!userStore.token) {
      NProgress.done()
      return { path: '/login', query: { redirect: to.fullPath } }
    }
    
    // 已登录,但动态路由未加载(首次访问或刷新后)
    if (!permStore.isLoaded) {
      try {
        await permStore.loadPermissions()
        // 关键:重新导航,让新注册的路由生效
        return { ...to, replace: true }
      } catch {
        userStore.logout()
        return { path: '/login', query: { redirect: to.fullPath } }
      }
    }
    
    return true
  })
  
  router.afterEach((to) => {
    NProgress.done()
    if (to.meta.title) {
      document.title = `${to.meta.title} - 管理系统`
    }
  })
  
  // 捕获导航失败(如守卫抛出的错误)
  router.onError((error) => {
    console.error('[Router Error]', error)
    NProgress.done()
  })
}

13. 高级模式:路由与状态的深度集成

将路由作为唯一数据源(URL-first 设计)

对于分页、筛选等场景,把状态存在 URL 而非组件局部状态,有显著优势:

typescript 复制代码
// composables/useUrlQuery.ts
// 把分页和筛选参数同步到 URL query

import { reactive, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'

interface PageQuery {
  page: number
  pageSize: number
  [key: string]: any
}

export function useUrlQuery<T extends PageQuery>(defaults: T) {
  const route = useRoute()
  const router = useRouter()
  
  // 从 URL 初始化(刷新页面时恢复状态)
  const query = reactive<T>({
    ...defaults,
    ...parseQuery(route.query)  // URL query 的值都是字符串,需要类型转换
  })
  
  // query 变化时同步到 URL
  watch(query, (newQuery) => {
    router.replace({ query: serializeQuery(newQuery) })
  }, { deep: true })
  
  return { query }
}

function parseQuery(urlQuery: Record<string, string | string[]>): Partial<PageQuery> {
  const result: Record<string, any> = {}
  for (const [key, value] of Object.entries(urlQuery)) {
    const num = Number(value)
    result[key] = isNaN(num) ? value : num  // 尽可能转为数字
  }
  return result
}

// 使用
const { query } = useUrlQuery({ page: 1, pageSize: 10, status: '' })

// 改变 query.page,URL 自动更新
// 分享 URL,对方看到相同的筛选结果
// 刷新页面,筛选状态保留

路由过渡动画与方向感知

vue 复制代码
<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <transition :name="transitionName">
      <component :is="Component" :key="route.path" />
    </transition>
  </router-view>
</template>

<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'

// 路由层级(在 meta 中定义,用于决定动画方向)
const route = useRoute()
const transitionName = ref('fade')

const routeOrder = {
  '/dashboard': 0,
  '/user': 1,
  '/order': 2,
  '/report': 3,
}

watch(
  () => route.path,
  (to, from) => {
    const toOrder = routeOrder[to] ?? 0
    const fromOrder = routeOrder[from] ?? 0
    // 层级更深(数字更大):向左滑入;层级更浅:向右滑入
    transitionName.value = toOrder > fromOrder ? 'slide-left' : 'slide-right'
  }
)
</script>

14. SSR 场景下的路由特殊处理

当使用 Nuxt 3 或 Vite SSR 进行服务端渲染时,路由有几个重要差异:

每个请求需要独立的 Router 实例

javascript 复制代码
// ❌ 错误:单例 Router 在 SSR 中会导致状态污染
// 用户 A 的请求修改了路由状态,用户 B 看到的是用户 A 的状态

// ✅ 正确:每个 SSR 请求创建独立的 Router 实例
// server.js
app.get('*', async (req, res) => {
  const router = createRouter({
    history: createMemoryHistory(req.url),  // 用 Memory History,不依赖浏览器
    routes: [...]
  })
  
  // 等待路由导航完成(解析异步组件等)
  await router.push(req.url)
  await router.isReady()
  
  const { html } = await renderToString(createApp(router))
  res.send(html)
})

路由数据预取(Server-Side Data Fetching)

javascript 复制代码
// 在服务端,组件需要在渲染前就获取数据(没有交互,没有 DOM)
// Nuxt 3 的 useFetch / useAsyncData 解决这个问题

// pages/user/[id].vue(Nuxt 3 文件路由)
const route = useRoute()
const { data: user } = await useFetch(`/api/users/${route.params.id}`)
// 这段代码在服务端和客户端都能运行:
// 服务端:等待 fetch 完成后渲染 HTML
// 客户端:hydration 时,Nuxt 自动复用服务端的数据,不重复请求

15. 常见坑深度解析

坑 1:addRoute 后当前导航不生效

现象 :调用 router.addRoute() 后立即 router.push('/new-route'),仍然 404。

原因addRoute 同步更新路由表,但当前正在进行的导航(如守卫中调用 addRoute)已经在匹配阶段过了。新路由的匹配要在下一次导航中才生效。

解决 :在守卫中 addRoute 后,用 return { ...to, replace: true } 触发一次新导航。

typescript 复制代码
// router/guards.ts
if (!permStore.isLoaded) {
  await permStore.loadPermissions() // 内部调用了 router.addRoute
  
  // ✅ 关键:返回目标路由,触发一次新的导航
  // 这次新导航发生时,新路由已在路由表中
  return { ...to, replace: true }
}

坑 2:History 模式 Nginx 配置后还是 404

现象 :Nginx 已配置 try_files $uri $uri/ /index.html,但某些路径仍然 404。

可能原因 :静态文件路径配置有误,root 指向了错误目录。

nginx 复制代码
# 完整的 Nginx 配置(History 模式)
server {
  listen 80;
  server_name your-domain.com;
  
  # !! root 必须指向 dist 目录
  root /usr/share/nginx/html/dist;
  index index.html;
  
  # 静态资源直接返回(不走 try_files,提高性能)
  location ~* \.(js|css|png|jpg|ico|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }
  
  # API 代理(必须在 / 前定义,优先级更高)
  location /api {
    proxy_pass http://backend:3001;
  }
  
  # 所有其他请求返回 index.html
  location / {
    try_files $uri $uri/ /index.html;
  }
}

坑 3:多层嵌套路由,子路由 path 不要写 /

javascript 复制代码
// ❌ 错误:子路由 path 以 / 开头,变成了绝对路径
const routes = [
  {
    path: '/',
    component: Layout,
    children: [
      { path: '/user', component: UserPage }  // 变成了绝对路径 /user,跳出了嵌套
    ]
  }
]

// ✅ 正确:子路由 path 是相对路径(不以 / 开头)
const routes = [
  {
    path: '/',
    component: Layout,
    children: [
      { path: 'user', component: UserPage }  // 相对路径,完整路径是 /user
    ]
  }
]

坑 4:路由守卫中使用 Pinia Store 的时机

typescript 复制代码
// ❌ 模块顶部调用(Pinia 未初始化)
const userStore = useUserStore()  // Error: No active Pinia

// ✅ 函数内部调用(执行守卫时 Pinia 已通过 app.use(pinia) 初始化)
router.beforeEach((to, from) => {
  const userStore = useUserStore()  // ✅ 此时已初始化
})

// 确保 main.js 中 pinia 在 router 之前安装:
// app.use(pinia)   ← 先安装 Pinia
// app.use(router)  ← 再安装 Router

小结

路由系统的本质认知

复制代码
浏览器规范层:
  Session History(历史栈) + hashchange / popstate 事件
           ↓
路由抽象层:
  Hash History / HTML5 History / Memory History
           ↓
框架路由层(Vue Router):
  Matcher(路由表编译与匹配) + 守卫状态机 + RouterView 渲染
           ↓
应用业务层:
  登录守卫 + 动态路由 + 权限控制 + 代码分割

最核心的五个认知

  1. Hash vs History 的本质:前者利用 fragment 不发请求,后者利用 pushState 改 URL,两者都绕过了"URL 变化 = 发请求"的默认行为

  2. 守卫是状态机:导航是有序的异步过程,任何一个守卫可以中止它;理解执行顺序是正确使用守卫的前提

  3. 组件复用是性能优化:动态参数变化时组件不重建,是 Vue 的刻意设计,应该用 watch 响应参数变化,而不是强制重建

  4. 前端路由是后端路由的映射:前端路由控制 UI,后端路由控制数据和权限;前端路由隐藏入口不等于安全

  5. 懒加载是必须项:路由懒加载是应用性能的基础优化,没有它,应用规模一旦增长首屏体验就会崩溃

相关推荐
mldong6 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排6 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60617 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角8 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!8 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰8 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi10 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen10 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马11 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu11 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss