muwoo / blogs

📚一个前端的博客。
2.32k stars 352 forks source link

vue-router 实现 -- HashHistory #25

Open muwoo opened 6 years ago

muwoo commented 6 years ago

因为我们用的比较多的是 vue 的 HashHistory。下面我们首先来介绍一下 HashHistory。我们知道,通过mode来确定使用 history的方式,如果当前mode = 'hash',则会执行:

this.history = new HashHistory(this, options.base, this.fallback)

this.fallback是用来判断当前mode = 'hash'是不是通过降级处理的:

this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false

接下来我们看看HashHistory的内部实现,首先是看一下 new HashHistory()的时候,实例化做了哪些事:

constructor

// 继承 History 基类
export class HashHistory extends History {
  constructor (router: VueRouter, base: ?string, fallback: boolean) {
    // 调用基类构造器
    super(router, base)

    // 如果说是从 history 模式降级来的
    // 需要做降级检查
    if (fallback && this.checkFallback()) {
      // 如果降级 且 做了降级处理 则什么也不需要做
      return
    }
    // 保证 hash 是以 / 开头
    ensureSlash()
  }
// ...
}

function checkFallback (base) {
    // 得到除去 base 的真正的 location 值
    const location = getLocation(this.base)
    if (!/^\/#/.test(location)) {
      // 如果说此时的地址不是以 /# 开头的
      // 需要做一次降级处理 降级为 hash 模式下应有的 /# 开头
      window.location.replace(
        cleanPath(this.base + '/#' + location)
      )
      return true
    }
}

// 保证 hash 以 / 开头
function ensureSlash (): boolean {
  // 得到 hash 值
  const path = getHash()
  // 如果说是以 / 开头的 直接返回即可
  if (path.charAt(0) === '/') {
    return true
  }
  // 不是的话 需要手工保证一次 替换 hash 值
  replaceHash('/' + path)
  return false
}

export function getHash (): string {
  // 因为兼容性问题 这里没有直接使用 window.location.hash
  // 因为 Firefox decode hash 值
  const href = window.location.href
  const index = href.indexOf('#')
  // 如果此时没有 # 则返回 ''
  // 否则 取得 # 后的所有内容
  return index === -1 ? '' : href.slice(index + 1)
}

可以看到在实例化过程中主要做两件事情:针对于不支持history api 的降级处理,以及保证默认进入的时候对应的 hash 值是以 / 开头的,如果不是则替换。

如果细心点,可以发现这里并没有对 hashchange事件做处理。主要是因为这个问题:beforeEnter fire twice on root path ('/') after async next call

简要来说就是说如果在 beforeEnter 这样的钩子函数中是异步的话,beforeEnter 钩子就会被触发两次,原因是因为在初始化的时候如果此时的 hash 值不是以 / 开头的话就会补上 #/,这个过程会触发hashchange 事件,所以会再走一次生命周期钩子,也就意味着会再次调用 beforeEnter 钩子函数。

transitionTo

还记得 init的时候,有这样的动作:

   if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

如果historyHashHistory 的实例。则调用historytransitionTo方法。调用transitionTo的时候传入了3个参数,第一个是history.getCurrentLocation(),后面的都是setupHashListener。先来看一下getCurrentLocation:

  getCurrentLocation () {
    return getHash()
  }

也就是返回了当前路径。接着是setupHashListener函数,其内部定义了history.setupListeners()的执行。后面我们在具体分析他所做的工作,我们现在只需要明白这几个参数的含义。 接下来我们来看一下transitionTo的实现:

  transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const route = this.router.match(location, this.current)
    this.confirmTransition(route, () => {
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()

      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {
      if (onAbort) {
        onAbort(err)
      }
      if (err && !this.ready) {
        this.ready = true
        this.readyErrorCbs.forEach(cb => { cb(err) })
      }
    })
  }

该函数执行的时候,先去定义了route变量:

const route = this.router.match(location, this.current)

我们知道location代表了当前的 hash 路径。那么this.current又是什么呢?不要着急,我们找到this.current的定义:

