VueRouter 原理解读 - 路由能力的原理与实现

956 阅读16分钟

在 VueRouter 源码解析系列文章的 “初始化流程” 的单篇文章当中,我们简单的对createRouter这个创建 VueRouter 路由对象方法进行了流程上的解析,其中关于 VueRouter 这个前端路由库的导航跳转能力的实现是相对粗略的概述过去了,当时仅仅是简单剧透了下最底层实现所使用的是浏览器history api 提供的能力,现在就让我们对 VueRouter 是如何实现不依赖服务端的纯前端路由能力以及对应路由导航跳转能力进行一个更为详尽的源码阅读解析吧。

一、VueRouter 的路由模式

1.1 WebHashHistory 模式

hash 模式下使用的是createWebHashHistoryapi 进行配置 VueRouter 的历史模式。

import { createRouter, createWebHashHistory } from 'vue-router'

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

使用 hash 模式的路由 url e.g.

http://128.0.0.1:3000/#/home

http://128.0.0.1:3000/#/manage

使用 hash 模式下会在浏览器网页路由当中使用哈希字符(#)对 url 进行切割并且匹配哈希字符后的字符进行判断路由匹配与跳转处理。由于这部分 URL 从未被发送到服务器,所以它不需要在服务器层面上进行任何特殊处理。这个 hash 模式因为需要使用 # 字符,因此如果有 url 参数 hash 使用的业务必要性下(hash 传递记录页面用户操作信息),该模式就会有业务使用上的冲突,并且如果对 url 的形式有强迫症的话这 url 看着确实有点难受;这个模式下还有另一个问题,对 SEO 的不友好。

1.2 H5 WebHistory 模式

HTML5 模式则使用的是createWebHistory配置。

import { createRouter, createWebHistory } from 'vue-router'

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

使用 history 模式的路由 url e.g.

http://128.0.0.1:3000/home

http://128.0.0.1:3000/manage

在这模式下路由的 url 则与以前传统的一个网页文件一个访问路径类似,但是应用本质上还是一个 SPA,如果没有在服务器当中配置相关路由匹配设置的话,用户在浏览器中直接访问路由路径时候会因为服务器当中没有路由匹配到相关的路径或者是服务器部署的项目当中没有对应的路径的文件的情况下就会返回 404 错误。要解决这个问题则需要在服务器上添加一个简单的回退路由的匹配规则如果 URL 不匹配任何静态资源,它应提供与你的应用程序中的 index.html 相同的页面。

服务器配置示例

Nginx

location / {
  try_files $uri $uri/ /index.html;
}

Node

const http = require('http')
const fs = require('fs')
const httpPort = 80

http
  .createServer((req, res) => {
    fs.readFile('index.html', 'utf-8', (err, content) => {
      if (err) {
        console.log('We cannot open "index.html" file.')
      }

      res.writeHead(200, {
        'Content-Type': 'text/html; charset=utf-8',
      })

      res.end(content)
    })
  })
  .listen(httpPort, () => {
    console.log('Server listening on: http://localhost:%s', httpPort)
  })
  • 其他类型的服务器配置例子可以查看 VueRouter 官网上的例子;

1.3 MemoryHistory 模式

这个模式可能比较少接触到,这个模式是提供在 SSR 情况下的服务端使用,createMemoryHistory能够创建一个基于内存的历史记录。这个历史记录的主要目的是处理 SSR。它在一个特殊的位置开始,这个位置无处不在。如果用户不在浏览器上下文中,它们可以通过调用 router.push() 或 router.replace() 将该位置替换为启动位置。

这里我们主要还是将精力集中在浏览器端使用的 H5 WebHistory 与 WebHashHistory 这两个模式当中,后面有时间有机会我们再重新对这个模式以及 SSR 相关的知识重新补充。





二、路由模式的源码实现解析

前面我们提及到在 Vue-Router 当中存在着三种路由模式,这里我们主要关注的是 hash 和 h5 history 这两种路由模式的原理与实现。其实 ReactRouter、VueRouter 等在内大多数的 SPA 路由库底层实现都是是基于 H5 History API 能力来实现的。

2.1 Web History

这里我们先提前了解一些关于 H5 History API 的知识作为即将开始的对 VueRouter 路由模式源码解析的前置基础知识:

属性

state - 当前状态

history.state 能够读取当前历史记录项的状态对象。

window.addEventListener('load', () => {
  const currentState = history.state;
  console.log('currentState', currentState); 
})

操作路由

用户历史记录跳转 - back、forward、go

// 在 history 中向后跳转(和用户点击浏览器回退按钮的效果相同):
window.history.back();

// 向前跳转(如同用户点击了前进按钮):
window.history.forward();

// 跳转到 history 中指定的一个点:
window.history.go(-1); // 向后移动一个页面 (等同于调用 back())
window.history.go(1); // 向前移动一个页面,等同于调用了 forward()

添加和修改历史记录 - pushState、replaceState

在正常的网页当中调用,能够发现虽然浏览器的 url 虽然是变化了,但是页面是没有任何页面跳转的变化;这样子我们就能拿来作为前端路由跳转使用的实现底层 api;

并且第一个参数在调用时候能够将数据存储到浏览器的浏览历史记录当中,在 history 的 state 当中能够获取到,后续我们也会在源码当中见识到这个数据传递的作用。

let stateObj = {
    foo: "bar",
};

history.pushState(stateObj, "page 2", "bar.html");

history.replaceState(stateObj, "page 3", "bar2.html");

事件监听

popstate 事件

每当激活同一文档中不同的历史记录条目时,popstate 事件就会在对应的 window 对象上触发。如果当前处于激活状态的历史记录条目是由 history.pushState() 方法创建的或者是由 history.replaceState() 方法修改的,则 popstate 事件的 state 属性包含了这个历史记录条目的 state 对象的一个拷贝。

备注: 调用 history.pushState() 或者 history.replaceState() 不会触发 popstate 事件。popstate 事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back() 方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。

window.addEventListener('popstate', (event) => {
  console.info("location: " + document.location + ", state: " + JSON.stringify(event.state));
})

history.pushState({page: 1}, "title 1", "?page=1");
history.pushState({page: 2}, "title 2", "?page=2");
history.replaceState({page: 3}, "title 3", "?page=3");
history.back(); // "location: http://example.com/example.html?page=1, state: {"page":1}"
history.back(); // "location: http://example.com/example.html, state: null
history.go(2);  // "location: http://example.com/example.html?page=3, state: {"page":3}

API 应用优势

  1. pushState、replaceState 这两个 api 能够不与服务端交互便在浏览器处进行改变网页 url 实现路由跳转切换功能;
  2. api 能够在历史记录 history 当中传值,并且能在 history 的 state 读取跳转存储的数据;
  3. 兼容性好,web history 相关的 api 在各个浏览器上基本能用;

2.2 路由模式的初始化

从上一章节当中我们已经了解了 Web History API 的相关信息,这章节开始我们来看下 VueRouter 是如何在 History API 之上进行封装,真正实现前端路由和页面跳转的相关能力。

createWebHashHistory

我们先来看 hash 模式的createWebHashHistory的实现:

createWebHashHistory 的源码文件

vuejs:router/packages/router/src/history/hash.ts

import { RouterHistory } from './common'
import { createWebHistory } from './html5'
import { warn } from '../warning'

export function createWebHashHistory(base?: string): RouterHistory {
  base = location.host ? base || location.pathname + location.search : ''
  
  // 判断当前路径如果没有 # 字符则在路径结尾拼接上 # 字符
  if (!base.includes('#')) base += '#'

  // 调用 H5 web History 的创建模式 api 
  return createWebHistory(base)
}

通过对createWebHashHistory方法的分析,我们能够看到 Hash 的模式下首先是拼接好 url 和对应的 hash 部分,然后使用createWebHistoryapi 进行创建;也就是其实 hash 模式和 h5 web history 模式底层的实现其实是类似的。

接下来我们继续分析createWebHistory这个 api。

createWebHistory

H5 web history 模式的实现源码文件

vuejs:router/packages/router/src/history/html5.ts

// vuejs:router/packages/router/src/history/html5.ts

export function createWebHistory(base?: string): RouterHistory {
  base = normalizeBase(base)

  // 创建路由历史跳转对象包含 state、location 属性和 push、replace 方法
  const historyNavigation = useHistoryStateNavigation(base)

  // 创建路由历史的监听器
  const historyListeners = useHistoryListeners(
    base,
    historyNavigation.state,
    historyNavigation.location,
    historyNavigation.replace
  )

  // 封装路由历史跳转的 go 方法,进行清除见监听器
  function go(delta: number, triggerListeners = true) {
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta)
  }

  // 使用 Object.assign 合并属性创建 VueRouter 路由历史对象,并且将该对象作为函数返回值
  const routerHistory: RouterHistory = assign(
    {
      location: '',
      base,
      go,
      createHref: createHref.bind(null, base),
    },

    historyNavigation,
    historyListeners
  )

  // 在 VueRouter 路由历史对象添加属性劫持 -> 读取时候直接获取的是 history 对象的
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => historyNavigation.location.value,
  })
  Object.defineProperty(routerHistory, 'state', {
    enumerable: true,
    get: () => historyNavigation.state.value,
  })

  return routerHistory
}

