vue router 4 源码篇:router history的原生结合

3,742 阅读12分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

源码专栏

感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:

开场

哈喽大咖好,我是跑手,本次给大家继续探讨vue-router@4.x源码中有关Web History API能力的部分,也就是官方文档中历史模式

大家多少有点了解,包括react routervue-router在内大多数单页路由库,是基于 H5 History API能力来实现的。History API其实做的事情也很简单,就是改变当前web URL而不与服务器交互,完成纯前端页面的URL变型。

撰写目的

在这篇文章里,你能获得以下增益:

  1. 了解vue-router中对Web History API能力的应用。
  2. 了解createWebHistorycreateWebHashHistory的实现原理。

事不宜迟,开讲。。。
。。

Web History API

在H5 History API完成页面url变化有2个重要函数:pushState()replaceState(),它们的差异无非就是

举个沉浸式例子

我们随便打开一个页面,在控制台查看下原始History是这样的,其中length是一个只读属性,代表当前 session记录的页面历史数量(包括当前页)。

image.png

然后再执行这段代码,有得到如下效果:浏览器url发生了变化,但页面内容没有任何改动:

history.pushState(
    { myName: 'test', state: { page: 1, index: 2 } }, 
    'div title', 
    '/divPath'
)

我们再看看History内容,如下图:

image.png

会发现和之前的变化有:

  • length23。虽然页面不跳转,但我们执行pushState时往history堆栈中插入了一条新数据,所以依旧被History对象收录,因此length1
  • scrollRestoration是描述页面滚动属性,auto | manual: 分别表示自动 | 手动恢复页面滚动位置,在vue-router滚动行为中就用到这块的能力;
  • History.state值变成了我们在pushState传的第一个参数,理论上这个参数可以是任意对象,这也是单页应用在路由跳转时可以随心所欲传值的关键。另外如果不是pushState()replaceState()调用,state 的值将会是 null。

服务器适配

pushState() 和 replaceState() 改变URL确实也有个通病,就是刷新页面报404,因为刷新行为属于浏览器与后台服务通信的默认行为,服务器没法解析前端自定义path而导致404错误。

image.png

要解决这个问题,你需要在服务器上添加一个简单的回退路由,如果 URL 不匹配任何静态资源,直接回退到 index.html。

结论

说了那么多,总结下Web History API能给我们带来:

  1. 在不与服务端交互情况下改变页面url,给单页路由应用带来可玩(有)性(戏)
  2. 能传值,并且能在history栈顶的state读到这些值,解决单页之间的跳转数据传输问题
  3. 兼容性好,主流和不是那么主流的客户端都兼容

基于此,各类的路由库应用应运而生,当然vue-router也是其中之一。

createWebHistory

创建一个适配Vue的 H5 History记录,需要用到createWebHistory方法,入参是一个路径字符串,表示history的根路径,返回是一个vue的history对象,返回类型定义如下:

Typescript类型:

export declare function createWebHistory(base?: string): RouterHistory



/**
 * Interface implemented by History implementations that can be passed to the
 * router as {@link Router.history}
 *
 * @alpha
 */
export interface RouterHistory {
  /**
   * Base path that is prepended to every url. This allows hosting an SPA at a
   * sub-folder of a domain like `example.com/sub-folder` by having a `base` of
   * `/sub-folder`
   */
  readonly base: string
  /**
   * Current History location
   */
  readonly location: HistoryLocation
  /**
   * Current History state
   */
  readonly state: HistoryState
  // readonly location: ValueContainer<HistoryLocationNormalized>

  /**
   * Navigates to a location. In the case of an HTML5 History implementation,
   * this will call `history.pushState` to effectively change the URL.
   *
   * @param to - location to push
   * @param data - optional {@link HistoryState} to be associated with the
   * navigation entry
   */
  push(to: HistoryLocation, data?: HistoryState): void
  /**
   * Same as {@link RouterHistory.push} but performs a `history.replaceState`
   * instead of `history.pushState`
   *
   * @param to - location to set
   * @param data - optional {@link HistoryState} to be associated with the
   * navigation entry
   */
  replace(to: HistoryLocation, data?: HistoryState): void

  /**
   * Traverses history in a given direction.
   *
   * @example
   * ```js
   * myHistory.go(-1) // equivalent to window.history.back()
   * myHistory.go(1) // equivalent to window.history.forward()
   * ```
   *
   * @param delta - distance to travel. If delta is < 0, it will go back,
   * if it's > 0, it will go forward by that amount of entries.
   * @param triggerListeners - whether this should trigger listeners attached to
   * the history
   */
  go(delta: number, triggerListeners?: boolean): void

