vue-router 源码解析(一)-三种路由对象

基本使用

  • vue-router 支持创建三种模式路由:分别为history、hash以及node宿主环境下的路由
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
]
const router = VueRouter.createRouter({
  history: VueRouter.createWebHashHistory(), // 创建对应的路由对象
  routes,
})

导引

  • 本文核心是介绍三种模式下路由对象的创建,通过它们来操作路由记录改变页面url,但真正交给开发者使用的API,还会在此基础上进行封装,添加路由守卫、动态路由等功能
  • 当读到后面文章时需要明白交给开发者使用的push等API,最终会调用本文中介绍的路由对象的API

创建history路由对象

  • 通过useHistoryStateNavigation返回操作路由相关API
  • 通过useHistoryListeners监听路由变化
  • 最终返回给外部调用
// src/hostory/html5.ts
export function createWebHistory(base?: string): RouterHistory {
  /**
   * 如果没有传入base,则取html文档中<base />,会将域名后第一个斜杠之前的内容全部去除
   * 如果传入了base,若开头没有'/',则添加'/'前缀
   * 最后去掉尾部的'/'
   */
  base = normalizeBase(base)
  // 创建导航信息,以及导航相关api
  const historyNavigation = useHistoryStateNavigation(base)
  // 创建路由改变时的监听、订阅功能
  // 虽然popstate不能监听history.pushState,但不管是通过history.pushState或者通过popstate监听,都只要保证路由信息正确即可,不一定需要监听pushState
  const historyListeners = useHistoryListeners(
    base,
    historyNavigation.state,
    historyNavigation.location,
    historyNavigation.replace
  )

  // 包装history.go API,添加停止触发popstate后的listeners
  function go(delta: number, triggerListeners = true) {
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta) // hisotory.go会触发popstate事件,在popstate事件中触发导航
  }

  // 最终整合返回给外部调用的API
  const routerHistory: RouterHistory = assign(
    {
      // it's overridden right after
      location: '',
      base,
      go,
      createHref: createHref.bind(null, base), // 删除掉 # 之前的所有内容
    },

    historyNavigation,
    historyListeners
  )

  // 通过Object.defineProperty劫持,保证时刻返回最新的路由信息
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => historyNavigation.location.value,
  })

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

  return routerHistory
}

useHistoryStateNavigation 路由相关API

  • 根据当前页面进行初始化
function useHistoryStateNavigation(base: string) {
  const { history, location } = window
  
  // 始终指向当前页面
  const currentLocation: ValueContainer<HistoryLocation> = {
    // 返回去掉base路径后的loaction的url
    value: createCurrentLocation(base, location),
  }
  // 当前页面存储的状态
  const historyState: ValueContainer<StateEntry> = { value: history.state }
  // 初始化页面路由信息
  if (!historyState.value) {
    changeLocation(
      currentLocation.value,
      {
        back: null,
        current: currentLocation.value,
        forward: null,
        // the length is off by one, we need to decrease it
        position: history.length - 1,
        replaced: true,
        // don't add a scroll as the user may have an anchor, and we want
        // scrollBehavior to be triggered without a saved position
        scroll: null,
      },
      true
    )
  }
  
  //...
  
  return {
    location: currentLocation, // 始终指向当前页面地址
    state: historyState, // 始终指向当前页面的状态

    push,
    replace,
  }
  
 }
  • changeLocation 改变页面路由,最终页面路径的改变最后都是调用的该方法
    • 通过调用浏览器的history.pushState|replaceState两个API改变路由路径
    • 如果出错则通过location.replace|assign直接刷新页面
function changeLocation(
    to: HistoryLocation,
    state: StateEntry,
    replace: boolean
  ): void {
    const hashIndex = base.indexOf('#')
    // 拼接完整的url
    const url =
      hashIndex > -1
        ? (location.host && document.querySelector('base')
          ? base
          : base.slice(hashIndex)) + to
        : createBaseLocation() + base + to //createBaseLocation:通过window.location拼接的域名

    try {
      // 调用浏览器相关API
      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.assign() 方法会触发窗口加载并显示指定的 URL 的内容。
      location[replace ? 'replace' : 'assign'](url)
    }
  }
  • push方法
    • 修改上一次路由记录状态,将forward修改为要去到的路由地址,然后再更新路由添加最新记录,过程分别对应方法内的两次changeLocation调用
  function push(to: HistoryLocation, data?: HistoryState) {
    // historyState此时保存的还是旧信息,添加forward指向将要去到的路径,以及保存页面滚动坐标
    const currentState = assign(
      {},
      historyState.value,
      history.state as Partial<StateEntry> | null,
      {
        forward: to,
        scroll: computeScrollPosition(), //返回window.pageXOffset等相关的坐标信息
      }
    )

    // 更新旧路由信息,指向新路由
    changeLocation(currentState.current, currentState, true)

    // 创建新路由信息,buildState中添加back指向之前路径,to为current路径,forward为null
    const state: StateEntry = assign(
      {},
      buildState(currentLocation.value, to, null),
      { position: currentState.position + 1 }, // push添加新记录,所以位置会+1
      data
    )
	// 更新路由
    changeLocation(to, state, false)
    // 更新当前location
    currentLocation.value = to
  }
  • replace方法
    • 因为replace是直接替换路由记录,所以并不用修改上一次路由记录相关指向,只调用一次changeLocation即可
  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  //调用replace
      ),
      data,
      { position: historyState.value.position }
    )

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

useHistoryListeners 路由监听

  • 通过 window.addEventListener(‘popstate’, popStateHandler)进行监听,但实际要收集的监听是交给外部传入的
    • 会在第一次成功导航结束后setupListeners中添加回调
  • 监听浏览器前进后退改变URL、history的back、go、forward方法、window.location改变