从上面的createWebHistory的代码能够分析出创建初始化的流程非常简单:

  1. 首先通过调用useHistoryStateNavigation方法创建路由历史跳转对象 historyNavigation,包含4个属性:location(当前 location)、state(路由页面的 history state)属性和 push、replace 两个方法;
  2. 使用useHistoryListeners创建路由历史的监听:主要支持路由跳转时的对 state 处理和跳转钩子函数的回调;
  3. 使用Object.assign合并属性创建 VueRouter 路由历史对象并且对其 location 和 state 属性进行劫持:
    1. location 劫持,当读取 routerHistory.location 时返回 location 经过拼接后的标准化路径;
    2. state 劫持,当读取 routerHistory.state 时返回浏览器原生 history 对象的 state 值;
  1. 返回这个 VueRouter 路由历史对象作为createWebHistory函数的返回值。

useHistoryStateNavigation 创建路由历史

// vuejs:router/packages/router/src/history/html5.ts

// 拼接当前标准的链接 Url
function createCurrentLocation(
  base: string,
  location: Location
): HistoryLocation {
  const { pathname, search, hash } = location
  const hashPos = base.indexOf('#')
  if (hashPos > -1) {
    let slicePos = hash.includes(base.slice(hashPos))
      ? base.slice(hashPos).length
      : 1
    let pathFromHash = hash.slice(slicePos)
    if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash
    return stripBase(pathFromHash, '')
  }
  const path = stripBase(pathname, base)
  return path + search + hash
}

