Vue 3 设计与实现
第 16 章 Vue Router 内核
第 16 章 Vue Router 内核
本章要点
- 路由的本质:URL 与组件树的映射关系
- createRouter 的架构:matcher(路由匹配)+ history(URL 管理)+ 导航守卫的三位一体
- 路由匹配器:如何将
/user/:id/posts这样的路径模式编译为高效的正则表达式- History 模式的实现差异:createWebHistory vs createWebHashHistory vs createMemoryHistory
- 导航解析的完整流程:从 router.push 到组件渲染的 12 个步骤(官方文档 Full Navigation Resolution Flow 的口径)
- 导航守卫的串行队列:beforeRouteLeave → beforeEach → beforeRouteUpdate → beforeEnter → beforeRouteEnter → beforeResolve,导航确认后再 afterEach
- RouterView 的实现:如何利用 provide/inject 实现嵌套路由
- 路由懒加载与代码分割的底层机制
本章源码核查基准:vue-router v4.6.4(
packages/router/src,不含experimental/目录下的新解析器)。
URL 是 Web 应用的"灵魂"。用户分享链接、浏览器前进后退、SEO 爬虫抓取——所有这些都依赖于 URL 与应用状态的正确映射。Vue Router 就是管理这种映射关系的核心库。
表面上看,路由只是"URL 变了就渲染对应组件"。但深入内核你会发现,这背后涉及路径模式的编译与匹配、浏览器 History API 的封装、异步导航守卫的流程控制、嵌套路由的组件协调等一系列精密的工程实现。
路由系统的工程难度来自哪里
换个角度想:单页应用(SPA)出现之前,"URL ↔ 页面"的映射天生由浏览器和服务器来维护——请求一次、返回一次、换一次 URL。但 SPA 打破了这个对应:页面不再真正"跳转",只是同一个 DOM 在重绘。于是浏览器不再帮我们管历史栈、不再触发重新加载、不再有"取消导航"这种天然语义。Vue Router 的所有复杂度都来自把这些天然语义重新在 JavaScript 里复刻一遍,并且还要比浏览器原生实现做得更好(可以 async、可以取消、可以注入副作用)。
作一个对比更清楚:浏览器处理 <a href="/user/1"> 跳转的流程是"下载 HTML → 解析 → 渲染",纯顺序、无异步分支、无中断。Vue Router 处理 router.push('/user/1') 却要在一段 JavaScript 里完成:解析路径→匹配路由→运行一串可能返回 Promise 的异步守卫→加载懒加载组件→更新 URL→触发视图重绘→执行滚动行为。这串操作中任何一步都可能抛异常、取消、重定向、超时——任何一个环节丢失了正确的错误处理,都会让 URL 和视图出现"失同步"(URL 改了但页面没换、或者页面换了但 URL 没改),这是用户体验的灾难。
所以读 Vue Router 源码最有收获的不是"认识了多少 API",而是学会怎么在 JS 里用 Promise 管理一个带分支、可取消、可重入的状态机。这种思维模型在后面做表单流程、购物车多步结账、大型 Wizard 都能复用。《React 19 内核探秘》专栏第 9 章讨论过类似的问题——React 用 Suspense 把异步渲染塞进调度器;Vue Router 用 Promise 队列把异步导航塞进一个线性的 Promise 链。两种思路、同一种工程难度。
本章读法建议
Vue Router 的源码不大(packages/router/src 下 TypeScript 约 7500 行,不含 experimental/),但组织精巧。本章的阅读策略是:先看三条独立的轴,再看它们如何在 navigate 函数里汇合。
- 轴 1(数据结构轴):路由记录怎么表达 →
RouteRecordRaw、RouteRecordNormalized、RouteRecordMatcher三层,后面两层是运行时产物 - 轴 2(URL 轴):浏览器地址栏怎么和内存状态同步 →
RouterHistory接口 + 三个实现 - 轴 3(流程轴):一次 push 发生了什么 → navigate 函数串起来的 6 段守卫队列
这三条轴分别对应 16.2、16.3、16.4 三节。读完这三节后再回看 16.1 的整体架构图,就能看见每根轴在哪里、怎么耦合。
16.1 整体架构
Vue Router 4 的核心由三个模块组成:
graph TD
A["createRouter()"] --> B["Matcher<br/>路由匹配器"]
A --> C["RouterHistory<br/>URL 管理"]
A --> D["Navigation Guards<br/>导航守卫"]
B --> E["路径解析<br/>tokenizePath / tokensToParser"]
B --> F["路由记录<br/>RouteRecordMatcher"]
C --> G["createWebHistory"]
C --> H["createWebHashHistory"]
C --> I["createMemoryHistory"]
D --> J["beforeEach / afterEach"]
D --> K["beforeEnter / beforeRouteLeave"]
D --> L["Guard 队列执行引擎"]
style A fill:#42b883,color:#333
style B fill:#35495e,color:#fff
style C fill:#35495e,color:#fff
style D fill:#35495e,color:#fff
createRouter 的入口
// vue-router 4.6.4 packages/router/src/router.ts:132
export function createRouter(options: RouterOptions): Router {
// 1. 创建匹配器
const matcher = createRouterMatcher(options.routes, options)
// 2. 获取 history 实现
const routerHistory = options.history
// 3. 导航守卫数组
const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
const afterGuards = useCallbacks<NavigationHookAfter>()
// 4. 当前路由(响应式)
const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
START_LOCATION_NORMALIZED
)
const router: Router = {
currentRoute,
listening: true,
addRoute,
removeRoute,
clearRoutes: matcher.clearRoutes,
hasRoute,
getRoutes,
resolve,
options,
push,
replace,
go,
back: () => go(-1),
forward: () => go(1),
beforeEach: beforeGuards.add,
beforeResolve: beforeResolveGuards.add,
afterEach: afterGuards.add,
onError: errorListeners.add,
isReady,
install(app: App) { /* ... */ },
}
return router
}
注意 currentRoute 使用的是 shallowRef 而不是 ref。这是因为路由对象包含大量嵌套属性(params、query、matched 等),深层响应式会带来不必要的性能开销。路由变化时,整个对象被替换(而不是修改内部属性),所以浅层响应足矣。
shallowRef 的 cost/benefit 权衡
这个选择值得展开一点——它是整份 Vue Router 源码里最能体现"深度性能意识"的地方之一。
ref(obj) 的语义是 reactive(obj) + 一层 .value 壳子:对象会被包成 reactive Proxy,访问到嵌套对象时再逐层包成 Proxy(深层响应式)。对一个普通业务对象这没什么问题,但路由对象不是普通对象。一条典型路由的 matched 字段是一个数组,每个元素是一个 RouteRecordNormalized,里面又有 components(一个记录了组件、工厂函数、异步 resolved 状态的字典)、meta、children、props,有些字段可能还是数组或嵌套对象。嵌套路由越深,深层响应式要代理的对象就越多,每个访问都要过一次 Proxy。
更关键的是路由对象的变更模式:每次导航结束,Router 都是整体替换 currentRoute.value,不会原地修改内部字段。深层响应式在这种场景下毫无价值——即便业务代码写 watch(() => route.params.id),真正需要追踪的也只是 currentRoute 这一个引用的替换。Vue 3 的 shallowRef 正是为这种"粒度是整体"的场景设计的:只有 .value 替换时触发更新,内部字段的读取是普通属性读取,无 Proxy 开销。
这和第 5 章讲 shallowRef 时的结论完全一致:响应式粒度要和真实变更粒度匹配。如果 Vue Router 把 currentRoute 写成 ref,每次导航后读取新路由对象的嵌套字段都要额外创建一批 Proxy——嵌套路由越多、这笔开销越大,而它换不来任何有用的追踪。shallowRef 把这笔开销直接省掉。
16.2 路由匹配器
路径模式的编译
Matcher 是 Vue Router 最"编译器化"的一块。它的输入是人类友好的字符串(/user/:id/posts),输出是机器高效的数据结构(预编译的正则 + 参数提取表)。这个从 DSL 到可执行形式的翻译过程,几乎可以看成一个微型的编译器:有词法分析、有语法(其实是段级的 tokenizer)、有代码生成(生成 RegExp)、有优化(评分排序)。对比《Rust 编译器与运行时揭秘》专栏第 14 章讲 rustc 的 Token Tree,你会发现 Matcher 和真正的编译器前端在架构思想上惊人地一致——只不过规模小得多。
这个"小编译器"值得好好读源码,因为它展示了怎么把一个看起来简单的字符串匹配问题,切分成一系列明确可测的阶段。一个稍微想一下就发现水比较深的事实:路径匹配要能正确区分 /user/profile 和 /user/:id——前者是静态字面量、后者是动态参数,但它们有相同的"形状";更要能正确处理 /user/:id(\\d+)(只允许数字)、/files/:path(.*)(贪婪通配)、/:lang?/docs(可选参数)这些变种。直接写一个 if-else 的 switch 语句能做出来,但维护噩梦。Vue Router 选择的路径是先 tokenize、再 compile、最后评分——三阶段流水线,每阶段只做一件事。
当你定义 /user/:id/posts 这样的路径时,Vue Router 需要将它编译为能匹配实际 URL 的正则表达式。这个过程分两步:
第一步:词法分析(tokenizePath)
// 将路径字符串分解为 token 数组
tokenizePath('/user/:id/posts')
// 输出:
[
[{ type: TokenType.Static, value: 'user' }],
[{ type: TokenType.Param, value: 'id', regexp: '', repeatable: false, optional: false }],
[{ type: TokenType.Static, value: 'posts' }]
]
每个路径段被分解为一个 token 数组。token 类型包括:
这里有个有意思的细节:段(segment)和 token 是两层。一个路径被 / 切成多段,每段内部还可以有多个 token——这对应类似 /user-:id 这种混合了静态和动态的"组合段"。tokenizer 把它识别为两个 token [Static('user-'), Param('id')],后续的正则编译能对每个 token 精确生成模式,保证 /user-42 能匹配而 /user42(中间没有连字符)不匹配。
这种两层结构的好处是扩展性:未来要加新的 token 类型(比如带正则白名单的 :id<uuid>),只需在 tokenizer 和 tokensToParser 里各加一个分支,不影响其它逻辑。Vue Router 3 把这件事交给第三方的 path-to-regexp;v4 改为自研解析(官方迁移指南给的理由是"允许路由排序并实现动态路由"),带正则、可重复、可选、通配这几种变体都落在这套两层结构里。
| 类型 | 示例 | 说明 |
|---|---|---|
| Static | user |
固定字符串 |
| Param | :id |
动态参数 |
| Param + regexp | :id(\\d+) |
带约束的参数 |
| Param + repeat | :chapters+ |
可重复参数 |
| Param + optional | :lang? |
可选参数 |
第二步:编译为正则(tokensToParser)
// packages/router/src/matcher/pathParserRanker.ts:128
// (略去自定义正则的合法性校验、strict 模式下根段补 '/' 等细节)
export function tokensToParser(
segments: Array<Token[]>,
extraOptions?: _PathParserOptions
): PathParser {
const options = assign({}, BASE_PATH_PARSER_OPTIONS, extraOptions)
const score: Array<number[]> = []
let pattern = options.start ? '^' : ''
const keys: PathParserParamKey[] = []
for (const segment of segments) {
// 根路径 "/" 是一个空段,单独记 Root 分
const segmentScores: number[] = segment.length ? [] : [PathScore.Root]
for (let tokenIndex = 0; tokenIndex < segment.length; tokenIndex++) {
const token = segment[tokenIndex]
// 每个 token 从 Segment 基础分起算(/:a-other-:b 这类子段各算一次)
let subSegmentScore: number =
PathScore.Segment +
(options.sensitive ? PathScore.BonusCaseSensitive : 0)
if (token.type === TokenType.Static) {
if (!tokenIndex) pattern += '/'
pattern += token.value.replace(REGEX_CHARS_RE, '\\$&')
subSegmentScore += PathScore.Static
} else if (token.type === TokenType.Param) {
const { value, repeatable, optional, regexp } = token
keys.push({ name: value, repeatable, optional })
const re = regexp ? regexp : BASE_PARAM_PATTERN // '[^/]+?'
if (re !== BASE_PARAM_PATTERN) subSegmentScore += PathScore.BonusCustomRegExp
let subPattern = repeatable ? `((?:${re})(?:/(?:${re}))*)` : `(${re})`
if (!tokenIndex)
subPattern =
optional && segment.length < 2 ? `(?:/${subPattern})` : '/' + subPattern
if (optional) subPattern += '?'
pattern += subPattern
subSegmentScore += PathScore.Dynamic
if (optional) subSegmentScore += PathScore.BonusOptional
if (repeatable) subSegmentScore += PathScore.BonusRepeatable
if (re === '.*') subSegmentScore += PathScore.BonusWildcard
}
segmentScores.push(subSegmentScore)
}
score.push(segmentScores)
}
// strict 加分只加在最后一个 score 上
if (options.strict && options.end) {
const i = score.length - 1
score[i][score[i].length - 1] += PathScore.BonusStrict
}
if (!options.strict) pattern += '/?'
if (options.end) pattern += '$'
else if (options.strict && !pattern.endsWith('/')) pattern += '(?:/|$)'
const re = new RegExp(pattern, options.sensitive ? '' : 'i')
// 返回 parser 对象
return {
re,
score,
keys,
parse(path) { /* 用 re 匹配并提取参数 */ },
stringify(params) { },
}
}
路由评分系统
当多条路由都能匹配同一个 URL 时,Vue Router 使用评分系统选择最佳匹配:
// packages/router/src/matcher/pathParserRanker.ts:102
const enum PathScore {
_multiplier = 10,
Root = 9 * _multiplier, // just /
Segment = 4 * _multiplier, // /a-segment
SubSegment = 3 * _multiplier, // /multiple-:things-in-one-:segment
Static = 4 * _multiplier, // /static
Dynamic = 2 * _multiplier, // /:someId
BonusCustomRegExp = 1 * _multiplier, // /:someId(\\d+)
BonusWildcard = -4 * _multiplier - BonusCustomRegExp, // /:namedWildcard(.*) 抹掉自定义正则的加分
BonusRepeatable = -2 * _multiplier, // /:w+ or /:w*
BonusOptional = -0.8 * _multiplier, // /:w? or /:w*
// 这两项必须小于 0.1,保证 strict 的 /:page 仍然低于 /:a-:b
BonusStrict = 0.07 * _multiplier, // options.strict: true
BonusCaseSensitive = 0.025 * _multiplier, // options.sensitive: true
}
规则直觉:
- 静态路径 > 动态路径:
/user/profile优先于/user/:id - 有约束 > 无约束:
/user/:id(\\d+)优先于/user/:id - 必选 > 可选:
/user/:id优先于/user/:id? - 通配符最低:
/:path(.*)只在没有其他匹配时才命中
为什么要评分而不是"谁先定义谁赢"
一个更简单的做法是"第一个匹配的赢"——用户把静态路由放前面、动态路由放后面,自然就能正确优先。早期的 React Router v3、Express.js 都是这么做的。
但这种"隐式顺序依赖"有个致命问题:路由定义一多,顺序错了不好发现。设想你的应用有 50 条路由,某天同事新增了一条 /:path(.*) 作为 404 兜底,随手放到了前面——于是所有路由都被 404 接管,但单元测试可能刚好没覆盖。评分排序把这种顺序依赖显式化:不管你怎么排,引擎都按"具体度降序"处理。这就是 Vue Router 选择编译期排序(而不是运行时顺序)的根本原因——让路由的正确性和定义顺序解耦。
这个思路和《vLLM 推理内核深度解析》专栏第 3 章的 scheduler 设计有共通之处——都是"不依赖调用顺序,而依赖某种可量化的优先级"。在系统设计里,消除隐式顺序依赖几乎总是能降低 bug 率。
graph LR
A["URL: /user/123"] --> B{"按 score 降序逐条 re.test"}
B --> C["/user/profile — [[80],[80]],re 不匹配"]
B --> D["/user/:id(\\d+) — [[80],[70]]"]
B --> E["/user/:id — [[80],[60]]"]
B --> F["/:path(.*) — [[20]]"]
D -->|最佳匹配| G["选中 /user/:id(\\d+)"]
style G fill:#42b883,color:#333
createRouterMatcher
// packages/router/src/matcher/index.ts:60(简化:省略 alias、同名路由替换与 dev 检查)
export function createRouterMatcher(
routes: Readonly<RouteRecordRaw[]>,
globalOptions: PathParserOptions
): RouterMatcher {
const matchers: RouteRecordMatcher[] = []
const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>()
function addRoute(record: RouteRecordRaw, parent?: RouteRecordMatcher) {
const normalizedRecord = normalizeRouteRecord(record)
const { path } = normalizedRecord
// 处理嵌套路由:子路径不以 / 开头时才拼接父路径
if (parent && path[0] !== '/') {
const parentPath = parent.record.path
const connectingSlash =
parentPath[parentPath.length - 1] === '/' ? '' : '/'
normalizedRecord.path = parentPath + (path && connectingSlash + path)
}
const matcher: RouteRecordMatcher = createRouteRecordMatcher(
normalizedRecord,
parent,
globalOptions
)
// 按 score 排序插入(既无 name、也无 components、也无 redirect 的记录——比如只用来分组 children 的父记录——不进 matchers)
if (isMatchable(matcher)) insertMatcher(matcher)
// 递归处理子路由
if ('children' in normalizedRecord && normalizedRecord.children) {
for (const child of normalizedRecord.children) {
addRoute(child, matcher)
}
}
}
function resolve(
location: MatcherLocationRaw,
currentLocation: MatcherLocation
): MatcherLocation {
let matcher: RouteRecordMatcher | undefined
if ('name' in location && location.name) {
// 命名路由:直接通过 Map 查找
matcher = matcherMap.get(location.name)
// ...
} else if (location.path != null) {
// 路径匹配:matchers 已按 score 排好序,find 到的第一个就是最高分
matcher = matchers.find(m => m.re.test(location.path))
if (matcher) params = matcher.parse(location.path)!
// ...
}
// 沿 parent 链向上收集 matched 数组,父记录在前
// ...
}
// 初始化:添加所有路由
routes.forEach(route => addRoute(route))
return { addRoute, resolve, removeRoute, clearRoutes, getRoutes, getRecordMatcher }
}
命名路由通过 Map 实现 O(1) 查找;路径路由通过排序后的数组实现"优先匹配高分"。
嵌套路由的展开时机
上面 addRoute 函数里 if (parent && path[0] !== '/') { normalizedRecord.path = parentPath + ... } 这几行值得单独说。路由的嵌套是树形结构,但 matcher 数组是一维的——这意味着树必须在 addRoute 时被展平。展平的方式是"路径拼接":子路由记录的 path 被改写为父路径 + 自身路径。
这种编译期展平带来两个好处:第一,运行时匹配只需要一次数组遍历,不需要递归下钻;第二,所有路径在 matcher 里都是"完整形式",容易调试。代价是所有子路由都要连带父路径信息存一份——但路径字符串本身很短,内存开销可忽略。
有个容易踩坑的细节:子路径以 / 开头意味着绝对路径(忽略父路径)——这正是上面 path[0] !== '/' 那个判断的来源:
// /admin 下挂 /login,最终路径不是 /admin/login 而是 /login
{ path: '/admin', children: [{ path: '/login', component: Login }] }
这个行为并不是 v4 新增的——Vue Router 3 的文档就写明 "nested paths that start with / will be treated as a root path",v4 只是把它落实在 path[0] !== '/' 这一处判断上,并在 dev 模式下用 checkMissingParamsInAbsolutePath 提醒你绝对子路径漏掉了父级的参数。它仍然是嵌套路由里最容易误踩的点之一。《微前端源码精讲》在讲 qiankun 路由接管时提过类似问题——子应用的路由既不能完全相对(会冲突),也不能完全绝对(失去上下文),最终 qiankun 选择了"前缀隔离"的折中方案。Vue Router 走的是另一条路(显式决定权给开发者)。
16.3 History 模式
统一接口
// packages/router/src/history/common.ts:74(源码带 export)
interface RouterHistory {
readonly base: string
readonly location: HistoryLocation
readonly state: HistoryState
push(to: HistoryLocation, data?: HistoryState): void
replace(to: HistoryLocation, data?: HistoryState): void
go(delta: number, triggerListeners?: boolean): void
listen(callback: NavigationCallback): () => void
createHref(location: HistoryLocation): string
destroy(): void
}
三种 History 实现共享相同的接口,Router 不关心底层是 HTML5 History API、Hash 还是内存模式。
为什么一定要抽象这层接口
把 History 从 Router 里抽出来做成接口,不是"架构控"的洁癖,而是硬需求。Router 核心要同时服务三类截然不同的宿主:
- 浏览器主路径:
createWebHistory,用history.pushState和popstate事件。URL 更新会真实影响浏览器地址栏、前进后退按钮、收藏夹。 - 兼容老浏览器 / 静态托管:
createWebHashHistory,把路径塞进#后面。老 IE、没有服务器路由重写能力的静态托管(如早期 GitHub Pages)都要走这条路。 - SSR / 单元测试 / Electron 无 BrowserWindow 的预渲染:
createMemoryHistory,完全不碰window。SSR 场景下根本没有window,强行用 webHistory 会直接报 ReferenceError。
如果 Router 核心直接写死用 history.pushState,那 SSR 就没法做、测试就得 mock 一大堆浏览器 API。抽象成接口后,三种宿主的差异被隔离到了 createXxxHistory 工厂函数里,Router 本身 100% 环境无关。这种"核心环境无关 + 边缘适配器"的模式在前端基础设施里极常见——《Vite 设计与实现》讲 Vite 的 dev server / build pipeline 共享一套 plugin 体系、只在 host environment 适配器上做差异,思路完全一致。
SSR 下为什么必须是 MemoryHistory
额外说一句:如果你在 SSR 场景下忘了把 createMemoryHistory() 传进 createRouter,而用了 createWebHistory(),会在 Node 里触发 ReferenceError: window is not defined——因为 createWebHistory 的初始化路径里要读 window.location。Vue Router 4 在这一点上比 Vue Router 3 更严格:v3 有过"检测环境自动降级"的逻辑,但实际上自动降级在 Hydration 场景下会导致 server 和 client 的路由状态不同步——客户端接管后一刷新导航栈就乱了。v4 索性把选择权完全交给开发者:环境不同则 history 不同,强制开发者显式表达意图。
createWebHistory
// packages/router/src/history/html5.ts:317
export function createWebHistory(base?: string): RouterHistory {
base = normalizeBase(base)
const historyNavigation = useHistoryStateNavigation(base)
const historyListeners = useHistoryListeners(
base,
historyNavigation.state,
historyNavigation.location,
historyNavigation.replace
)
function go(delta: number, triggerListeners = true) {
if (!triggerListeners) historyListeners.pauseListeners()
history.go(delta)
}
const routerHistory: RouterHistory = assign(
{ location: '', base, go, createHref: createHref.bind(null, base) },
historyNavigation,
historyListeners
)
// location / state 都是 getter:永远读到 historyNavigation 里的最新值
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => historyNavigation.location.value,
})
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => historyNavigation.state.value,
})
return routerHistory
}
核心是对浏览器 history.pushState / history.replaceState 和 popstate 事件的封装。这两个 History API 早在 HTML5 规范里就有,但 SPA 框架之前很少好好用——Vue Router(和 React Router、Angular Router)把它们包装成可用、可测、可 mock 的接口,才是"单页应用"能真正流行起来的幕后功臣之一。
useHistoryListeners:popstate 的处理
// packages/router/src/history/html5.ts:58(签名在源码里是多行带类型)
function useHistoryListeners(base, historyState, currentLocation, replace) {
let listeners: NavigationCallback[] = []
let teardowns: Array<() => void> = []
let pauseState: HistoryLocation | null = null
const popStateHandler: PopStateListener = ({ state }) => {
const to = createCurrentLocation(base, location)
const from = currentLocation.value
const fromState = historyState.value
let delta = 0
if (state) {
currentLocation.value = to
historyState.value = state
// 这次 popstate 是 router 自己 go() 出来的:吞掉,并复位 pauseState
if (pauseState && pauseState === from) {
pauseState = null
return
}
delta = fromState ? state.position - fromState.position : 0
} else {
// 没有 state 的条目(比如用户手改 hash):补一个 replace
replace(to)
}
// 通知所有监听者
listeners.forEach(listener => {
listener(currentLocation.value, from, {
delta,
type: NavigationType.pop,
direction: delta
? delta > 0 ? NavigationDirection.forward : NavigationDirection.back
: NavigationDirection.unknown,
})
})
}
function pauseListeners() {
pauseState = currentLocation.value
}
// ...listen()、beforeUnloadListener()、destroy() 省略
window.addEventListener('popstate', popStateHandler)
// iOS Safari 不触发 beforeunload,用 pagehide + visibilitychange 在离开前把滚动位置写进 history.state
window.addEventListener('pagehide', beforeUnloadListener)
document.addEventListener('visibilitychange', beforeUnloadListener)
return { pauseListeners, listen, destroy }
}
pauseState 机制用于区分"编程式导航"和"用户操作"。当 Router 以 go(delta, false) 主动回退历史(比如撤销一次被守卫拦下的 popstate 导航)时,会暂停 popstate 监听,避免触发重复的导航逻辑。
popstate 的非对称性
浏览器的 popstate 事件有个反直觉的设计:pushState 和 replaceState 不会触发 popstate,只有浏览历史的前进后退(包括用户点按钮和脚本调用 history.back() / history.go())以及只改 hash 的跳转才触发。这个设计初看怪异,一种理解是为了避免"脚本修改地址栏触发自己设置的 popstate 监听器"导致的无限循环。
所以 useHistoryListeners 的任务看起来简单——只听 popstate 就够了——但它要和 Router 的主动导航协调:Router push 的时候自己更新内部状态(不需要 popstate 驱动);用户按后退键时,popstate 触发,Router 需要感知并更新 currentRoute。这一进一出两条路径要共用同一套"路由状态变更"逻辑,pauseState 是它们之间的同步旗标。
顺便说,这也是为什么单纯用 <a href> + server-rendering 的传统网站在导航这件事上比 SPA 简单得多——浏览器帮你处理了一切。SPA 自己接管后,所有"浏览器免费送的能力"都得自己补齐。
createWebHashHistory
Hash 模式的实现复用了 createWebHistory 的大部分逻辑,只是在 URL 处理上有差异:
export function createWebHashHistory(base?: string): RouterHistory {
// 将 base 调整为 hash 前缀
base = location.host ? base || location.pathname + location.search : ''
if (!base.includes('#')) base += '#'
return createWebHistory(base)
}
/app/#/user/123 中,# 之后的部分作为实际路由路径。Hash 模式的优势是不需要服务器配置,因为 hash 部分不会发送到服务器。这在静态托管(GitHub Pages、COS、OSS 直接托管)上特别有用——任何 URL 都指向同一个 index.html,前端靠 hash 区分路由,不需要服务器做 rewrite 规则。代价是 URL 看起来"不干净"(带着 #),并且对 SEO 不友好(部分老旧爬虫不解析 hash 部分)。选哪种模式最终还是"你能多干净地配置服务器"决定的。
createMemoryHistory
// packages/router/src/history/memory.ts:21
export function createMemoryHistory(base: string = ''): RouterHistory {
let listeners: NavigationCallback[] = []
// 每个条目是 [url, state] 二元组
let queue: [url: HistoryLocation, state: HistoryState][] = [[START, {}]]
let position: number = 0
base = normalizeBase(base)
function setLocation(location: HistoryLocation, state: HistoryState = {}) {
position++
if (position !== queue.length) {
queue.splice(position) // 去掉"未来"的记录
}
queue.push([location, state])
}
// :36-49——把一次 pop 广播给所有 listener,direction / delta 由调用方算好
function triggerListeners(
to: HistoryLocation,
from: HistoryLocation,
{ direction, delta }: Pick<NavigationInformation, 'direction' | 'delta'>
): void {
const info: NavigationInformation = { direction, delta, type: NavigationType.pop }
for (const callback of listeners) {
callback(to, from, info)
}
}
const routerHistory: RouterHistory = {
location: START, // 下面会被 defineProperty 改写成 getter
state: {},
base,
createHref: createHref.bind(null, base),
replace(to, state?: HistoryState) {
queue.splice(position--, 1)
setLocation(to, state)
},
push(to, state?: HistoryState) {
setLocation(to, state)
},
listen(callback) {
listeners.push(callback)
return () => {
const index = listeners.indexOf(callback)
if (index > -1) listeners.splice(index, 1)
}
},
destroy() {
listeners = []
queue = [[START, {}]]
position = 0
},
go(delta, shouldTrigger = true) {
const from = this.location
const direction = delta < 0 ? NavigationDirection.back : NavigationDirection.forward
position = Math.max(0, Math.min(position + delta, queue.length - 1))
if (shouldTrigger) {
triggerListeners(this.location, from, { direction, delta })
}
},
}
Object.defineProperty(routerHistory, 'location', {
enumerable: true,
get: () => queue[position][0],
})
Object.defineProperty(routerHistory, 'state', {
enumerable: true,
get: () => queue[position][1],
})
return routerHistory
}
Memory History 不与浏览器交互,用数组模拟历史记录栈。主要用于 SSR 和测试场景。
从 Memory History 看架构的威力
值得单独点出:整个 Memory History 的实现只有一百来行(含注释)——一个数组 queue、一个位置指针 position、几个 push/replace/go 操作。完全是一个玩具级的实现。
但正是这个"玩具级"实现,让 Vue Router 能在没有浏览器的环境(SSR、单测、非 DOM 的自定义渲染器)里完整跑起来——没有任何能力缺失,守卫、嵌套路由、路径匹配全都正常工作。这就是接口抽象的价值:核心逻辑不绑死任何实现细节,只要新实现满足接口契约(push / replace / go / listen),整个系统就能无缝切换。
16.4 导航流程
从 push 到渲染
router.push('/user/123') 触发的完整流程:
sequenceDiagram
participant U as 用户代码
participant R as Router
participant M as Matcher
participant G as Guards
participant H as History
participant V as RouterView
U->>R: push('/user/123')
R->>M: resolve('/user/123')
M-->>R: 匹配结果 {matched, params}
R->>G: 执行 beforeRouteLeave
R->>G: 执行 beforeEach
R->>G: 执行 beforeRouteUpdate
R->>G: 执行 beforeEnter
R->>G: 解析异步路由组件(与 beforeRouteEnter 同一队列)
R->>G: 执行 beforeRouteEnter
R->>G: 执行 beforeResolve
R->>H: 更新 URL (pushState)
R->>R: currentRoute.value = newRoute
V-->>V: 检测 currentRoute 变化,重新渲染
R->>G: 执行 afterEach
navigate 函数
// vue-router 4.6.4 packages/router/src/router.ts:581(createRouter 闭包内)
function navigate(
to: RouteLocationNormalized,
from: RouteLocationNormalizedLoaded
): Promise<any> {
let guards: Lazy<any>[]
// 提取需要离开、更新、进入的路由记录
const [leavingRecords, updatingRecords, enteringRecords] =
extractChangingRecords(to, from)
// 1. beforeRouteLeave(从即将离开的组件中提取,子路由先于父路由)
guards = extractComponentsGuards(
leavingRecords.reverse(),
'beforeRouteLeave',
to,
from
)
// 加上通过 onBeforeRouteLeave 注册的守卫
for (const record of leavingRecords) {
record.leaveGuards.forEach(guard => {
guards.push(guardToPromiseFn(guard, to, from))
})
}
// 每一段队列末尾都塞一个"导航是否已被更新的导航取消"检查
const canceledNavigationCheck = checkCanceledNavigationAndReject.bind(null, to, from)
guards.push(canceledNavigationCheck)
return (
runGuardQueue(guards)
.then(() => {
// 2. 全局 beforeEach
guards = []
for (const guard of beforeGuards.list()) {
guards.push(guardToPromiseFn(guard, to, from))
}
guards.push(canceledNavigationCheck)
return runGuardQueue(guards)
})
.then(() => {
// 3. beforeRouteUpdate(复用的组件)+ onBeforeRouteUpdate 注册的守卫
guards = extractComponentsGuards(updatingRecords, 'beforeRouteUpdate', to, from)
for (const record of updatingRecords) {
record.updateGuards.forEach(guard => {
guards.push(guardToPromiseFn(guard, to, from))
})
}
guards.push(canceledNavigationCheck)
return runGuardQueue(guards)
})
.then(() => {
// 4. 路由配置的 beforeEnter(复用的记录不触发)
guards = []
for (const record of enteringRecords) {
if (record.beforeEnter) {
if (isArray(record.beforeEnter)) {
for (const beforeEnter of record.beforeEnter)
guards.push(guardToPromiseFn(beforeEnter, to, from))
} else {
guards.push(guardToPromiseFn(record.beforeEnter, to, from))
}
}
}
guards.push(canceledNavigationCheck)
return runGuardQueue(guards)
})
.then(() => {
// 5. 解析异步路由组件 + beforeRouteEnter(新进入的组件)
// 懒加载组件的下载就发生在 extractComponentsGuards 里,见 16.7
to.matched.forEach(record => (record.enterCallbacks = {}))
guards = extractComponentsGuards(
enteringRecords,
'beforeRouteEnter',
to,
from,
runWithContext
)
guards.push(canceledNavigationCheck)
return runGuardQueue(guards)
})
.then(() => {
// 6. 全局 beforeResolve
guards = []
for (const guard of beforeResolveGuards.list()) {
guards.push(guardToPromiseFn(guard, to, from))
}
guards.push(canceledNavigationCheck)
return runGuardQueue(guards)
})
// 被更新的导航"取消"不算错误,原样返回给 pushWithRedirect 处理
.catch(err =>
isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED)
? err
: Promise.reject(err)
)
)
}
官方文档把这条链描述成 12 步(导航被触发 → beforeRouteLeave → beforeEach → beforeRouteUpdate → beforeEnter → 解析异步组件 → beforeRouteEnter → beforeResolve → 导航确认 → afterEach → DOM 更新 → next(vm => ...) 回调)。对照源码你会发现"解析异步组件"并不是独立的一段 .then——它藏在第 5 段提取 beforeRouteEnter 守卫的 extractComponentsGuards 里。守卫链跑完后,pushWithRedirect 才调用 finalizeNavigation(更新 URL、替换 currentRoute.value、处理滚动)和 triggerAfterEach。
extractChangingRecords:路由变更检测
// packages/router/src/navigationGuards.ts:417(源码带 export)
function extractChangingRecords(
to: RouteLocationNormalized,
from: RouteLocationNormalizedLoaded
) {
const leavingRecords: RouteRecordNormalized[] = []
const updatingRecords: RouteRecordNormalized[] = []
const enteringRecords: RouteRecordNormalized[] = []
const len = Math.max(from.matched.length, to.matched.length)
for (let i = 0; i < len; i++) {
const recordFrom = from.matched[i]
if (recordFrom) {
if (to.matched.find(record => isSameRouteRecord(record, recordFrom))) {
updatingRecords.push(recordFrom) // 复用
} else {
leavingRecords.push(recordFrom) // 离开
}
}
const recordTo = to.matched[i]
if (recordTo) {
if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) {
enteringRecords.push(recordTo) // 进入
}
}
}
return [leavingRecords, updatingRecords, enteringRecords]
}
这个函数将路由变更分为三类——"离开"、"复用"、"进入"。这三类对应不同的守卫和不同的组件操作。
为什么复用这件事这么重要
单独说说"复用"(updatingRecords)的意义——这是 Vue Router 导航模型里最容易被忽视的一块,但它决定了嵌套路由切换时的性能和用户体验。
设想一个后台管理页:顶部有固定导航栏 AdminLayout,左侧有侧边栏,右边是内容区。URL 从 /admin/users 切到 /admin/orders 时,AdminLayout 不应该卸载重建——里面的用户头像、菜单折叠状态、通知数量这些都不该丢。但如果 Router 把所有路由记录都当作"离开 + 进入"处理,AdminLayout 的组件实例就会被销毁再重建,一切状态归零。
extractChangingRecords 的三分类正是解决这个问题的:对于 matched 数组里在新旧路由中都存在的同一条记录(通过 isSameRouteRecord 判断),归为"复用"——对应的 RouterView 不会触发 Component 的卸载挂载,只会触发 beforeRouteUpdate 这种"更新"语义的钩子。这让嵌套路由切换在正确的 layer 上"稳住不动",只有真正变化的 layer 重建。
这背后的思想叫最小变更(minimal change),是所有好 diff 算法的共同追求。本专栏第 11 章讨论 VDOM diff 的时候也是同一个出发点——找到真正变化的部分、其他部分保持不动。Vue Router 的 record diff 和 VDOM 的 node diff 在思想上是孪生的,只是作用在不同的粒度(VDOM 作用于虚拟节点、Router 作用于路由记录)。
16.5 RouterView 的实现
嵌套路由与 depth
RouterView 利用 provide/inject 实现嵌套路由的层级感知:
// packages/router/src/RouterView.ts:45(源码在 defineComponent 前有 /*#__PURE__*/)
export const RouterViewImpl = defineComponent({
name: 'RouterView',
setup(props, { attrs, slots }) {
const injectedRoute = inject(routerViewLocationKey)!
const routeToDisplay = computed(() => props.route || injectedRoute.value)
// 当前 RouterView 的深度
const injectedDepth = inject(viewDepthKey, 0)
const depth = computed(() => {
let initialDepth = unref(injectedDepth)
const { matched } = routeToDisplay.value
let matchedRoute: RouteLocationMatched | undefined
while (
(matchedRoute = matched[initialDepth]) &&
!matchedRoute.components
) {
initialDepth++
}
return initialDepth
})
const matchedRouteRef = computed(
() => routeToDisplay.value.matched[depth.value]
)
// 向子 RouterView 提供递增的 depth
provide(viewDepthKey, computed(() => depth.value + 1))
provide(matchedRouteKey, matchedRouteRef)
provide(routerViewLocationKey, routeToDisplay)
// ...
return () => {
const route = routeToDisplay.value
const currentName = props.name // prop 默认值 'default'
const matchedRoute = matchedRouteRef.value
const ViewComponent =
matchedRoute && matchedRoute.components![currentName]
if (!ViewComponent) {
return normalizeSlot(slots.default, { Component: ViewComponent, route })
}
// routeProps 来自记录的 props 选项(true → route.params / 函数 / 对象)
const component = h(
ViewComponent,
assign({}, routeProps, attrs, { onVnodeUnmounted, ref: viewRef })
)
return (
normalizeSlot(slots.default, { Component: component, route }) ||
component
)
}
}
})
graph TD
A["RouterView depth=0<br/>provide(depth, 1)"] --> B["Layout 组件"]
B --> C["RouterView depth=1<br/>provide(depth, 2)"]
C --> D["Page 组件"]
D --> E["RouterView depth=2<br/>provide(depth, 3)"]
E --> F["SubPage 组件"]
A -.->|"matched[0]"| B
C -.->|"matched[1]"| D
E -.->|"matched[2]"| F
style A fill:#42b883,color:#333
style C fill:#42b883,color:#333
style E fill:#42b883,color:#333
每层 RouterView 通过 depth 确定自己应该渲染 matched 数组中的哪个路由记录。第一层渲染 matched[0],第二层渲染 matched[1],以此类推。provide(viewDepthKey, depth + 1) 让嵌套的 RouterView 自动获取正确的层级。
为什么 depth 用 provide/inject 而不是 props
一个合理的替代设计是:RouterView 用 prop depth 从父组件传进来。这样做也能工作,但会产生一个让人头疼的要求——每个用到嵌套路由的布局组件都必须显式传递 depth。这对开发者是心智负担,而且很容易漏传。
provide/inject 在这里是"上下文自动冒泡"的标准解法:父 RouterView 设好 depth=N+1,不论中间隔了多少层 DOM 或自定义组件,子 RouterView 都能 inject 到正确的 depth,一行用户代码不用写。Vue 的 provide/inject 就是为这种"跨越任意层级的隐式上下文"设计的。《React 19 内核探秘》讲 React Context 时也讨论过同一个问题——Context 和 provide/inject 解决的核心痛点都是"props 钻透"(prop drilling),但 Vue 的实现更轻量(没有 ContextProvider 包装组件)、Vue 的响应式配合 inject 更自然(inject 到的 ref 可以直接参与响应式系统)。
多 view 命名路由的实现细节
更复杂的场景是一个路由配多个命名 view:
const routes = [
{
path: '/settings',
components: {
default: SettingsMain,
sidebar: SettingsSidebar,
header: SettingsHeader
}
}
]
模板里对应要写 <RouterView name="sidebar" /> 等三处。components 字段是一个字典(而不是单个组件),RouterView 的 name prop 决定去字典里取哪个。源码里 matchedRoute.components![currentName] 这一行就是在做字典查询——缺省 name 是 'default',所以最常见的"只有一个组件"的写法只是多命名 view 特例的一个简写。
这种"一套记录 + 多个命名出口"的设计在实际项目里比想象的有用:同一路由下的 header、sidebar、main 三块可以互不影响地独立渲染和复用,状态也各自隔离。只是初学者不太用到,导致这个能力"藏得太深"。
16.6 RouterLink 的实现
导航与激活状态
// packages/router/src/RouterLink.ts:143 起(源码是泛型签名
// `useLink<Name extends keyof RouteMap = keyof RouteMap>(props: UseLinkOptions<Name>)`,
// 这里简化掉了类型参数,并省略 dev 校验与 devtools 相关代码)
// 逻辑都在 useLink() 这个组合式函数里,RouterLink 组件只是它的一层薄封装
export function useLink(props: UseLinkOptions) {
const router = inject(routerKey)!
const currentRoute = inject(routeLocationKey)!
const route = computed(() => router.resolve(unref(props.to)))
const activeRecordIndex = computed<number>(() => {
const { matched } = route.value
const { length } = matched
const routeMatched = matched[length - 1]
const currentMatched = currentRoute.matched
if (!routeMatched || !currentMatched.length) return -1
const index = currentMatched.findIndex(
isSameRouteRecord.bind(null, routeMatched)
)
if (index > -1) return index
// ...(嵌套路由里"空路径子路由"与父记录同路径的特殊处理)
return index
})
const isActive = computed<boolean>(
() =>
activeRecordIndex.value > -1 &&
includesParams(currentRoute.params, route.value.params)
)
const isExactActive = computed<boolean>(
() =>
activeRecordIndex.value > -1 &&
activeRecordIndex.value === currentRoute.matched.length - 1 &&
isSameRouteLocationParams(currentRoute.params, route.value.params)
)
function navigate(e: MouseEvent = {} as MouseEvent) {
if (guardEvent(e)) {
const p = router[unref(props.replace) ? 'replace' : 'push'](
unref(props.to)
).catch(noop)
if (
props.viewTransition &&
typeof document !== 'undefined' &&
'startViewTransition' in document
) {
document.startViewTransition(() => p)
}
return p
}
return Promise.resolve()
}
return {
route,
href: computed(() => route.value.href),
isActive,
isExactActive,
navigate,
}
}
export const RouterLinkImpl = defineComponent({
name: 'RouterLink',
props: {
to: { type: [String, Object] as PropType<RouteLocationRaw>, required: true },
replace: Boolean,
activeClass: String,
exactActiveClass: String,
custom: Boolean,
ariaCurrentValue: { type: String, default: 'page' },
viewTransition: Boolean,
},
useLink,
setup(props, { slots }) {
const link = reactive(useLink(props))
const { options } = inject(routerKey)!
const elClass = computed(() => ({
[getLinkClass(props.activeClass, options.linkActiveClass, 'router-link-active')]:
link.isActive,
[getLinkClass(props.exactActiveClass, options.linkExactActiveClass, 'router-link-exact-active')]:
link.isExactActive,
}))
return () => {
const children = slots.default && preferSingleVNode(slots.default(link))
return props.custom
? children
: h(
'a',
{
'aria-current': link.isExactActive ? props.ariaCurrentValue : null,
href: link.href,
onClick: link.navigate,
class: elClass.value,
},
children
)
}
},
})
guardEvent 函数处理各种边界情况(代码见本节末尾)。
RouterLink 为什么还是要渲染 <a> 元素
Vue Router 可以完全靠 JS 拦截事件做导航,为什么 RouterLink 一定要渲染真实的 <a href="..."> 标签?直接用 <div> + 点击事件不是更灵活吗?
答案是 SEO + 可访问性(a11y)+ 交互习惯。
- SEO:搜索引擎爬虫能跟着
<a href>继续爬取站内其他页面。用<div>没 href,爬虫根本不知道有这些路由存在,站内链接结构完全不可见。SPA 大行其道后 Google 的爬虫已经能执行 JavaScript,但没<a href>还是死角——爬虫一般不会主动触发合成事件。 - 可访问性:屏幕阅读器区分"链接"和"按钮"的依据是
<a>vs<button>标签。用<div>模拟链接,盲人用户就完全识别不出来这是个导航。符合 WCAG 标准的应用必须保留<a>。 - 用户习惯:右键"在新标签页打开"、Ctrl/Cmd+点击、中键点击——这些默认交互都依赖真实
<a href>。如果只是 JS 拦截,这些操作会失效(被 preventDefault 的路径挡掉),用户只能在当前标签页打开。
guardEvent 的逻辑就是精确区分"当在同标签内导航时拦截,其他情况放行给浏览器原生行为"。检测修饰键(Ctrl/Cmd/Shift/Alt)、检测 target="_blank"、检测非左键点击——这些都是"用户希望用浏览器原生方式处理"的信号,Router 识别后主动让路。这是个非常微妙的交互设计,理解了这一点就理解了"为什么 RouterLink 一定要复杂成这样"。
function guardEvent(e: MouseEvent) {
// 不处理以下情况
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return // 修饰键
if (e.defaultPrevented) return // 已被阻止
if (e.button !== undefined && e.button !== 0) return // 非左键
if (e.currentTarget && (e.currentTarget as HTMLElement).getAttribute) {
const target = (e.currentTarget as HTMLElement).getAttribute('target')
if (/\b_blank\b/i.test(target || '')) return // target="_blank"
}
e.preventDefault()
return true
}
16.7 路由懒加载
异步组件与代码分割
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
}
]
() => import() 返回一个 Promise。Vue Router 并没有一段专门"加载异步组件"的 .then:在 navigate 第 5 段提取 beforeRouteEnter 守卫时,extractComponentsGuards 遇到还是函数的组件就直接调用它开始下载 chunk,resolve 之后再从组件选项里取守卫:
// navigationGuards.ts › extractComponentsGuards 内部
for (const name in record.components) {
let rawComponent = record.components[name]
// 非 beforeRouteEnter 的守卫只对已挂载的组件实例有意义
if (guardType !== 'beforeRouteEnter' && !record.instances[name]) continue
if (isRouteComponent(rawComponent)) {
// 已经是组件对象:直接取守卫
const options: ComponentOptions = (rawComponent as any).__vccOpts || rawComponent
const guard = options[guardType]
guard && guards.push(guardToPromiseFn(guard, to, from, record, name, runWithContext))
} else {
// 还是 () => import(...):立刻开始请求 chunk
let componentPromise = (rawComponent as Lazy<RouteComponent>)()
guards.push(() =>
componentPromise.then(resolved => {
if (!resolved)
throw new Error(`Couldn't resolve component "${name}" at "${record.path}"`)
const resolvedComponent = isESModule(resolved) ? resolved.default : resolved
// 整个模块也留一份,供 data loaders 这类插件使用
record.mods[name] = resolved
// 用加载完成的组件替换原始的工厂函数
record.components![name] = resolvedComponent
const options: ComponentOptions =
(resolvedComponent as any).__vccOpts || resolvedComponent
const guard = options[guardType]
return guard && guardToPromiseFn(guard, to, from, record, name, runWithContext)()
})
)
}
}
加载后的组件会替换原始的工厂函数。这意味着同一个路由的第二次访问不会重复加载。
替换原始工厂函数的微妙之处
这个"原地改写"的设计看起来随意,其实里面藏着一个权衡:把 caching 放在路由记录上,而不是放在全局缓存。
全局缓存的做法是:弄一个 Map<path, Component>,加载完塞进去、访问时查表。这听起来更干净,但有个问题——路由记录是会被 removeRoute 的。如果用户动态移除了一个路由,然后又重新 addRoute 同路径,全局缓存会让第二次的"新路由"无意中继承了第一次已加载的组件。想避免这个必须额外做生命周期管理,代码量陡增。
把 cache 绑定在路由记录自身,这些问题自然消失——记录被移除,连带它的已加载组件一起 gc 掉;记录重新添加,归一化出新的 components 对象,会重新调用工厂函数(import() 通常命中模块缓存,不再走网络)。例外是用 components: 写法、且重复传入同一个原始记录对象:normalizeRouteRecord 直接复用该对象(packages/router/src/matcher/index.ts:414-416),已解析的组件会保留下来。一个对象生命周期跟另一个绑定在一起,比两套独立生命周期 + 手工维护同步关系要简单太多。这种"把缓存挂在自然生命周期边界上"的技巧在很多系统里反复出现。
与 Webpack/Vite 代码分割的配合
// 自动分包
component: () => import('./views/Dashboard.vue')
// Webpack 魔法注释:指定 chunk 名
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue')
// Vite 同样支持动态 import 的自动分割
构建工具看到 import() 会自动将目标模块分割为独立的 chunk。路由懒加载 + 代码分割 = 首屏只加载首屏需要的代码。
懒加载失败怎么办
一个现实但大家都不爱想的问题:如果 import() 失败了(网络问题、CDN 挂了、chunk 404)怎么办?
Vue Router 的 navigate 函数把懒加载包在 Promise 链里,如果 Promise reject,整个导航就 reject。默认情况下这会导致 URL 不变(因为 pushState 还没调用)——看起来就像"点了链接没反应"。生产环境看到这种故障,开发者应该主动处理:
router.onError((error, to) => {
if (error.message.includes('Failed to fetch dynamically imported module')) {
// chunk 加载失败——通常是因为部署后旧的 chunk 已经被新版本替换
// 解决方案:强刷一次页面,下载最新的 chunk 清单
window.location.href = to.fullPath
}
})
这个模式在生产部署频繁的 SPA 里很关键——用户打开页面后,后端发布了新版本、旧的 chunk 文件名在 CDN 上消失了,用户此时点任何懒加载链接都会 404。onError 里兜底"刷新一次"能把用户自动拉到新版本上,体验比"卡住不动"好得多。
常见的缓解办法是发布时让旧版带 hash 的 chunk 在 CDN 上保留一段时间(减少用户掉链风险),但长期看最终还是要刷新页面才能彻底恢复。这是现代 SPA 必然面对的"部署原子性 vs 用户会话寿命"的冲突。
16.8 导航守卫的执行引擎
guardToPromiseFn:将守卫统一为 Promise
// packages/router/src/navigationGuards.ts:123(:110 与 :115 是两个重载声明)
export function guardToPromiseFn(
guard: NavigationGuard,
to: RouteLocationNormalized,
from: RouteLocationNormalizedLoaded,
record?: RouteRecordNormalized,
name?: string,
runWithContext: <T>(fn: () => T) => T = fn => fn()
): () => Promise<void> {
// beforeRouteEnter 的 next(vm => ...) 回调存在这里,RouterView 挂载后再逐个调用
const enterCallbackArray =
record &&
(record.enterCallbacks[name!] = record.enterCallbacks[name!] || [])
return () =>
new Promise((resolve, reject) => {
const next: NavigationGuardNext = (valid?: any) => {
if (valid === false) {
reject(createRouterError(ErrorTypes.NAVIGATION_ABORTED, { from, to }))
} else if (valid instanceof Error) {
reject(valid)
} else if (isRouteLocation(valid)) {
reject(createRouterError(ErrorTypes.NAVIGATION_GUARD_REDIRECT, {
from: to,
to: valid,
}))
} else {
// next(vm => ...):把回调记下来(若期间没有新导航覆盖 enterCallbacks)
if (
enterCallbackArray &&
record!.enterCallbacks[name!] === enterCallbackArray &&
typeof valid === 'function'
) {
enterCallbackArray.push(valid)
}
resolve()
}
}
// 调用守卫,传入 next(组件内守卫的 this 是组件实例)
const guardReturn = runWithContext(() =>
guard.call(
record && record.instances[name!],
to,
from,
__DEV__ ? canOnlyBeCalledOnce(next, to, from) : next
)
)
// 支持返回值(不使用 next)
let guardCall = Promise.resolve(guardReturn)
if (guard.length < 3) guardCall = guardCall.then(next)
// ...(dev 下检查"声明了 next 却没调用",省略)
guardCall.catch(err => reject(err))
})
}
这个函数将三种守卫风格统一为 Promise:
// 风格 1:使用 next 回调
beforeEach((to, from, next) => { next() })
// 风格 2:返回值
beforeEach((to, from) => { return true })
// 风格 3:异步
beforeEach(async (to, from) => { await checkAuth(); return true })
runGuardQueue:串行执行
function runGuardQueue(guards: Lazy<any>[]): Promise<any> {
return guards.reduce(
(promise, guard) => promise.then(() => runWithContext(guard)),
Promise.resolve()
)
}
守卫严格串行执行——前一个 resolve 了才执行下一个。任何一个 reject(返回 false、抛异常、重定向)都会终止整个链。
为什么不并行
乍一看,这些守卫好像可以并行——beforeEach 之间互相独立,让它们同时跑不是更快吗?
但守卫里经常有副作用耦合:一个守卫检查登录态(读 store)、一个守卫记埋点(写网络)、一个守卫重定向(改路由)。这些操作互相可能有时序依赖——典型场景:守卫 A 判断未登录后 return '/login' 触发重定向,守卫 B 之后的所有判断都应该基于"要去 /login 而不是 /user/1"。如果 A 和 B 并行,B 可能还在基于旧的目标地址做检查,等 A 返回时整个决策链已经不一致了。
串行执行以确定性换一点性能。大多数守卫是同步的(读 store、简单判断),总时间开销也就几毫秒,串行 vs 并行差异在用户体感上接近零;但串行带来的可预测性,在生产出 bug 时能救命——"这个守卫明明先跑啊,为什么我看到的状态是后跑那个守卫改过的?"这种问题并行模型下能调到爆炸,串行模型下一看队列顺序就懂。
《LangGraph 设计与实现》专栏第 4 章谈到了 StateGraph 节点的并行语义问题——LangGraph 明确规定并行节点的状态合并规则(reducer 函数)。Vue Router 选择更简单的路径:根本不并行,就没有合并问题。这是两种不同复杂度场景下的合理取舍。
reduce 的陷阱和价值
guards.reduce((promise, guard) => promise.then(() => guard()), Promise.resolve()) 这一行代码是 JavaScript Promise 教程里的经典模式,但常被写成下面两种变体:
// 变体 1:同样是串行,但只能写在 async 函数里
for (const guard of guards) {
await guard()
}
// 变体 2:常见错误
Promise.all(guards.map(g => g())) // 这是并行,不是串行
reduce 构造的是一条显式的 Promise 链,好处是:调用方拿到的 promise 是"所有守卫都跑完后 resolve"的最终 promise,可以 .then 接后续逻辑、可以 .catch 统一处理错误。这是串行异步任务的标准做法——如果你在业务代码里也写类似的"按顺序等多个异步操作",建议直接抄这个模式。
16.9 router.install:与 Vue 应用的集成
// packages/router/src/router.ts:1010
install(app: App) {
// 注册全局组件
app.component('RouterLink', RouterLink)
app.component('RouterView', RouterView)
// 全局属性:$route 不是一个快照对象,而是一个 getter
app.config.globalProperties.$router = router
Object.defineProperty(app.config.globalProperties, '$route', {
enumerable: true,
get: () => unref(currentRoute),
})
// 初始导航:只在浏览器里做,多个 app 共用一个 router 时也只 push 一次
if (
isBrowser &&
!started &&
currentRoute.value === START_LOCATION_NORMALIZED
) {
started = true
push(routerHistory.location).catch(err => {
if (__DEV__) warn('Unexpected error when starting the router:', err)
})
}
// 给 useRoute() 用的对象:每个字段都是转发到 currentRoute.value 的 getter
const reactiveRoute = {} as RouteLocationNormalizedLoaded
for (const key in START_LOCATION_NORMALIZED) {
Object.defineProperty(reactiveRoute, key, {
get: () => currentRoute.value[key as keyof RouteLocationNormalized],
enumerable: true,
})
}
// 依赖注入
app.provide(routerKey, router)
app.provide(routeLocationKey, shallowReactive(reactiveRoute))
app.provide(routerViewLocationKey, currentRoute)
// 拦住 app.unmount:最后一个 app 卸载时摘掉 history 监听、复位 router 状态
const unmountApp = app.unmount
installedApps.add(app)
app.unmount = function () {
installedApps.delete(app)
if (installedApps.size < 1) {
pendingLocation = START_LOCATION_NORMALIZED
removeHistoryListener && removeHistoryListener()
removeHistoryListener = null
currentRoute.value = START_LOCATION_NORMALIZED
started = false
ready = false
}
unmountApp()
}
// ...(dev / devtools 下注册 devtools,省略)
}
注意 $route 不是直接赋值,而是用 Object.defineProperty 定义成一个 getter——这是为了让 Options API 中的 this.$route 始终返回最新的路由信息,而不需要手动更新全局属性。
getter 在这里替代了什么
Vue 2 + Vue Router 3 时代其实也用 getter:$route 定义在 Vue.prototype 上,get 返回 this._routerRoot._route。但它多一层中转——每个根实例上用 Vue.util.defineReactive(this, '_route', ...) 维护一个响应式字段,每次导航由 history.listen 回调把新路由逐个赋给 router.apps 里每个根实例的 _route;它还依赖 Vue 2 全局 mixin 往每个组件塞 _routerRoot 引用这套机制。
Vue Router 4 把这层中转也省掉了——不再为 $route 维护任何副本,只在访问时才解析。get: () => unref(currentRoute) 这段代码的含义是:"当有人读 this.$route 时,惰性地从 currentRoute 里取"。这种懒求值避免了"导航时把新路由推给每个根实例"的模式,改为"消费者 pull"。结果是:
- 性能:路由变化时零额外工作,变化只影响
currentRoute.value这一个引用 - 正确性:永远返回最新值,不存在"来不及更新"的窗口
- 可观察:getter 里读的是
currentRoute.value(一个 shallowRef),所以读this.$route天然进 tracking 表,变了 → 重渲染
这和本专栏第 4 章讨论的 reactive 是同一种思路——reactive 用 Proxy 拦截属性读取,这里用 getter 拦截一次读取——都是把"推"改成"拉"。提供给 useRoute() 的那个 reactiveRoute 更进一步:每个字段都是转发到 currentRoute.value[key] 的 getter,再包一层 shallowReactive,于是 route.params 在模板和 computed 里都能被正确追踪。一致的底层思路、不同的粒度选择,这是设计的优雅之处。
16.10 滚动行为
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition // 浏览器前进/后退:恢复位置
}
if (to.hash) {
return { el: to.hash } // 有锚点:滚动到锚点
}
return { top: 0 } // 默认:滚动到顶部
}
})
滚动行为在导航完成后执行(handleScroll 源码见本节末尾)。
为什么滚动行为要在 nextTick 里
本节末尾 handleScroll 里的 nextTick().then(() => scrollBehavior(...)) 别小看——去掉它就是一串难以复现的 bug。
用户点一个链接 /posts/100#comment-42,期望"跳转到这篇文章 + 滚到评论 42 的位置"。Router 做完导航后,新页面的 DOM 还没渲染完——Vue 的响应式更新是异步的,渲染要等调度器在一个微任务里 flush。如果立刻调 scrollToPosition,目标元素 #comment-42 可能还不存在于 DOM,scrollToPosition 找不到元素,只会(dev 下)打一条警告然后放弃滚动。
nextTick 的作用是"等 Vue 把所有挂起的 DOM 变更 flush 完",之后再执行滚动。这样目标 DOM 一定存在、高度也稳定了,才能算出正确的滚动目标。
这个细节典型反映了前端框架里"渲染、事件、任务调度"三者怎么协同。本专栏第 12 章讨论调度器时深入讲过 nextTick 的底层——它返回的是 currentFlushPromise || resolvedPromise,也就是排在当前这一轮调度器 flush 之后的那个微任务 Promise(packages/runtime-core/src/scheduler.ts)。每一个用 nextTick 的地方都有它的道理,滚动行为只是其中最容易被忽视的一个。
savedPosition 的来源
scrollBehavior(to, from, savedPosition) 的第三个参数是"保存的位置"——只在浏览器前进/后退(popstate)或首次导航时非空。Router 怎么"保存"的?两条腿走路。第一条腿在 createWebHistory 的 push 里:在真正 pushState 之前,先 replaceState 一次,把当前滚动坐标写进当前这个历史条目的 history.state(页面隐藏时 pagehide / visibilitychange 也会补写一次):
// history/html5.ts › useHistoryStateNavigation.push
const currentState = assign({}, historyState.value, history.state, {
forward: to,
scroll: computeScrollPosition(),
})
changeLocation(currentState.current, currentState, true) // replaceState
// ...然后才 pushState 到 to
第二条腿在 setupListeners 里:popstate 到来时,先把正在离开的页面坐标存进 scrollBehavior.ts 的内存 Map:saveScrollPosition(getScrollKey(from.fullPath, info.delta), computeScrollPosition())。handleScroll 取值时先查这张 Map,没有再退回 history.state.scroll,两者都没有才是 null——用户按后退键,就看到"回到原来那个滚动位置"的预期行为。
这个实现的精妙在于把 history.state 当作跨刷新的持久层:内存 Map 只是当前会话内的快速通道,刷新后 Map 清空,但 history.state 里的 scroll 还在——它天然跟每个历史栈条目绑死、跨页刷新存活、由浏览器维护生命周期。这是复用浏览器能力的典范。
// finalizeNavigation 内部:URL 已更新、currentRoute 已替换之后
currentRoute.value = toLocation
handleScroll(toLocation, from, isPush, isFirstNavigation)
function handleScroll(to, from, isPush, isFirstNavigation): Promise<unknown> {
const { scrollBehavior } = options
if (!isBrowser || !scrollBehavior) return Promise.resolve()
// popstate:先查内存 Map;首次导航或 popstate:再退回 history.state.scroll
const scrollPosition =
(!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) ||
((isFirstNavigation || !isPush) && history.state && history.state.scroll) ||
null
return nextTick()
.then(() => scrollBehavior(to, from, scrollPosition))
// scrollToPosition 内部使用 window.scrollTo 或对 el 求 getBoundingClientRect
.then(position => position && scrollToPosition(position))
.catch(err => triggerError(err, to, from))
}
16.11 小结
Vue Router 的内核可以用一句话概括:将 URL 的变化转化为组件树的变化,同时在两者之间插入可控的拦截层。
| 模块 | 职责 | 核心技术 |
|---|---|---|
| Matcher | 路径 → 路由记录 | 正则编译 + 评分排序 |
| History | URL 管理 | History API / Hash / Memory |
| Navigation | 守卫流程控制 | Promise 串行队列 |
| RouterView | 嵌套路由渲染 | provide/inject + depth |
| RouterLink | 声明式导航 | 激活状态计算 + 事件拦截 |
Vue Router 的设计有两个值得学习的地方:
- 关注点分离:Matcher 不知道 History,History 不知道组件,每个模块都可以独立测试和替换
- 渐进式复杂度:简单场景只需要
path + component,但高级场景可以使用守卫、懒加载、滚动行为、动态路由等全套能力,而这些功能不会增加简单场景的开销
路由系统思想的迁移价值
读完 Vue Router 源码,最有迁移价值的不是具体 API(那些查文档就行),而是几条反复出现的工程思想。
思想 1:把隐式顺序依赖显式化。Matcher 用评分取代"定义顺序"、guards 用 Promise 链取代"callback 嵌套"、嵌套路由用 depth 取代"组件手工传 index"——每一处都是在"消除开发者需要记住的隐式约定"。这个思路可以迁移到任何领域:写表单顺序逻辑时用声明式的 step 定义取代 if-else;写中间件链时用 array 取代链式回调;写权限系统时用规则评分取代硬编码顺序。
思想 2:用接口而不是实现组合复杂度。History 是接口 → Router 不知道浏览器;Matcher 是模块 → History 不知道路由匹配。所有"下层不知道上层"的架构都能换成"上层通过 mock 下层来跑测试"。Vue Router 的单元测试里,大量 test case 用的是 createMemoryHistory 做 fixture——不需要启一个真正的浏览器环境,也不需要 mock window。能做到这一点的前提是接口设计得好。
思想 3:默认值要"几乎总是对的"。scrollBehavior 不配就完全不接管滚动(连 history.scrollRestoration 都保持浏览器默认)、currentRoute 用 shallowRef(路由对象特性决定)、history 需要显式选择(避免 SSR / 浏览器错配)。Vue Router 的 API 表面小,但默认行为经过反复打磨——默认值选得好,大多数用户不需要读文档就能写出正确的代码。这是框架设计的顶级追求。
延伸阅读
- 本专栏 第 4、5 章(reactive/shallowRef 的机制)、第 11 章(VDOM diff)、第 12 章(调度器 + nextTick)——Vue Router 里所有涉及响应式、DOM 更新、微任务的细节都和这几章底层打通
- 《React 19 内核探秘》专栏第 9 章(并发模式,含 Suspense)——对比两套前端框架处理异步的风格差异
- 《微前端源码精讲》 讨论了 qiankun / wujie / Module Federation 的路由接管策略,是 Vue Router 基础上的扩展问题
- 官方源码:
packages/router/src/(TypeScript 约 7.5k 行、注释质量极高,是学源码的好样本;experimental/下是正在孵化的新解析器,本章不涉及) - Vue Router 的 RFC:例如 vuejs/rfcs#150(Redesigning Navigation failures and global handlers)讲了导航失败为什么从 reject 改成 resolve 一个 failure 对象——"破坏性变更背后的 motivation"比 changelog 更值得读
思考题
-
为什么
currentRoute使用shallowRef而不是ref?如果改为ref,会对性能产生什么影响? -
假设有路由
/user/:id,用户从/user/1导航到/user/2。RouterView 会卸载并重新创建组件,还是复用同一个组件实例?为什么?如何控制这个行为? -
导航守卫链中,如果某个
beforeEach守卫返回了一个新的路由(重定向),Vue Router 如何防止无限重定向循环? -
设计一个路由级别的缓存方案:切换路由后,之前的页面组件不销毁,再次访问时恢复原状。考虑内存限制(最多缓存 N 个页面),你会如何实现?