// 监听路由变化
function useHistoryListeners(
  base: string,
  historyState: ValueContainer<StateEntry>,
  currentLocation: ValueContainer<HistoryLocation>,
  replace: RouterHistory['replace']
) {
  let listeners: NavigationCallback[] = []
  // 用于统一卸载收集的回调
  let teardowns: Array<() => void> = []
  // 暂停监听,true不会触发收集到的listeners
  let pauseState: HistoryLocation | null = null
	
  const popStateHandler: PopStateListener = ({
    state,
  }: {
    state: StateEntry | null
  }) => {
    // 通过window.location API拿到最新的地址
    const to = createCurrentLocation(base, location)
    // currentLocation还未更新,此时指向上一次地址
    const from: HistoryLocation = currentLocation.value
    const fromState: StateEntry = historyState.value
    let delta = 0
    // 拿到路由变化后对应记录中的state
    if (state) {
      // 更新当前页面路由信息
      currentLocation.value = to
      historyState.value = state

      // ignore the popstate and reset the pauseState
      if (pauseState && pauseState === from) {
        pauseState = null
        return
      }
      // 更新位置信息
      delta = fromState ? state.position - fromState.position : 0
    } else {
      replace(to)
    }

    // 路由记录改变后触发listeners
    // listeners在setupListeners中会添加
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,  // 监听popstate行为
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      })
    })
  }
  // 调用后popstate不触发listeners监听
  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 beforeUnloadListener() {
    const { history } = window
    if (!history.state) return
    // 第三个参数不传,那么就只更新state
    history.replaceState(
      assign({}, history.state, { scroll: computeScrollPosition() }),
      ''
    )
  }
  // 清除监听
  function destroy() {
    for (const teardown of teardowns) teardown()
    teardowns = []
    window.removeEventListener('popstate', popStateHandler)
    window.removeEventListener('beforeunload', beforeUnloadListener)
  }

  window.addEventListener('popstate', popStateHandler)

  // https://developer.chrome.com/blog/page-lifecycle-api/
  // 当浏览器窗口,文档或其资源将要卸载时,会触发beforeunload事件
  window.addEventListener('beforeunload', beforeUnloadListener, {
    passive: true, //表示 listener 永远不会调用 preventDefault()
  })

  return {
    pauseListeners, // 暂停触发本次回调
    listen, // 添加回调
    destroy, // 清除所有监听
  }
}

创建hash路由对象

  • 因为hash路由是更改 # 后的hash值,popstate事件同样可以监听到hash的变化,所以history的方式同样适用hash模式,只针对路径强制添加了# ,其他和history模式一致
export function createWebHashHistory(base?: string): RouterHistory {
  base = location.host ? base || location.pathname + location.search : ''
  // 强制为base基路径添加#,使得后续push时不用再添加#
  if (!base.includes('#')) base += '#'

  if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
    warn(
      `A hash base must end with a "#":\n"${base}" should be "${base.replace(
        /#.*$/,
        '#'
      )}".`
    )
  }
  // 其他和history路由方式一致,调用浏览器的history API去更改hash
  return createWebHistory(base)
}

创建node环境下路由对象

  • 因为node环境下没有window对象,所以采用数组来模拟浏览器的页面栈行为
export function createMemoryHistory(base: string = ''): RouterHistory {
  // 监听器,用于监听使用go API跳转的方式,同样会在后续setupListeners方法中收集跳转新路由的方法
  let listeners: NavigationCallback[] = []
  // 通过数组来模拟页面栈
  let queue: HistoryLocation[] = [START]
  // 当前路由对应的位置
  let position: number = 0
  base = normalizeBase(base)

  // ...
  
  // 返回对应位置的路由记录
  Object.defineProperty(routerHistory, 'location', {
    enumerable: true,
    get: () => queue[position],
  })

  return routerHistory
}
  • 操作路由相关API
export function createMemoryHistory(base: string = ''): RouterHistory {
  let listeners: NavigationCallback[] = []
  let queue: HistoryLocation[] = [START]
  let position: number = 0
  base = normalizeBase(base)
  
  const routerHistory: RouterHistory = {
    // rewritten by Object.defineProperty
    location: START,
    // TODO: should be kept in queue
    state: {},
    base,
    createHref: createHref.bind(null, base),

    replace(to) {
      queue.splice(position--, 1) // 替换栈末尾记录,因为setLocation中会让position+1,所以这里要先-1
      setLocation(to)
    },

    push(to, data?: HistoryState) {
      setLocation(to). // 数组末尾添加记录
    },

    listen(callback) {  // 添加回调,这里用于go API
      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) { // 这里只是更改postion和获取跳转相关记录上下文,实际跳转是setupListeners中添加的回调方法
      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
      // 确保 0 < position < queue.length-1
      position = Math.max(0, Math.min(position + delta, queue.length - 1))
      if (shouldTrigger) {
        triggerListeners(this.location, from, { // 触发setupListeners中添加的回调
          direction,
          delta,
        })
      }
    },
  }
  
  // 最终改变路由栈的方法
  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
      // 删除postions及之后的所有记录
      queue.splice(position)
      queue.push(location)
    }
  }

  // ...

  return routerHistory
}
  • 路由监听
  // 监听器,当调用go API时,会触发其中的callback
  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)
    }
  }

总结一下三种路由

  • history和hash模式,都统一使用浏览器的history.pushState|replaceState更改路由记录
  • 对于node环境下的memory模式,则通过数组来模拟页面栈,通过操作数组元素来获取路由记录
  • 对于页面内容的更改:获取到路由记录中的组件,然后在router-view组件中通过渲染函数进行渲染,当然router-view中会监听路由记录的变化
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值