function useHistoryStateNavigation(base: string) {
  const { history, location } = window

  // location 属性对象
  const currentLocation: ValueContainer<HistoryLocation> = {
    value: createCurrentLocation(base, location),
  }

  // state 属性对象
  const historyState: ValueContainer<StateEntry> = { value: history.state }

  // 封装的两种跳转方法
  function replace(to: HistoryLocation, data?: HistoryState) {
    // ··· ···
  }
  function push(to: HistoryLocation, data?: HistoryState) {
    // ··· ···
  }

  return {
    location: currentLocation,
    state: historyState,

    push,
    replace,
  }
}

从上面的useHistoryStateNavigation方法源码中看到,这个方法主要是创建和返回一个 VueRouter history 对象,这个对象包含有locationstate两个属性以及pushreplace这两个单页路由跳转方法。

  • location:调用createCurrentLocation获取经过格式化后的标准化的 url 字符串;
  • state:直接获取window.history对象上的state属性;

接下来我们继续分析下pushreplace跳转方法的实现,其实这两个跳转方法的底层实现所使用的 api 就是前面提及到的 web history 的history.pushStatehistory.replaceState方法(高不高兴,前面章节的学习当中你已经了解完这个 api 了,你已经学会已经被强化了,可以上了,皮卡丘!)。好,我们现在具体来分析 VueRouter的router.pushrouter.replace的路由跳转方法具体的实现:

// vuejs:router/packages/router/src/history/html5.ts

