前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >vue router 4 源码篇:router history的原生结合

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

原创
作者头像
南山种子外卖跑手
发布2022-10-13 19:59:52
1.2K0
发布2022-10-13 19:59:52
举报
文章被收录于专栏:南山种子外卖跑手的专栏

本文为原创文章,引用请注明出处,欢迎大家收藏和分享💐💐

源码专栏

感谢大家继续阅读《Vue Router 4 源码探索系列》专栏,你可以在下面找到往期文章:《vue router 4 源码篇:路由诞生——createRouter原理探索》 《vue router 4 源码篇:路由matcher的前世今生》 《vue router 4 源码篇:router history的原生结合》 《vue router 4 源码篇:导航守卫该如何设计(一)》 《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
image.png

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

代码语言:javascript
复制
history.pushState(
    { myName: 'test', state: { page: 1, index: 2 } }, 
    'div title', 
    '/divPath'
)

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

image.png
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
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类型:

代码语言: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
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;

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

代码语言:typescript
复制
/**
 * 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
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,源码如下:

代码语言:typescript
复制
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中添加页面滚动位置记录,方便页面回退时滚动到原来位置。

代码语言:typescript
复制
/**
 * 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变量。

代码语言:typescript
复制
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
代码语言:typescript
复制
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
image.png

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

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

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

源码解析

popStateHandler
代码语言:typescript
复制
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
代码语言:typescript
复制
function beforeUnloadListener() {
  const { history } = window
  if (!history.state) return
  history.replaceState(
    assign({}, history.state, { scroll: computeScrollPosition() }),
    ''
  )
}

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

3个listener hooks
代码语言:typescript
复制
// 暂停监听
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劫持

代码语言:typescript
复制
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(队列)代替,而监听器也是消费队列完成路由切换。以下是关键源码:

代码语言:typescript
复制
/**
 * 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
}

落幕

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 源码专栏
  • 开场
  • 撰写目的
  • Web History API
    • 举个沉浸式例子
      • 服务器适配
        • 结论
        • createWebHistory
          • 实现原理流程图
            • 创建History
              • 流程图
              • 源码解析
            • 创建路由监听器
              • 流程图
              • 源码解析
            • 添加location和state劫持
            • createWebHashHistory
            • 落幕
            相关产品与服务
            云服务器
            云服务器(Cloud Virtual Machine,CVM)提供安全可靠的弹性计算服务。 您可以实时扩展或缩减计算资源,适应变化的业务需求,并只需按实际使用的资源计费。使用 CVM 可以极大降低您的软硬件采购成本,简化 IT 运维工作。
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档