  /**
   * Attach a listener to the History implementation that is triggered when the
   * navigation is triggered from outside (like the Browser back and forward
   * buttons) or when passing `true` to {@link RouterHistory.back} and
   * {@link RouterHistory.forward}
   *
   * @param callback - listener to attach
   * @returns a callback to remove the listener
   */
  listen(callback: NavigationCallback): () => void

  /**
   * Generates the corresponding href to be used in an anchor tag.
   *
   * @param location - history location that should create an href
   */
  createHref(location: HistoryLocation): string

  /**
   * Clears any event listener attached by the history implementation.
   */
  destroy(): void
}

《vue router 4 源码篇:路由诞生——createRouter原理探索》中讲到,createRouter创建vue-router实例时,会添加单页跳转时的监听回调,其能力源于本方法createWebHistory创建的history对象。该对象中导出的方法(如:listen、destroy、push等等...),都是依托了原生Web History API能力,并且结合了Vue技术而封装的中间层SDK,把两者连接起来。

实现原理流程图

image.png

createWebHistory总流程非常简单,分4步走:

  1. 创建vue router 的history对象,包含4个属性:location(当前location)、state(路由页面的history state)、和pushreplace2个方法;
  2. 创建vue router 监听器:主要支持路由跳转时的state处理和自定义的跳转逻辑回调;
  3. 添加location劫持,当routerHistory.location变动时返回标准化的路径;
  4. 添加state劫持,当routerHistory.state变动时返回里面的state;

步骤对应的源码如下「附注释」:

/**
 * Creates an HTML5 history. Most common history for single page applications.
 *
 * @param base -
 */
export function createWebHistory(base?: string): RouterHistory {
  base = normalizeBase(base)

  // 步骤1:创建`vue router` 的history对象
  const historyNavigation = useHistoryStateNavigation(base)
  
  // 步骤2:创建`vue router` 监听器
  const historyListeners = useHistoryListeners(
    base,
    historyNavigation.state,
    historyNavigation.location,
    historyNavigation.replace
  )
  function go(delta: number, triggerListeners = true) {
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta)
  }

  // 组装routerHistory对象
  const routerHistory: RouterHistory = assign(
    {
      // it's overridden right after
      location: '',
      base,
      go,
      createHref: createHref.bind(null, base),
    },

    historyNavigation,
    historyListeners
  )

  // 步骤3:添加location劫持
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => historyNavigation.location.value,
  })

  // 步骤4:添加state劫持
  Object.defineProperty(routerHistory, 'state', {
    enumerable: true,
    get: () => historyNavigation.state.value,
  })

  // 返回整个router History对象
  return routerHistory
}

最后,createWebHistory方法返回处理好后的routerHistory对象,供createRouter使用。

接下来,我们跟着源码,拆分上面四个流程,看具体是怎么实现的。

创建History

第一步,创建vue router 的history对象,在上面源码用useHistoryStateNavigation方法来创建这个对象,方便大家理解,笔者简化一个流程图:

流程图

image.png

从左到右,vue router history使用了H5 History能力。其中history.pushStatehistory.replaceState 方法被封装到一个名为locationChange的路径变化处理函数中,而locationChange作为一个公共函数,则被push 和 replace 函数调用,这2个函数,也就是我们熟知的Router pushRouter replace 方法。

另外,vue router history的state对象底层也是用到了history.state,只不过再封装成符合vue router的state罢了。

最后,useHistoryStateNavigation方法把push、replace、state、location集成到一个对象中返回,完成了history的初始化。

源码解析

changeLocation

先看changeLocation,源码如下:

function changeLocation(
  to: HistoryLocation,
  state: StateEntry,
  replace: boolean
): void {
  /**
   * if a base tag is provided, and we are on a normal domain, we have to
   * respect the provided `base` attribute because pushState() will use it and
   * potentially erase anything before the `#` like at
   * https://github.com/vuejs/router/issues/685 where a base of
   * `/folder/#` but a base of `/` would erase the `/folder/` section. If
   * there is no host, the `<base>` tag makes no sense and if there isn't a
   * base tag we can just use everything after the `#`.
   */
  const hashIndex = base.indexOf('#')
  const url =
    hashIndex > -1
      ? (location.host && document.querySelector('base')
          ? base
          : base.slice(hashIndex)) + to
      : createBaseLocation() + base + to
  try {
    // BROWSER QUIRK
    // NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
    history[replace ? 'replaceState' : 'pushState'](state, '', url)
    historyState.value = state
  } catch (err) {
    if (__DEV__) {
      warn('Error with push/replace State', err)
    } else {
      console.error(err)
    }
    // Force the navigation, this also resets the call count
    location[replace ? 'replace' : 'assign'](url)
  }
}