// 格式化 format state 对象属性
function buildState(
  back: HistoryLocation | null,
  current: HistoryLocation,
  forward: HistoryLocation | null,
  replaced: boolean = false,
  computeScroll: boolean = false
): StateEntry {
  return {
    back,
    current,
    forward,
    replaced,
    position: window.history.length,
    scroll: computeScroll ? computeScrollPosition() : null,
  }
}

function useHistoryStateNavigation(base: string) {
  const { history, location } = window

  // ··· ···

  // 内部封装的跳转方法 - 底层使用 history api 进行处理
  function changeLocation(
    to: HistoryLocation,
    state: StateEntry,
    replace: boolean
  ): void {
    const hashIndex = base.indexOf('#')
    const url =
      hashIndex > -1
        ? (location.host && document.querySelector('base')
            ? base
            : base.slice(hashIndex)) + to
        : createBaseLocation() + base + to
    try {
      history[replace ? 'replaceState' : 'pushState'](state, '', url)
      historyState.value = state
    } catch (err) {
      console.error(err)
      location[replace ? 'replace' : 'assign'](url)
    }
  }

  // 路由替换跳转
  function replace(to: HistoryLocation, data?: HistoryState) {
    const state: StateEntry = assign(
      {},
      history.state,
      buildState(
        historyState.value.back,
        to,
        historyState.value.forward,
        true
      ),
      data,
      { position: historyState.value.position }
    )

    changeLocation(to, state, true)
    currentLocation.value = to
  }

  // 路由导航跳转
  function push(to: HistoryLocation, data?: HistoryState) {
    const currentState = assign(
      {},
      historyState.value,
      history.state as Partial<StateEntry> | null,
      {
        forward: to,
        scroll: computeScrollPosition(),
      }
    )

    changeLocation(currentState.current, currentState, true)

    const state: StateEntry = assign(
      {},
      buildState(currentLocation.value, to, null),
      { position: currentState.position + 1 },
      data
    )

    changeLocation(to, state, false)
    currentLocation.value = to
  }

  // ··· ···
}

我们首先来看看changeLocation这个方法的实现,这个方法需要传递三个参数,第一个是要跳转的路由to,第二个是state是跳转时候的需要存储在浏览器历史记录的状态数据,第三个则是判断路由跳转的模式replace。这个方法内部实现就是使用浏览器的historyapi 来进行路由的跳转,也是根据第三个参数来判断是执行history.pushState或者是history.replaceState方法。

接着我们来具体看看pushreplace这两个方法在调用changeLocation进行路由跳转前所做的逻辑。

replace 跳转
// vuejs:router/packages/router/src/history/html5.ts

function replace(to: HistoryLocation, data?: HistoryState) {
  const state: StateEntry = assign(
    {},
    history.state,
    buildState(
      historyState.value.back,
      to,
      historyState.value.forward,
      true
    ),
    data,
    { position: historyState.value.position }
  )

  changeLocation(to, state, true) // 调用路由跳转方法,第三个参数传递为 true 表示使用 history.replace 进行路由跳转

  // 
  currentLocation.value = to
}

能够看到,replace的路由跳转方式的实现是比较简单直接的,就是格式化整理了当前网页的 state 数据,然后调用changeLocation方法传入要跳转的 to URL 链接、要存储的网页的 state 数据以及第三个参数 replace 为 true 值,前文当中我们在对changeLocation这个方法时候就已经对这个方法的第三个参数进行解析了,就是决定路由跳转的底层调用方法是 history 的pushState还是replaceState方法;这里传递的是 true 值表示使用replaceStateapi 来对前端路由进行跳转处理。

push 跳转
// vuejs:router/packages/router/src/history/html5.ts

function push(to: HistoryLocation, data?: HistoryState) {
  const currentState = assign(
    {},
    historyState.value,
    history.state as Partial<StateEntry> | null,
    {
      forward: to,
      scroll: computeScrollPosition(),
    }
  )

  // 记录当前页面的滚动情况
  changeLocation(currentState.current, currentState, true)

  const state: StateEntry = assign(
    {},
    buildState(currentLocation.value, to, null),
    { position: currentState.position + 1 },
    data
  )

  // 进行页面的 push 路由跳转
  changeLocation(to, state, false)

  currentLocation.value = to
}