export function createRoute (
  record: ?RouteRecord,
  location: Location,
  redirectedFrom?: ?Location,
  router?: VueRouter
): Route {
  const stringifyQuery = router && router.options.stringifyQuery

  let query: any = location.query || {}
  try {
    // 一个深拷贝
    query = clone(query)
  } catch (e) {}

  const route: Route = {
    name: location.name || (record && record.name),
    meta: (record && record.meta) || {},
    path: location.path || '/',
    hash: location.hash || '',
    query,
    params: location.params || {},
    fullPath: getFullPath(location, stringifyQuery),
    matched: record ? formatMatch(record) : []
  }
  if (redirectedFrom) {
    route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery)
  }
  return Object.freeze(route)
}

export const START = createRoute(null, {
  path: '/'
})

this.current = START

this.current就是START,通过createRoute来创建返回。注意返回的是通过Object.freeze定义的只读对象 route。可以简单看一下大致返回的内容可能是这样的:

image

接着,我们会调用this.router.match方法,来获取route对象。来看一下match方法:

  this.matcher = createMatcher(options.routes || [], this)
  match (
    raw: RawLocation,
    current?: Route,
    redirectedFrom?: Location
  ): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

大致能看出来 match函数执行this.macher对象的match方法调用。this.matcher对象通过createMatcher方法返回。看一下this.matcher.match方法:

  function match (
    raw: RawLocation,  // 目标url
    currentRoute?: Route, // 当前url对应的route对象
    redirectedFrom?: Location // 重定向
  ): Route {
    // 解析当前 url,得到 hash、path、query和name等信息
    const location = normalizeLocation(raw, currentRoute, false, router)
    const { name } = location
    // 如果是命名路由
    if (name) {
      //  得到路由记录
      const record = nameMap[name]
      // 不存在记录 返回
      if (!record) return _createRoute(null, location)
      const paramNames = record.regex.keys
        .filter(key => !key.optional)
        .map(key => key.name)

      if (typeof location.params !== 'object') {
        location.params = {}
      }
      // 复制 currentRoute.params 到  location.params
      if (currentRoute && typeof currentRoute.params === 'object') {
        for (const key in currentRoute.params) {
          if (!(key in location.params) && paramNames.indexOf(key) > -1) {
            location.params[key] = currentRoute.params[key]
          }
        }
      }
      // 如果存在 record 记录
      if (record) {
        location.path = fillParams(record.path, location.params, `named route "${name}"`)
        return _createRoute(record, location, redirectedFrom)
      }
    } else if (location.path) {
      // 处理非命名路由
      location.params = {}
       // 这里会遍历pathList,找到合适的record,因此命名路由的record查找效率更高
      for (let i = 0; i < pathList.length; i++) {
        const path = pathList[i]
        const record = pathMap[path]
        if (matchRoute(record.regex, location.path, location.params)) {
          return _createRoute(record, location, redirectedFrom)
        }
      }
    }
    // 没有匹配到的情况
    return _createRoute(null, location)
  }

这里我们可能需要理解一下pathListpathMapnameMap这几个变量。他们是通过createRouteMap来创建的几个对象:

const { pathList, pathMap, nameMap } = createRouteMap(routes)

routes 使我们定义的路由数组,可能是这样的:

const router = new VueRouter({
  mode: 'history',
  base: __dirname,
  routes: [
    { path: '/', name: 'home', component: Home },
    { path: '/foo', name: 'foo', component: Foo },
    { path: '/bar/:id', name: 'bar', component: Bar }
  ]
})

createRouteMap主要作用便是处理传入的routes属性,整理成3个对象:

  1. nameMap nameMap

  2. pathList pathList

  3. pathMap pathMap

所以 match的主要功能是通过目标路径匹配定义的route 数据,根据匹配到的记录,来进行_createRoute操作。而_createRoute会根据RouteRecord执行相关的路由操作,最后返回Route对象:

  function _createRoute (
    record: ?RouteRecord,
    location: Location,
    redirectedFrom?: Location
  ): Route {
    // 重定向
    if (record && record.redirect) {
      return redirect(record, redirectedFrom || location)
    }
    // 别名
    if (record && record.matchAs) {
      return alias(record, location, record.matchAs)
    }
    // 普通路由
    return createRoute(record, location, redirectedFrom, router)
  }