首先是结合base根路径计算最终的跳转url,然后根据replace标记决定使用history.pushState 或 history.replaceState进行跳转。

buildState

replace和push里都使用到一个公共函数buildState,这函数作用是在原来state中添加页面滚动位置记录,方便页面回退时滚动到原来位置。

/**
 * Creates a state object
 */
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,
  }
}

// computeScrollPosition方法定义
export const computeScrollPosition = () =>
  ({
    left: window.pageXOffset,
    top: window.pageYOffset,
  } as _ScrollPositionNormalized)

replace

replace方法实现也比较简单:先把state和传进来的data整合得到一个最终state,再调用changeLocation进行跳转,最后更新下当前Location变量。

function replace(to: HistoryLocation, data?: HistoryState) {
  const state: StateEntry = assign(
    {},
    history.state,
    buildState(
      historyState.value.back,
      // keep back and forward entries but override current position
      to,
      historyState.value.forward,
      true
    ),
    data,
    { position: historyState.value.position }
  )

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

push

function push(to: HistoryLocation, data?: HistoryState) {
  // Add to current entry the information of where we are going
  // as well as saving the current position
  const currentState = assign(
    {},
    // use current history state to gracefully handle a wrong call to
    // history.replaceState
    // https://github.com/vuejs/router/issues/366
    historyState.value,
    history.state as Partial<StateEntry> | null,
    {
      forward: to,
      scroll: computeScrollPosition(),
    }
  )

  if (__DEV__ && !history.state) {
    warn(
      `history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` +
        `history.replaceState(history.state, '', url)\n\n` +
        `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`
    )
  }

  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
}

和replace差不多,都是调用changeLocation完成跳转,但是push方法会跳转2次:第一次是给router history添加forward和scroll的中间跳转,其作用是保存当前页面的滚动位置。

为什么要2次跳转才能保存页面位置? 大家试想下,当你浏览一个页面,滚动到某个位置,你利用history.pushState跳转到另一个页面时,history堆栈会压入一条记录,但同时vue router会帮助你记录跳转前页面位置,以便在回退时恢复滚动位置。要实现这个效果,就必须在push方法中,在调用changeLocation前把当前页面位置记录到router state中。

要实现这个功能方法有多种,最简单方法就是在跳转前把位置信息记录好放进state里面,然后通过changeLocation(to, state, false)实现跳转。

但官方用了另一种优雅方法解决这个问题,就是在最终跳转前先来一次replace模式的中间跳转,这样在不破坏原页面信息基础上更新了router state,省去更多与页面位置相关的连带处理。这就有了push方法中2次调用changeLocation

至此,vue router history的创建流程全部执行完成,但仅仅依靠history的改变是不够的,下面我们再看看监听器的实现过程。

创建路由监听器

流程图

image.png

众所周知,history.gohistory.forwardhistory.back都会触发popstate事件,然后再将popStateHandler方法绑定到popstate事件即可实现路由跳转监听。

而页面关闭或离开时会触发beforeunload事件,同理将beforeUnloadListener方法绑定到该事件上实现对此类场景的监控。

最后为了能自定义监控逻辑,监听器抛出了3个钩子函数:pauseListeners「停止监听」、listen「注册监听回调,符合订阅发布模式」、destroy「卸载监听器」。

源码解析

popStateHandler

const popStateHandler: PopStateListener = ({
  state,
}: {
  state: StateEntry | null
}) => {
  // 新跳转地址
  const to = createCurrentLocation(base, location)
  // 当前路由地址
  const from: HistoryLocation = currentLocation.value
  // 当前state
  const fromState: StateEntry = historyState.value
  // 计步器
  let delta = 0

  if (state) {
    // 目标路由state不为空时,更新currentLocation和historyState缓存
    currentLocation.value = to
    historyState.value = state

    // 暂停监控时,中断跳转并重置pauseState
    if (pauseState && pauseState === from) {
      pauseState = null
      return
    }
    // 计算距离
    delta = fromState ? state.position - fromState.position : 0
  } else {
    // 否则执行replace回调
    replace(to)
  }

  // console.log({ deltaFromCurrent })
  // Here we could also revert the navigation by calling history.go(-delta)
  // this listener will have to be adapted to not trigger again and to wait for the url
  // to be updated before triggering the listeners. Some kind of validation function would also
  // need to be passed to the listeners so the navigation can be accepted
  // call all listeners
  
  // 发布跳转事件,将Location、跳转类型、跳转距离等信息返回给所有注册的订阅者,并执行注册回调
  listeners.forEach(listener => {
    listener(currentLocation.value, from, {
      delta,
      type: NavigationType.pop,
      direction: delta
        ? delta > 0
          ? NavigationDirection.forward
          : NavigationDirection.back
        : NavigationDirection.unknown,
    })
  })
}

纵观而视,popStateHandler在路由跳转时,做了这些事情:

  1. 更新history的location和state等信息,使得缓存信息同步;
  2. 暂停监控时,中断跳转并重置pauseState;
  3. 将必要信息告知所有注册的订阅者,并执行注册回调;

beforeUnloadListener

function beforeUnloadListener() {
  const { history } = window
  if (!history.state) return
  history.replaceState(
    assign({}, history.state, { scroll: computeScrollPosition() }),
    ''
  )
}

关闭页面前会执行这个方法,主要作用是记录下当前页面滚动。

3个listener hooks

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

// 注册监听逻辑
function listen(callback: NavigationCallback) {
  // set up the listener and prepare teardown callbacks
  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)
}