push方法相比replace方法是调用了两次changeLocation方法对前端路由是进行了两次跳转操作。

  • 第一次调用changeLocation传入第一个参数 url 就是当前网页的 url 没变化,主要是第二个参数的 state 里面是通过computeScrollPosition方法获取了当前页面的滚动情况,并且第三个参数 replace 传递的是 true,是需要覆盖当前浏览历史的跳转操作;也就是第一次的跳转是通过 history 的 replaceState 跳转推入 state 数据来记录当前网页的滚动情况。
    • 其实我们也可以利用这种实现思路来进行一些数据的存储传递操作,并且不会对浏览网页的 url 进行任何修改链接、参数的副作用。
  • 第二次调用changeLocation则传入需要跳转的 to url,并且第三个参数为 false ,使用 history 的 pushState 方法来进行路由跳转了。

useHistoryListeners 添加路由历史监听

相关 VueRouter history 对象创建完成属性赋值后便是使用useHistoryListeners方法对浏览器的路由跳转事件进行监听处理。

根据前面的章节学习,我们能够知道 VueRouter 路由能力和路由跳转底层实现是基于 Web history 这个 api 来实现的,而history.gohistory.forwardhistory.back这几个事件都会触发popstate事件,因此可以增加对浏览器的popstate事件监听来实现路由的跳转监听。

至于网页关闭或者离开网页时候则会触发beforeunload事件,因此同理对浏览器的beforeunload事件进行监听处理来实现离开、关闭网页的监控。

// vuejs:router/packages/router/src/history/html5.ts

function useHistoryListeners(
    base: string,
    historyState: ValueContainer<StateEntry>,
    currentLocation: ValueContainer<HistoryLocation>,
    replace: RouterHistory['replace']
) {
  let listeners: NavigationCallback[] = []
  let teardowns: Array<() => void> = []
  let pauseState: HistoryLocation | null = null

  // 订阅 popstate 事件触发执行,后续解析
  const popStateHandler: PopStateListener = ({
    state,
  }: {
    state: StateEntry | null
  }) => {
    // ··· ···
  }

  // 订阅 beforeunload 事件触发执行,后续解析
  function beforeUnloadListener() {
    // ··· ···
  }

  // 暂停监听
  function pauseListeners() {
    pauseState = currentLocation.value
  }

  // 注册监听
  function listen(callback: NavigationCallback) {
    listeners.push(callback)

    const teardown = () => {
      const index = listeners.indexOf(callback)
      if (index > -1) listeners.splice(index, 1)
    }

    teardowns.push(teardown)
    return teardown
  }

  // 销毁监听
  function destroy() {
    for (const teardown of teardowns) teardown()
    teardowns = []
    window.removeEventListener('popstate', popStateHandler)
    window.removeEventListener('beforeunload', beforeUnloadListener)
  }

  // 添加对浏览器的 popstate 与 beforeunload 事件的监听(passive 就是不阻止浏览器的默认事件触发)
  window.addEventListener('popstate', popStateHandler)
  window.addEventListener('beforeunload', beforeUnloadListener, {
    passive: true,
  })

  return {
    pauseListeners,
    listen,
    destroy,
  }
}

接下来我们具体看看这两个监听的回调事件具体处理逻辑。

对路由历史 popstate 事件的监听回调
// vuejs:router/packages/router/src/history/html5.ts

