Vue 3 设计与实现

第 16 章 Vue Router 内核

作者 杨艺韬 · 11,398 字

第 16 章 Vue Router 内核

本章要点

  • 路由的本质:URL 与组件树的映射关系
  • createRouter 的架构:matcher(路由匹配)+ history(URL 管理)+ 导航守卫的三位一体
  • 路由匹配器:如何将 /user/:id/posts 这样的路径模式编译为高效的正则表达式
  • History 模式的实现差异:createWebHistory vs createWebHashHistory vs createMemoryHistory
  • 导航解析的完整流程:从 router.push 到组件渲染的 17 个步骤
  • 导航守卫的洋葱模型:beforeEach → beforeRouteUpdate → beforeEnter → beforeRouteEnter → afterEach
  • RouterView 的实现:如何利用 provide/inject 实现嵌套路由
  • 路由懒加载与代码分割的底层机制

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 的源码不大(vue-router-next 主仓库 TypeScript 代码约 6000 行),但组织精巧。本章的阅读策略是:先看三条独立的轴,再看它们如何在 navigate 函数里汇合

  • 轴 1(数据结构轴):路由记录怎么表达 → RouteRecordRawRouteRecordNormalizedRouteRecordMatcher 三层,后面两层是运行时产物
  • 轴 2(URL 轴):浏览器地址栏怎么和内存状态同步 → RouterHistory 接口 + 三个实现
  • 轴 3(流程轴):一次 push 发生了什么 → navigate 函数的 7 个 then 链

这三条轴分别对应 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:#fff
    style B fill:#35495e,color:#fff
    style C fill:#35495e,color:#fff
    style D fill:#35495e,color:#fff

createRouter 的入口

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,
    addRoute,
    removeRoute,
    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 壳子:它会递归把对象的每一个属性都转成响应式 getter/setter。对一个普通业务对象这没什么问题,但路由对象不是普通对象。一条典型路由的 matched 字段是一个数组,每个元素是一个 RouteRecordNormalized,里面又有 components(一个记录了组件、工厂函数、异步 resolved 状态的字典)、metachildrenprops,有些字段可能还是数组或嵌套对象。一个嵌套 3 层的路由,深层响应式要代理的对象数量轻易超过 50 个,每个访问都要过一次 Proxy。

更关键的是路由对象的变更模式:每次导航结束,Router 都是整体替换 currentRoute.value,不会原地修改内部字段。深层响应式在这种场景下毫无价值——没人去监听 currentRoute.params.id 的变化,大家都是监听 currentRoute 本身。Vue 3 的 shallowRef 正是为这种”粒度是整体”的场景设计的:只有 .value 替换时触发更新,内部字段的读取是普通属性读取,无 Proxy 开销。

这和第 3 章讲 ref vs shallowRef 时的结论完全一致:响应式粒度要和真实变更粒度匹配。如果 Vue Router 把 currentRoute 写成 ref,每次导航要额外跑 50-200 次 Proxy 创建——在多路由变更(如大量嵌套路由)场景下,这会在 DevTools Performance 面板上画出一条清晰的 reactive overhead 条纹。shallowRef 让这条纹消失。

16.2 路由匹配器

路径模式的编译

Matcher 是 Vue Router 最”编译器化”的一块。它的输入是人类友好的字符串(/user/:id/posts),输出是机器高效的数据结构(预编译的正则 + 参数提取表)。这个从 DSL 到可执行形式的翻译过程,几乎可以看成一个微型的编译器:有词法分析、有语法(其实是段级的 tokenizer)、有代码生成(生成 RegExp)、有优化(评分排序)。对比丛书卷《Rust 编译器与运行时揭秘》第 4 章讲 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: '', repeat: 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 的路径语法目前就是这么迭代过来的——从 Vue Router 3 的简陋参数,到 v4 支持带正则、可重复、可选、通配的四种变体,每次扩展都是在这个两层架构里增量进行。

类型示例说明
Staticuser固定字符串
Param:id动态参数
Param + regexp:id(\\d+)带约束的参数
Param + repeat:chapters+可重复参数
Param + optional:lang?可选参数

第二步:编译为正则(tokensToParser)