现在我们知道了this.mather.match最终返回的就是Route对象。到这里,我们再回到之前所说的transitionTo方法:

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // 匹配目标url的route对象
    const route = this.router.match(location, this.current)
    // 调用this.confirmTransition,执行路由转换
    this.confirmTransition(route, () => {
      // ...跳转完成
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()
      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => { 
      // ...处理异常
    })
  }
}

得到正确的路由对象route后,我们开始跳转动作confirmTransition。接下来看看confirmTransition的主要操作

confirmTransition

  confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    // 定义中断处理
    const abort = err => {
      // ...
      onAbort && onAbort(err)
    }

    // 同路由且 matched.length 相同
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort()
    }

    const {
      updated,
      deactivated,
      activated
    } = resolveQueue(this.current.matched, route.matched)

    // 整个切换周期的队列
    const queue: Array<?NavigationGuard> = [].concat(
      // 得到即将被销毁组建的 beforeRouteLeave 钩子函数
      extractLeaveGuards(deactivated),
      // 全局 router before hooks
      this.router.beforeHooks,
      // 得到组件 updated 钩子
      extractUpdateHooks(updated),
      // 将要更新的路由的 beforeEnter 钩子
      activated.map(m => m.beforeEnter),
      // 异步组件
      resolveAsyncComponents(activated)
    )

    this.pending = route
    // 每一个队列执行的 iterator 函数
    const iterator = (hook: NavigationGuard, next) => {
       // ...
    }

    // 执行队列 leave 和 beforeEnter 相关钩子
    runQueue(queue, iterator, () => {
       // ...
    })
  }

这里有一个很关键的路由对象的 matched 实例,从上次的分析中可以知道它就是匹配到的路由记录的合集;这里从执行顺序上来看有这些 resolveQueueextractLeaveGuardsextractUpdateHooksresolveAsyncComponentsrunQueue 关键方法。我们先来看看resolveQueue方法:

1. resolveQueue
function resolveQueue (
  current: Array<RouteRecord>,
  next: Array<RouteRecord>
): {
  updated: Array<RouteRecord>,
  activated: Array<RouteRecord>,
  deactivated: Array<RouteRecord>
} {
  let i
  // 取得最大深度
  const max = Math.max(current.length, next.length)
  for (i = 0; i < max; i++) {
    // 如果记录不一样则停止
    if (current[i] !== next[i]) {
      break
    }
  }

  // 分别返回哪些需要更新,哪些需要激活,哪些需要卸载
  return {
    updated: next.slice(0, i),
    activated: next.slice(i),
    deactivated: current.slice(i)
  }
}

可以看出resolveQueue 就是交叉比对当前路由的路由记录和现在的这个路由的路由记录来确定出哪些组件需要更新,哪些需要激活,哪些组件被卸载。再执行其中的对应钩子函数。

2. extractLeaveGuards/extractUpdateHooks
function extractLeaveGuards (deactivated: Array<RouteRecord>): Array<?Function> {
  return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}

function extractGuards (
  records: Array<RouteRecord>,
  name: string,
  bind: Function,
  reverse?: boolean
): Array<?Function> {
  const guards = flatMapComponents(records, (def, instance, match, key) => {
    // 获取组建的 beforeRouteLeave 钩子函数
    const guard = extractGuard(def, name)
    if (guard) {
      return Array.isArray(guard)
        ? guard.map(guard => bind(guard, instance, match, key))
        : bind(guard, instance, match, key)
    }
  })
  return flatten(reverse ? guards.reverse() : guards)
}

function extractGuard (
  def: Object | Function,
  key: string
): NavigationGuard | Array<NavigationGuard> {
  if (typeof def !== 'function') {
    // extend now so that global mixins are applied.
    def = _Vue.extend(def)
  }
  return def.options[key]
}

