写在前面
"路由"是 Web 开发中被最频繁使用、却最少被深入理解的概念之一。大多数人会配置
routes数组、加几行守卫代码,但对"URL 变化是如何触发组件渲染的""守卫为什么要按那个顺序执行""为什么 History 模式刷新会 404"这些问题知之甚少。更深层的问题是:浏览器的导航模型是什么?前端路由本质上在做什么?与后端路由有何本质区别?本篇从浏览器规范层面出发,经过框架实现细节,再到工程实践,尝试建立一个完整的路由认知体系。只有把这些底层原理想清楚,你在面对"路由守卫死循环""动态路由 404""History 模式 Nginx 配置"这些常见问题时,才能从容应对,而不是靠试错凑出来的。
参考资料:HTML Living Standard - Session history、Vue 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 实例:整合以上两者,管理当前路由状态,执行守卫链
RouterLink 的实现原理
<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.$router 和 this.$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 渲染
↓
应用业务层:
登录守卫 + 动态路由 + 权限控制 + 代码分割
最核心的五个认知:
-
Hash vs History 的本质:前者利用 fragment 不发请求,后者利用 pushState 改 URL,两者都绕过了"URL 变化 = 发请求"的默认行为
-
守卫是状态机:导航是有序的异步过程,任何一个守卫可以中止它;理解执行顺序是正确使用守卫的前提
-
组件复用是性能优化:动态参数变化时组件不重建,是 Vue 的刻意设计,应该用 watch 响应参数变化,而不是强制重建
-
前端路由是后端路由的映射:前端路由控制 UI,后端路由控制数据和权限;前端路由隐藏入口不等于安全
-
懒加载是必须项:路由懒加载是应用性能的基础优化,没有它,应用规模一旦增长首屏体验就会崩溃