function tokensToParser(
  segments: Array<Token[]>,
  extraOptions?: PathParserOptions
): PathParser {
  let score: Array<number[]> = []
  let pattern = options.start ? '^' : ''
  const keys: PathParserParamKey[] = []

  for (const segment of segments) {
    const segmentScores: number[] = []
    pattern += '/'

    for (const token of segment) {
      if (token.type === TokenType.Static) {
        pattern += token.value.replace(REGEX_CHARS_RE, '\\$&')
        segmentScores.push(PathScore.Static)
      } else {
        // 参数
        keys.push(token)
        const re = token.regexp ? token.regexp : BASE_PARAM_PATTERN
        pattern += token.repeat
          ? `((?:${re})(?:/(?:${re}))*)`
          : `(${re})`
        segmentScores.push(
          token.regexp ? PathScore.BonusCustomRegExp : PathScore.Dynamic
        )
      }
    }

    score.push(segmentScores)
  }

  const re = new RegExp(pattern, options.sensitive ? '' : 'i')

  // 返回 parser 对象
  return {
    re,
    score,
    keys,
    parse(path) { /* 用 re 匹配并提取参数 */ },
    stringify(params) { /* 将参数填入模式生成路径 */ },
  }
}

路由评分系统

当多条路由都能匹配同一个 URL 时,Vue Router 使用评分系统选择最佳匹配:

enum PathScore {
  _multiplier = 10,
  Root = 9 * _multiplier,           // /
  Segment = 4 * _multiplier,        // /segment
  SubSegment = 3 * _multiplier,     // /multiple-:things
  Static = 4 * _multiplier,         // /static
  Dynamic = 2 * _multiplier,        // /:param
  BonusCustomRegExp = 1 * _multiplier, // /:id(\\d+)
  BonusWildcard = -4 * _multiplier - PathScore.BonusCustomRegExp, // /:path(.*)
  BonusOptional = -0.8 * _multiplier, // /:id?
  BonusStrict = 0.07 * _multiplier, // 严格模式
  BonusCaseSensitive = 0.025 * _multiplier, // 大小写敏感
}

规则直觉:

  • 静态路径 > 动态路径/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 源码解析》第 6 章的 scheduler 设计有共通之处——都是”不依赖调用顺序,而依赖某种可量化的优先级”。在系统设计里,消除隐式顺序依赖几乎总是能降低 bug 率。

graph LR
    A["URL: /user/123"] --> B{匹配候选}
    B --> C["/user/profile — score: 80"]
    B --> D["/user/:id(\\d+) — score: 50"]
    B --> E["/user/:id — score: 40"]
    B --> F["/:path(.*) — score: -10"]

    D -->|最佳匹配| G["选中 /user/:id(\\d+)"]

    style G fill:#42b883,color:#fff

createRouterMatcher

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)

    // 处理嵌套路由
    if (parent) {
      normalizedRecord.path = parent.record.path + '/' + normalizedRecord.path
    }

    const matcher: RouteRecordMatcher = createRouteRecordMatcher(
      normalizedRecord,
      parent,
      globalOptions
    )

    // 按 score 排序插入
    insertMatcher(matcher)

    // 递归处理子路由
    if ('children' in normalizedRecord && normalizedRecord.children) {
      for (const child of normalizedRecord.children) {
        addRoute(child, matcher)
      }
    }
  }

  function resolve(location: MatcherLocationRaw): MatcherLocation {
    if (location.name) {
      // 命名路由:直接通过 Map 查找
      const matcher = matcherMap.get(location.name)
      // ...
    } else if (location.path) {
      // 路径匹配:遍历 matchers 数组,按 score 排序已保证优先匹配高分路由
      for (const matcher of matchers) {
        const parsed = matcher.re.exec(location.path)
        if (parsed) {
          // 提取参数,构造路由位置
          return /* ... */
        }
      }
    }
  }

  // 初始化:添加所有路由
  routes.forEach(route => addRoute(route))

  return { addRoute, resolve, removeRoute, getRoutes, getRecordMatcher }
}

命名路由通过 Map 实现 O(1) 查找;路径路由通过排序后的数组实现”优先匹配高分”。