添加location和state劫持

Object.defineProperty(routerHistory, 'location', {
  enumerable: true,
  get: () => historyNavigation.location.value,
})

Object.defineProperty(routerHistory, 'state', {
  enumerable: true,
  get: () => historyNavigation.state.value,
})

这里没啥好说的,就是读取routerHistory.locationrouterHistory.state时能获取到historyNavigation方法中的内容。

到这里就是createWebHistory如何结合vue创建出一个router history的整个过程了。

createWebHashHistory

createMemoryHistory主要创建一个基于内存的历史记录,这个历史记录的主要目的是处理 SSR。

其逻辑和createWebHistory大同小异,都是通过history和监听器实现,只不过在服务器场景中,没有window对象,也没法用到H5 History API能力,所以history用了一个queue(队列)代替,而监听器也是消费队列完成路由切换。以下是关键源码:

/**
 * Creates an in-memory based history. The main purpose of this history is to handle SSR. It starts in a special location that is nowhere.
 * It's up to the user to replace that location with the starter location by either calling `router.push` or `router.replace`.
 *
 * @param base - Base applied to all urls, defaults to '/'
 * @returns a history object that can be passed to the router constructor
 */
export function createMemoryHistory(base: string = ''): RouterHistory {
  let listeners: NavigationCallback[] = []
  let queue: HistoryLocation[] = [START]
  let position: number = 0
  base = normalizeBase(base)

  // 通过position(计步器)改变queue达到路由跳转效果
  function setLocation(location: HistoryLocation) {
    position++
    if (position === queue.length) {
      // we are at the end, we can simply append a new entry
      queue.push(location)
    } else {
      // we are in the middle, we remove everything from here in the queue
      queue.splice(position)
      queue.push(location)
    }
  }

  // 监听器触发
  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)
    }
  }

  // 构建router history
  const routerHistory: RouterHistory = {
    // rewritten by Object.defineProperty
    location: START,
    // TODO: should be kept in queue
    state: {},
    base,
    createHref: createHref.bind(null, base),

    // replace方法
    replace(to) {
      // remove current entry and decrement position
      queue.splice(position--, 1)
      setLocation(to)
    },

    // push方法
    // 这2种方法都是调用setLocation来改变queue
    push(to, data?: HistoryState) {
      setLocation(to)
    },

    // 添加监听回调
    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: NavigationDirection =
        // we are considering delta === 0 going forward, but in abstract mode
        // using 0 for the delta doesn't make sense like it does in html5 where
        // it reloads the page
        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],
  })

  // 针对单测时处理
  if (__TEST__) {
    // ...
  }

  return routerHistory
}

落幕

好了好了,这节先到这里,最后感谢大家阅览并欢迎纠错,欢迎大家关注本人公众号「似马非马」,一起玩耍起来!🌹🌹