export function flatMapComponents (
  matched: Array<RouteRecord>,
  fn: Function
): Array<?Function> {
  return flatten(matched.map(m => {
    // 遍历得到组建的 template, instance, macth,和组件名
    return Object.keys(m.components).map(key => fn(
      m.components[key],
      m.instances[key],
      m, key
    ))
  }))
}

// 抹平数组得到一个一维数组
export function flatten (arr: Array<any>): Array<any> {
  return Array.prototype.concat.apply([], arr)
}

总的来说 extractLeaveGuards的功能就是找到即将被销毁的路由组件的beforeRouteLeave钩子函数。处理成一个由深到浅的顺序组合的数组。接下来的extractUpdateHooks函数功能也是类似,主要是处理beforeRouteUpdate钩子函数。这里不再过多介绍了。

function extractUpdateHooks (updated: Array<RouteRecord>): Array<?Function> {
  return extractGuards(updated, 'beforeRouteUpdate', bindGuard)
}
3. resolveAsyncComponents
export function resolveAsyncComponents (matched: Array<RouteRecord>): Function {
  // 返回“异步”钩子函数
  return (to, from, next) => {
    let hasAsync = false
    let pending = 0
    let error = null

    flatMapComponents(matched, (def, _, match, key) => {
      // 这里假定说路由上定义的组件 是函数 但是没有 options
      // 就认为他是一个异步组件。
      // 这里并没有使用 Vue 默认的异步机制的原因是我们希望在得到真正的异步组件之前
      // 整个的路由导航是一直处于挂起状态
      if (typeof def === 'function' && def.cid === undefined) {
        hasAsync = true
        // ...

      }
    })

    if (!hasAsync) next()
  }
}

这里主要是用来处理异步组建的问题,通过判断路由上定义的组件 是函数且没有 options来确定异步组件,然后在得到真正的异步组件之前将其路由挂起。

4. runQueue
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
  const step = index => {
    // 如果全部执行完成则执行回调函数 cb
    if (index >= queue.length) {
      cb()
    } else {
      // 如果存在对应的函数
      if (queue[index]) {
        // 这里的 fn 传过来的是个 iterator 函数
        fn(queue[index], () => {
          // 执行队列中的下一个元素
          step(index + 1)
        })
      } else {
        // 执行队列中的下一个元素
        step(index + 1)
      }
    }
  }
  // 默认执行钩子队列中的第一个数据
  step(0)
}

我们知道在confirmTransition中通过这样的方式来调度队列的执行:

 runQueue(queue, iterator, () => { })

runQueue函数 fn 参数传入了一个iterator函数。接下来我们看看iterator函数的执行:

this.pending = route
const iterator = (hook: NavigationGuard, next) => {
  // 如果当前处理的路由,已经不等于 route 则终止处理
  if (this.pending !== route) {
    return abort()
  }
  try {
    // hook 是queue 中的钩子函数,在这里执行
    hook(route, current, (to: any) => {
      // 钩子函数外部执行的 next 方法
      // next(false): 中断当前的导航。
      // 如果浏览器的 URL 改变了 (可能是用户手动或者浏览器后退按钮)
      // 那么 URL 地址会重置到 from 路由对应的地址。
      if (to === false || isError(to)) {
        this.ensureURL(true)
        abort(to)
      } else if (
        // next('/') 或者 next({ path: '/' }): 跳转到一个不同的地址。
        // 当前的导航被中断,然后进行一个新的导航。
        typeof to === 'string' ||
        (typeof to === 'object' && (
          typeof to.path === 'string' ||
          typeof to.name === 'string'
        ))
      ) {
        // next('/') or next({ path: '/' }) -> redirect
        abort()
        if (typeof to === 'object' && to.replace) {
          this.replace(to)
        } else {
          this.push(to)
        }
      } else {
        // 当前钩子执行完成,移交给下一个钩子函数
        // 注意这里的 next 指的是 runQueue 中传过的执行队列下一个方法函数: step(index + 1)
        next(to)
      }
    })
  } catch (e) {
    abort(e)
  }
}