嵌套路由的展开时机

上面 addRoute 函数里 if (parent) { normalizedRecord.path = parent.record.path + '/' + ... } 这一行值得单独说。路由的嵌套是树形结构,但 matcher 数组是一维的——这意味着树必须在 addRoute 时被展平。展平的方式是”路径拼接”:子路由记录的 path 被改写为父路径 + 自身路径。

这种编译期展平带来两个好处:第一,运行时匹配只需要一次数组遍历,不需要递归下钻;第二,所有路径在 matcher 里都是”完整形式”,容易调试。代价是所有子路由都要连带父路径信息存一份——但路径字符串本身很短,内存开销可忽略。

有个容易踩坑的细节:子路径以 / 开头意味着绝对路径(忽略父路径)。Vue Router 4 对此做了兼容:

// /admin 下挂 /login,最终路径不是 /admin/login 而是 /login
{ path: '/admin', children: [{ path: '/login', component: Login }] }

这个行为在 Vue Router 4 里修正为”子路径以 / 开头表示重置父路径”,在 Vue Router 3 里则是 “子路径默认相对”。迁移 v3 → v4 时这是高频回归点。丛书卷《微前端三派源码解读》在讲 qiankun 路由接管时提过类似问题——子应用的路由既不能完全相对(会冲突),也不能完全绝对(失去上下文),最终 qiankun 选择了”前缀隔离”的折中方案。Vue Router 走的是另一条路(显式决定权给开发者)。

16.3 History 模式

统一接口

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 核心要同时服务三类截然不同的宿主:

  1. 浏览器主路径createWebHistory,用 history.pushStatepopstate 事件。URL 更新会真实影响浏览器地址栏、前进后退按钮、收藏夹。
  2. 兼容老浏览器 / 静态托管createWebHashHistory,把路径塞进 # 后面。老 IE、没有服务器路由重写能力的静态托管(如早期 GitHub Pages)都要走这条路。
  3. 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

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 = Object.assign(
    { location: '', base, go },
    historyNavigation,
    historyListeners
  )

  // 拦截 popstate 事件
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => historyNavigation.location.value,
  })

  return routerHistory
}

核心是对浏览器 history.pushState / history.replaceStatepopstate 事件的封装。这两个 History API 早在 HTML5 规范里就有,但 SPA 框架之前很少好好用——Vue Router(和 React Router、Angular Router)把它们包装成可用、可测、可 mock 的接口,才是”单页应用”能真正流行起来的幕后功臣之一。

useHistoryListeners:popstate 的处理

function useHistoryListeners(base, historyState, currentLocation, replace) {
  let listeners: NavigationCallback[] = []
  let teardowns: (() => void)[] = []
  let pauseState: HistoryLocation | null = null

  const popStateHandler: PopStateHandler = ({ state }) => {
    const to = createCurrentLocation(base, window.location)
    const from = currentLocation.value
    const fromState = historyState.value

    currentLocation.value = to
    historyState.value = state

    if (pauseState && pauseState === from) {
      pauseState = null
      return
    }

    // 通知所有监听者
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta: state ? state.position - fromState.position : 1,
        type: NavigationType.pop,
        direction: /* ... */
      })
    })
  }

  window.addEventListener('popstate', popStateHandler)

  // ...
}

pauseState 机制用于区分”编程式导航”和”用户操作”。当 Router 主动调用 history.go() 时,会暂停 popstate 监听,避免触发重复的导航逻辑。

popstate 的非对称性

浏览器的 popstate 事件有个反直觉的设计:pushStatereplaceState 不会触发 popstate,只有用户交互(前进、后退、搜索栏直接改 URL 按 Enter)才触发。这个设计初看怪异,但其实是避免”脚本修改地址栏触发自己设置的 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

