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

相关推荐
newbe365242 小时前
Design.md:让 AI 一致性进行前端 UI 设计的解决方案
前端·人工智能·ui
guojb8242 小时前
Vue3 高阶技巧:使用 AST 将 HTML 字符串优雅渲染为自定义组件
前端·javascript·vue.js
之歆2 小时前
API 层架构设计 — 从 RESTful 到 GraphQL 的范式演进
vue.js·后端·restful·graphql
MonkeyKing2 小时前
iOS Runtime 深度解析
前端·面试
程序员库里2 小时前
第 3 章:Tiptap 与 React 集成
前端·javascript·面试