我们来屡一下现在主要的流程:

  1. 执行transitionTo函数,先得到需要跳转路由的 match 对象route
  2. 执行confirmTransition函数
  3. confirmTransition函数内部判断是否是需要跳转,如果不需要跳转,则直接中断返回
  4. confirmTransition判断如果是需要跳转,则先得到钩子函数的任务队列 queue
  5. 通过 runQueue 函数来批次执行任务队列中的每个方法。
  6. 在执 queue 的钩子函数的时候,通过iterator来构造迭代器由用户传入 next方法,确定执行的过程
  7. 一直到整个队列执行完毕后,开始处理完成后的回调函数。

大致流程便是这样,我们接下来看处理完整个钩子函数队列之后将要执行的回调是什么样的:

runQueue(queue, iterator, () => {
  const postEnterCbs = []
  const isValid = () => this.current === route
  // 获取 beforeRouteEnter 钩子函数
  const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
  // 获取 beforeResolve 钩子函数 并合并生成另一个 queue
  const queue = enterGuards.concat(this.router.resolveHooks)
  runQueue(queue, iterator, () => {
    // 处理完,就不需要再次执行
    if (this.pending !== route) {
      return abort()
    }
    // 清空
    this.pending = null
    // 调用 onComplete 函数
    onComplete(route)
    if (this.router.app) {
      // nextTick 执行 postEnterCbs 所有回调
      this.router.app.$nextTick(() => {
        postEnterCbs.forEach(cb => { cb() })
      })
    }
  })
})

可以看到,处理完整个钩子函数队列之后将要执行的回调主要就是接入路由组件后期的钩子函数beforeRouteEnterbeforeResolve,并进行队列执行。一切处理完成后,开始执行transitionTo的回调函数onComplete

this.confirmTransition(route, () => {
  // 更新 route 
  this.updateRoute(route)
  // 执行 onComplete
  onComplete && onComplete(route)
  // 更新浏览器 url
  this.ensureURL()

  // 调用 ready 的回调
  if (!this.ready) {
    this.ready = true
    this.readyCbs.forEach(cb => { cb(route) })
  }
}, err => {
  // ...
})

updateRoute (route: Route) {
    const prev = this.current
    // 当前路由更新
    this.current = route
    // cb 执行
    this.cb && this.cb(route)
    // 调用 afterEach 钩子
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
}

可以看到,到这里,已经完成了对当前 route 的更新动作。我们之前已经分析了,在 install函数中设置了对route的数据劫持。此时会触发页面的重新渲染过程。还有一点需要注意,在完成路由的更新后,同时执行了onComplete && onComplete(route)。而这个便是在我们之前篇幅中介绍的setupHashListener:

const setupHashListener = () => {
  history.setupListeners()
}
history.transitionTo(
  history.getCurrentLocation(),
  setupHashListener,
  setupHashListener
)

setupListeners () {
  const router = this.router
  // 处理滚动
  const expectScroll = router.options.scrollBehavior
  const supportsScroll = supportsPushState && expectScroll

  if (supportsScroll) {
    setupScroll()
  }
  // 通过 supportsPushState 判断监听popstate 还是 hashchange
  window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {
    const current = this.current
    // 判断路由格式
    if (!ensureSlash()) {
      return
    }
    this.transitionTo(getHash(), route => {
      if (supportsScroll) {
        handleScroll(this.router, route, current, true)
      }
      // 如果不支持 history 模式,则换成 hash 模式
      if (!supportsPushState) {
        replaceHash(route.fullPath)
      }
    })
  })
}

可以看到 setupListeners这里主要做了 2 件事情,一个是对路由切换滚动位置的处理,具体的可以参考这里滚动行为。另一个是对路由变动做了一次监听window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {})

总结

到这里,hash模式下的主要操作便差不多介绍完成了,接下来我们会去介绍history模式。

参考: vue-router 源码分析-history

kreviazuk commented 5 years ago

一脸懵逼的进来,学习中

xuanbabybaby commented 4 years ago

满怀期待的进来,一脸懵圈的出去,,没看懂,,,哭了