export function createMemoryHistory(base?: string): RouterHistory {
  let listeners: NavigationCallback[] = []
  let queue: HistoryLocation[] = [START]
  let position: number = 0

  function setLocation(location: HistoryLocation) {
    position++
    if (position !== queue.length) {
      queue.splice(position)  // 去掉"未来"的记录
    }
    queue.push(location)
  }

  const routerHistory: RouterHistory = {
    location: START,
    state: {},

    push(to, data) {
      setLocation(to)
    },
    replace(to) {
      queue.splice(position--, 1)
      setLocation(to)
    },
    go(delta, shouldTrigger = true) {
      const from = this.location
      const direction = delta < 0 ? 'back' : delta > 0 ? 'forward' : ''
      position = Math.max(0, Math.min(position + delta, queue.length - 1))
      if (shouldTrigger) {
        listeners.forEach(l => l(this.location, from, { direction, delta, type: 'pop' }))
      }
    },
    listen(cb) { /* ... */ },
    destroy() { listeners = []; queue = [START]; position = 0 },
    createHref: (to) => to,
  }

  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => queue[position],
  })

  return routerHistory
}

Memory History 不与浏览器交互,用数组模拟历史记录栈。主要用于 SSR 和测试场景。

从 Memory History 看架构的威力

值得单独点出:整个 Memory History 的实现只有几十行代码——一个数组 queue、一个位置指针 position、几个 push/replace/go 操作。完全是一个玩具级的实现。

但正是这个”玩具级”实现,让 Vue Router 能在没有浏览器的环境(SSR、单测、React Native 之类)里完整跑起来——没有任何能力缺失,守卫、嵌套路由、路径匹配全都正常工作。这就是接口抽象的价值:核心逻辑不绑死任何实现细节,只要新实现满足接口契约(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: 解析异步路由组件
    R->>G: 执行 beforeRouteEnter
    R->>G: 执行 beforeResolve

    R->>H: 更新 URL (pushState)
    R->>R: currentRoute.value = newRoute
    V-->>V: 检测 currentRoute 变化,重新渲染

    R->>G: 执行 afterEach
function navigate(
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): Promise<NavigationFailure | void> {
  // 提取需要离开、更新、进入的路由记录
  const [leavingRecords, updatingRecords, enteringRecords] =
    extractChangingRecords(to, from)

  // 构建守卫队列
  let guards: Lazy<NavigationGuard>[]

  // 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)))
  }

  return runGuardQueue(guards)
    .then(() => {
      // 2. 全局 beforeEach
      guards = []
      for (const guard of beforeGuards.list()) {
        guards.push(guardToPromiseFn(guard, to, from))
      }
      return runGuardQueue(guards)
    })
    .then(() => {
      // 3. beforeRouteUpdate(复用的组件)
      guards = extractComponentsGuards(
        updatingRecords,
        'beforeRouteUpdate',
        to, from
      )
      return runGuardQueue(guards)
    })
    .then(() => {
      // 4. 路由配置的 beforeEnter
      guards = []
      for (const record of enteringRecords) {
        if (record.beforeEnter) {
          for (const beforeEnter of Array.isArray(record.beforeEnter)
            ? record.beforeEnter
            : [record.beforeEnter]) {
            guards.push(guardToPromiseFn(beforeEnter, to, from))
          }
        }
      }
      return runGuardQueue(guards)
    })
    .then(() => {
      // 5. 解析异步路由组件
      return Promise.all(
        enteringRecords.map(record => record.components &&
          Promise.all(
            Object.values(record.components).map(component =>
              typeof component === 'function'
                ? (component as () => Promise<any>)()
                : component
            )
          )
        )
      )
    })
    .then(() => {
      // 6. beforeRouteEnter(新进入的组件)
      guards = extractComponentsGuards(enteringRecords, 'beforeRouteEnter', to, from)
      return runGuardQueue(guards)
    })
    .then(() => {
      // 7. 全局 beforeResolve
      guards = []
      for (const guard of beforeResolveGuards.list()) {
        guards.push(guardToPromiseFn(guard, to, from))
      }
      return runGuardQueue(guards)
    })
}

extractChangingRecords:路由变更检测

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 算法的共同追求。丛书卷《Vue 3 设计与实现》第 11 章讨论 VDOM diff 的时候也是同一个出发点——找到真正变化的部分、其他部分保持不动。Vue Router 的 record diff 和 VDOM 的 node diff 在思想上是孪生的,只是作用在不同的粒度(VDOM 作用于虚拟节点、Router 作用于路由记录)。