function useHistoryListeners(
    base: string,
    historyState: ValueContainer<StateEntry>,
    currentLocation: ValueContainer<HistoryLocation>,
    replace: RouterHistory['replace']
) {
  let listeners: NavigationCallback[] = []
  let teardowns: Array<() => void> = []
  let pauseState: HistoryLocation | null = null

  const popStateHandler: PopStateListener = ({
    state,
  }: {
    state: StateEntry | null
  }) => {
    const to = createCurrentLocation(base, location)
    const from: HistoryLocation = currentLocation.value
    const fromState: StateEntry = historyState.value
    let delta = 0

    if (state) { // 判断当前网页的 state 是否有效
      // 刷新当前路由历史对象的 location 与 state 值
      currentLocation.value = to
      historyState.value = state

      // 判断是否暂停监听,若暂停监听则直接返回中断后续的处理
      if (pauseState && pauseState === from) {
        pauseState = null
        return
      }

      // 计算步长,后续判断是前进还是后退
      delta = fromState ? state.position - fromState.position : 0
    } else { // state 为无效值时候直接进行 replace 跳转处理
      replace(to)
    }

    // 触发发布事件,遍历路由对象的注册订阅列表,逐个进行执行回调
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
            ? delta > 0
                ? NavigationDirection.forward
                : NavigationDirection.back
            : NavigationDirection.unknown,
      })
    })
  }

  // ··· ···
}

方法popStateHandler在前端路由跳转时(popstate),主要做了是做了三件事情:

  1. 刷新当前路由历史对象的 location 与 state 值,使得缓存信息同步;
  2. 若暂停监控时,则重置 pauseState 并直接返回中断后续的操作;
  3. 触发跳转事件发布,并执行注册回调通知订阅者;
对页面 beforeunload 事件的监听
// vuejs:router/packages/router/src/history/html5.ts

function useHistoryListeners(
    base: string,
    historyState: ValueContainer<StateEntry>,
    currentLocation: ValueContainer<HistoryLocation>,
    replace: RouterHistory['replace']
) {
  // ··· ···

  // 网页关闭或者跳转离开网页时执行的回调
  function beforeUnloadListener() {
    const { history } = window
    if (!history.state) return

    history.replaceState(assign({}, history.state, { scroll: computeScrollPosition() }), '')
  }

  // ··· ···
}

对于离开页面的 unload 事件监听则没啥特殊的操作,其实就是执行history.replaceState设置浏览器历史记录当前页面的 state 数据,这里主要是为了要记录当前页面的滚动情况,因此使用computeScrollPosition方法获取当前页面的滚动情况数据,并且设置到 state 的 scroll 属性上。

location 与 state 属性的劫持获取

// vuejs:router/packages/router/src/history/html5.ts

export function createWebHistory(base?: string): RouterHistory {
  // ··· ···

  // 在 VueRouter 路由历史对象添加属性劫持 -> 读取时候直接获取的是 history 对象的
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => historyNavigation.location.value,
  })
  Object.defineProperty(routerHistory, 'state', {
    enumerable: true,
    get: () => historyNavigation.state.value,
  })

  // ··· ···
}

这块逻辑比较简单,从前面的对useHistoryStateNavigation方法的逻辑解析当中我们能够看到,现在 VueRouter history 对象的locationstate两个属性其实都是是分别经过包装处理的,是一个复杂对象属性;因此这里使用Object.defineProperty对 VueRouter history 对象进行属性劫持,重写其 get 取值方法能够简单有效的通过直接读取就能返回对应具体的 value 值,方便外部业务处进行操作读取。

小结

路由模式的初始化逻辑流程图展示

到这里我们已经对 VueRouter 的路由模式模块的底层实现和路由模式的初始化流程简析了一遍,在了解了浏览器的history这个 api 后,我们对 VueRouter 路由模式以及前端路由能力的底层实现其实已经有一定的了解。

    • VueRouter 的前端路由能力是基于浏览器的 history api 所实现的,对history.pushStatehistory.replaceState以及history.go等操作浏览器历史记录的 api 进行封装实现不需要服务端支持的前端浏览器页面进行状态变更的操作。并且通过对里面具体底层实现逻辑的阅读,我们学习到一种能够存储网页浏览信息的方法(通过浏览器历史记录的 state 进行存储相关信息);
    • 在路由跳转的相关的一些事件当中进行监听,并且依据一套比较简单的发布订阅模式来实现对页面路由切换修改时候的监听回调处理。

接下来我们会对 VueRouter 的导航守卫模块进行入手,探究整个 VueRouter 前端路由的生命周期流程是怎样的,又是如何进行实现钩子监听回调的。敬请大家期待。





参考资料

相关的系列文章

相关的参考资料