16.5 RouterView 的实现

嵌套路由与 depth

RouterView 利用 provide/inject 实现嵌套路由的层级感知:

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 currentName = props.name || 'default'
      const matchedRoute = matchedRouteRef.value
      const ViewComponent = matchedRoute &&
        matchedRoute.components![currentName]

      if (!ViewComponent) {
        return normalizeSlot(slots.default, { Component: null, route: routeToDisplay.value })
      }

      const component = h(ViewComponent, { ...attrs, ...routeProps })

      return normalizeSlot(slots.default, { Component: component, route: routeToDisplay.value })
        || 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:#fff
    style C fill:#42b883,color:#fff
    style E fill:#42b883,color:#fff

每层 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 字段是一个字典(而不是单个组件),RouterViewname prop 决定去字典里取哪个。源码里 matchedRoute.components![currentName] 这一行就是在做字典查询——缺省 name 是 'default',所以最常见的”只有一个组件”的写法只是多命名 view 特例的一个简写。

这种”一套记录 + 多个命名出口”的设计在实际项目里比想象的有用:同一路由下的 header、sidebar、main 三块可以互不影响地独立渲染和复用,状态也各自隔离。只是初学者不太用到,导致这个能力”藏得太深”。

导航与激活状态

export const RouterLinkImpl = defineComponent({
  name: 'RouterLink',

  props: {
    to: { type: [String, Object] as PropType<RouteLocationRaw>, required: true },
    replace: Boolean,
    activeClass: String,
    exactActiveClass: String,
  },

  setup(props, { slots }) {
    const router = inject(routerKey)!
    const currentRoute = inject(routeLocationKey)!

    const route = computed(() => router.resolve(unref(props.to)))

    const activeRecordIndex = computed(() => {
      const { matched: currentMatched } = currentRoute
      const { matched: toMatched } = route.value
      const index = toMatched.findIndex(
        isSameRouteRecord.bind(null, currentMatched[currentMatched.length - 1])
      )
      if (index > -1) return index
      // ...
      return -1
    })

    const isActive = computed(
      () => activeRecordIndex.value > -1 &&
        includesParams(currentRoute.params, route.value.params)
    )
    const isExactActive = computed(
      () => activeRecordIndex.value > -1 &&
        activeRecordIndex.value === route.value.matched.length - 1 &&
        isSameRouteLocationParams(currentRoute.params, route.value.params)
    )

    function navigate(e: MouseEvent) {
      if (guardEvent(e)) {
        router[unref(props.replace) ? 'replace' : 'push'](unref(props.to))
          .catch(noop)
      }
    }

    return () => {
      const children = slots.default && slots.default({
        route: route.value,
        href: route.value.href,
        isActive: isActive.value,
        isExactActive: isExactActive.value,
        navigate,
      })

      return h('a', {
        href: route.value.href,
        onClick: navigate,
        class: {
          [props.activeClass || 'router-link-active']: isActive.value,
          [props.exactActiveClass || 'router-link-exact-active']: isExactActive.value,
        }
      }, children)
    }
  }
})

guardEvent 函数处理各种边界情况:

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 在导航过程中(第 5 步)解析这些异步组件:

// navigate 函数中
.then(() => {
  // 解析异步路由组件
  return Promise.all(
    enteringRecords.map(record => {
      return Promise.all(
        Object.values(record.components).map(rawComponent => {
          if (typeof rawComponent === 'function') {
            // 调用工厂函数,加载组件
            return (rawComponent as Lazy<RouteComponent>)().then(resolved => {
              // 替换原始的工厂函数为加载后的组件
              record.components[name] = resolved.default || resolved
            })
          }
        })
      )
    })
  )
})

加载后的组件会替换原始的工厂函数。这意味着同一个路由的第二次访问不会重复加载。

替换原始工厂函数的微妙之处

这个”原地改写”的设计看起来随意,其实里面藏着一个权衡:把 caching 放在路由记录上,而不是放在全局缓存

全局缓存的做法是:弄一个 Map<path, Component>,加载完塞进去、访问时查表。这听起来更干净,但有个问题——路由记录是会被 removeRoute 的。如果用户动态移除了一个路由,然后又重新 addRoute 同路径,全局缓存会让第二次的”新路由”无意中继承了第一次的组件实例。想避免这个必须额外做生命周期管理,代码量陡增。

把 cache 绑定在路由记录自身,这些问题自然消失——记录被移除,连带它的已加载组件一起 gc 掉;记录重新添加,工厂函数是全新的,自然会重新加载。一个对象生命周期跟另一个绑定在一起,比两套独立生命周期 + 手工维护同步关系要简单太多。这种”把缓存挂在自然生命周期边界上”的技巧在很多系统里反复出现——丛书卷《Claude Code 源码解读》第 4 章讨论 ToolCache 的时候讲过同一个思路:不做全局单例缓存,而是把缓存挂在会话对象上,会话结束缓存自然释放。

与 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 里兜底”刷新一次”能把用户自动拉到新版本上,体验比”卡住不动”好得多。

丛书卷《Vite 设计与实现》第 15 章讲构建产物的 hash 命名时讨论过这个问题——“Immutable chunk”模式下旧 chunk 会在 CDN 保留一段时间(减少用户掉链风险),但长期看最终还是要刷新页面才能彻底恢复。这是现代 SPA 必然面对的”部署原子性 vs 用户会话寿命”的冲突。

16.8 导航守卫的执行引擎

guardToPromiseFn:将守卫统一为 Promise

function guardToPromiseFn(
  guard: NavigationGuard,
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): () => Promise<void> {
  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 {
          resolve()
        }
      }

      // 调用守卫,传入 next
      const guardReturn = guard.call(
        /* this */ 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)
      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<NavigationGuard>[]): Promise<void> {
  return guards.reduce(
    (promise, guard) => promise.then(() => guard()),
    Promise.resolve()
  )
}

守卫严格串行执行——前一个 resolve 了才执行下一个。任何一个 reject(返回 false、抛异常、重定向)都会终止整个链。

为什么不并行

乍一看,这些守卫好像可以并行——beforeEach 之间互相独立,让它们同时跑不是更快吗?

但守卫里经常有副作用耦合:一个守卫检查登录态(读 store)、一个守卫记埋点(写网络)、一个守卫重定向(改路由)。这些操作互相可能有时序依赖——典型场景:守卫 A 判断未登录后 return '/login' 触发重定向,守卫 B 之后的所有判断都应该基于”要去 /login 而不是 /user/1”。如果 A 和 B 并行,B 可能还在基于旧的目标地址做检查,等 A 返回时整个决策链已经不一致了。

串行执行以确定性换一点性能。大多数守卫是同步的(读 store、简单判断),总时间开销也就几毫秒,串行 vs 并行差异在用户体感上接近零;但串行带来的可预测性,在生产出 bug 时能救命——“这个守卫明明先跑啊,为什么我看到的状态是后跑那个守卫改过的?“这种问题并行模型下能调到爆炸,串行模型下一看队列顺序就懂。

丛书卷《LangGraph 源码解读》第 14 章谈到了 StateGraph 节点的并行语义问题——LangGraph 明确规定并行节点的状态合并规则(reducer 函数)。Vue Router 选择更简单的路径:根本不并行,就没有合并问题。这是两种不同复杂度场景下的合理取舍。

reduce 的陷阱和价值

guards.reduce((promise, guard) => promise.then(() => guard()), Promise.resolve()) 这一行代码是 JavaScript Promise 教程里的经典模式,但许多人写出来的变体是错的:

// 常见的错误写法
for (const guard of guards) {
  await guard()  // 如果在非 async 函数里,或者需要返回 Promise 给调用方,这个写法丢失了返回语义
}

// 另一个常见错误
Promise.all(guards.map(g => g()))  // 这是并行,不是串行

reduce 构造的是一条显式的 Promise 链,好处是:调用方拿到的 promise 是”所有守卫都跑完后 resolve”的最终 promise,可以 .then 接后续逻辑、可以 .catch 统一错处理。这是串行异步任务的标准做法——如果你在业务代码里也写类似的”按顺序等多个异步操作”,建议直接抄这个模式。

16.9 router.install:与 Vue 应用的集成

install(app: App) {
  const router = this
  // 注册全局组件
  app.component('RouterLink', RouterLink)
  app.component('RouterView', RouterView)

  // 全局属性
  app.config.globalProperties.$router = router
  app.config.globalProperties.$route = new Proxy({} as RouteLocationNormalized, {
    get: (_, key) => currentRoute.value[key as keyof RouteLocationNormalized]
  })

  // 依赖注入
  app.provide(routerKey, router)
  app.provide(routeLocationKey, shallowReactive(
    reactive({}) // ...
  ))
  app.provide(routerViewLocationKey, currentRoute)

  // 拦截所有组件的 unmount,清理导航守卫引用
  const unmountApp = app.unmount
  app.unmount = function () {
    delete started
    unmountApp()
  }

  // 初始导航
  if (isBrowser && !started) {
    started = true
    push(routerHistory.location).catch(err => {
      if (__DEV__) warn('Unexpected error when starting the router:', err)
    })
  }
}

注意 $route 使用了 Proxy——这是为了让 Options API 中的 this.$route 始终返回最新的路由信息,而不需要手动更新全局属性。

Proxy 在这里替代了什么

不用 Proxy 的朴素写法是:每次 currentRoute.value 变化时,遍历所有活的组件实例,把 this.$route 更新一遍。这在 Vue 2 + Vue Router 3 时代就是这么干的——当时叫 Vue.util.defineReactive,后面的成本是每个路由变更要扫一遍全 app 的组件树。

Vue Router 4 + Vue 3 的 Proxy 做法完全相反——**压根不同步 route,只在访问时才解析get:(,key)=>currentRoute.value[key]这段代码的含义是:"当有人读this.route,只在访问时才解析**。`get: (_, key) => currentRoute.value[key]` 这段代码的含义是:"当有人读 `this.route.params` 时,惰性地从 currentRoute 里取”。这种懒求值避免了”预先 push 到所有消费者”的模式,改为”消费者 pull”。结果是:

  1. 性能:路由变化时零额外工作,变化只影响 currentRoute.value 这一个引用
  2. 正确性:永远返回最新值,不存在”来不及更新”的窗口
  3. 可观察:配合 Vue 3 的 reactivity,读 this.$route 天然进 tracking 表,变了 → 重渲染

这是 Vue 3 响应式改进的一个典型受益点——Proxy 让”拦截属性读取”变得廉价,原本只能”推”的模式现在可以”拉”。对比丛书卷《Vue 3 设计与实现》第 4 章讨论 reactive 时,你会发现 reactive 本身就是 Proxy 包装。这里复用同一套能力:用 Proxy 把 $route 变成一个”代理到 currentRoute” 的虚对象。一致的底层机制、不一致的使用场景,这是设计的优雅之处。

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 } // 默认:滚动到顶部
  }
})

滚动行为在导航完成后执行:

为什么滚动行为要在 nextTick 里

代码里的 nextTick(() => handleScroll(...)) 这一行别小看——去掉它就是一串难以复现的 bug

用户点一个链接 /posts/100#comment-42,期望”跳转到这篇文章 + 滚到评论 42 的位置”。Router 做完导航后,新页面的 DOM 还没渲染完——Vue 的响应式更新是异步的,渲染要等到 microtask 队列清空之后。如果立刻调 scrollToPosition,目标元素 #comment-42 可能还不存在于 DOM,scrollIntoView 什么也不做。

nextTick 的作用是”等 Vue 把所有挂起的 DOM 变更 flush 完”,之后再执行滚动。这样目标 DOM 一定存在、高度也稳定了,才能算出正确的滚动目标。

这个细节典型反映了前端框架里”渲染、事件、任务调度”三者怎么协同。丛书卷《Vue 3 设计与实现》第 12 章讨论调度器时深入讲过 nextTick 的底层——它其实就是一个微任务(microtask)的封装,具体实现是 Promise.resolve().then(flushCallbacks)。每一个用 nextTick 的地方都有它的道理,滚动行为只是其中最容易被忽视的一个。

savedPosition 的来源

scrollBehavior(to, from, savedPosition) 的第三个参数是”保存的位置”——只在**浏览器前进/后退(popstate)**时非空。Router 怎么”保存”的?它用 history.state 字段存了滚动坐标:

// Router 内部在每次导航前
history.replaceState({ ...history.state, scroll: { left: window.scrollX, top: window.scrollY } })

当用户按浏览器后退键,触发 popstate 事件,附带的 state 里就带着当时的滚动位置。Router 拿出来传给 scrollBehavior,用户就看到”回到原来那个滚动位置”的预期行为。

这个实现的精妙在于借用了浏览器原生的 history.state——不需要在 JavaScript 里维护一个”路径 → 滚动位置”的 Map(那种做法有内存泄漏风险、前进后退深度没法跨刷新保持)。history.state 天然跟每个历史栈条目绑死、跨页刷新存活、浏览器原生维护生命周期。这是复用浏览器能力的典范。

// push 函数内部
router.push(to).then(() => {
  // 导航确认后
  nextTick(() => {
    // 等待 DOM 更新
    handleScroll(to, from, isPop, savedScrollPosition)
  })
})

function handleScroll(to, from, isPop, savedPosition) {
  const scrollBehavior = options.scrollBehavior
  if (!scrollBehavior) return

  const position = await scrollBehavior(to, from, isPop ? savedPosition : null)
  if (position) {
    // scrollToPosition 内部使用 window.scrollTo 或 el.scrollIntoView
    scrollToPosition(position)
  }
}

16.11 小结

Vue Router 的内核可以用一句话概括:将 URL 的变化转化为组件树的变化,同时在两者之间插入可控的拦截层

模块职责核心技术
Matcher路径 → 路由记录正则编译 + 评分排序
HistoryURL 管理History API / Hash / Memory
Navigation守卫流程控制Promise 串行队列
RouterView嵌套路由渲染provide/inject + depth
RouterLink声明式导航激活状态计算 + 事件拦截

Vue Router 的设计有两个值得学习的地方:

  1. 关注点分离:Matcher 不知道 History,History 不知道组件,每个模块都可以独立测试和替换
  2. 渐进式复杂度:简单场景只需要 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 返回 { top: 0 }(大部分人期望”切换页面回顶部”)、currentRouteshallowRef(路由对象特性决定)、history 需要显式选择(避免 SSR / 浏览器错配)。Vue Router 的 API 表面小,但默认行为经过反复打磨——默认值选得好,大多数用户不需要读文档就能写出正确的代码。这是框架设计的顶级追求。

延伸阅读

  • 丛书卷《Vue 3 设计与实现》 第 4 章(reactive/shallowRef 的机制)、第 11 章(VDOM diff)、第 12 章(调度器 + nextTick)——Vue Router 里所有涉及响应式、DOM 更新、微任务的细节都和这几章底层打通
  • 丛书卷《React 19 源码解读》 第 9 章(Suspense 与异步渲染)——对比两套前端框架处理异步的风格差异
  • 丛书卷《微前端三派源码解读》 讨论了 qiankun / wujie / Module Federation 的路由接管策略,是 Vue Router 基础上的扩展问题
  • 官方源码packages/router/src/(TypeScript 6k 行、注释质量极高,是学源码的好样本)
  • Vue Router 的 RFCvuejs/rfcs#150#137 讲了 v4 为什么放弃很多 v3 的 API 设计——“破坏性变更背后的 motivation”比 changelog 更值得读

思考题

  1. 为什么 currentRoute 使用 shallowRef 而不是 ref?如果改为 ref,会对性能产生什么影响?

  2. 假设有路由 /user/:id,用户从 /user/1 导航到 /user/2。RouterView 会卸载并重新创建组件,还是复用同一个组件实例?为什么?如何控制这个行为?

  3. 导航守卫链中,如果某个 beforeEach 守卫返回了一个新的路由(重定向),Vue Router 如何防止无限重定向循环?

  4. 设计一个路由级别的缓存方案:切换路由后,之前的页面组件不销毁,再次访问时恢复原状。考虑内存限制(最多缓存 N 个页面),你会如何实现?