4. createWebHistory()

1. 整体情况

该方法创建一个 HTML5 history,这是 SPA(Single Page Application, 单页应用程序)的常见 history。

1.1 方法源码

// 文件 history/html5.ts
export function createWebHistory(base?: string): RouterHistory {
  // 规范化 base,兼容 <base> 标签,删除值尾部的 /
  base = normalizeBase(base)

  // 返回一个包含 location, state 属性和 push, replace 方法的对象
  const historyNavigation = useHistoryStateNavigation(base)
  
  // 返回一个包含 pauseListeners, listen, destroy 方法的对象
  const historyListeners = useHistoryListeners(
    base,
    historyNavigation.state,
    historyNavigation.location,
    historyNavigation.replace
  )
  
  function go(delta: number, triggerListeners = true) {
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta)  // 原生方法 window.history.go()
  }

  // 赋值
  const routerHistory: RouterHistory = assign(
    {
      location: '',
      base,
      go,
      createHref: createHref.bind(null, base), // 移除 hash 前的所有字符
    },

    historyNavigation,
    historyListeners
  )

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

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

  return routerHistory
}
// 文件 history/common.ts
export interface RouterHistory {
  readonly base: string              // 附加到每个 url 前的 base path
  readonly location: HistoryLocation // 当前路由
  readonly state: HistoryState       // 当前路由的状态
  push(to: HistoryLocation, data?: HistoryState): void
  replace(to: HistoryLocation, data?: HistoryState): void
  go(delta: number, triggerListeners?: boolean): void
  listen(callback: NavigationCallback): () => void // 路由事件监听器,当导航从 outside 触发时(比如浏览器的前进和后退按钮)
  destroy(): void                                  // 清除路由上所有事件的 listeners
  createHref(location: HistoryLocation): string    // 生成在 anchor tag 里使用的 href
}

1.2 属性来源

根据 createWebHistory() 方法的代码,我们可以知道其返回的 RouterHistory 实例的各个属性的来源。如下:

RouterHistory 接口定义的属性和方法
来源
  1. base 规范化

  2. go

  3. createHref

createWebHistory()

  1. location

  2. state

  3. push()

  4. replace()

useHistoryStateNavigation()

  1. listen

  2. destroy

  3. pauseListeners

useHistoryListeners()

1.3 逻辑分析

createWebHistory() 方法的底层是依赖 Web History API,基于对 Web History API 的理解,关于该方法,需要重点关注以下逻辑:

  1. popstate 事件的处理,以及如何和组件挂钩的

  2. 对 Router History 栈的维护,因为它必然不能直接用原生的 session history stack

  3. 与 Router Matcher 的配合,因为理论上是先匹配找到下标,然后再用原生的 history.go(num) 方法实现路由跳转的

2. 监听 popstate 事件

经过搜索发现,和 popstate 事件相关的逻辑都在 useHistoryListeners() 里。

2.1 useHistoryListeners()

简化后的代码,如下:

// 文件 history/html5.ts
function useHistoryListeners(
    base: string,
    historyState: ValueContainer<StateEntry>,
    currentLocation: ValueContainer<HistoryLocation>,
    replace: RouterHistory['replace']
) {
    ...
    
    window.addEventListener('popstate', popStateHandler)
    window.addEventListener('beforeunload', beforeUnloadListener)

    return {
        pauseListeners,
        listen,
        destroy,
    }
}

2.2 私有方法 popStateHandler

预期是在这里完成的前端视图切换。

// 文件 history/html5.ts
type PopStateListener = (this: Window, ev: PopStateEvent) => any

function useHistoryListeners(
  base,
  historyState,
  currentLocation,
  replace
) {
  let listeners: NavigationCallback[] = []
  let pauseState: HistoryLocation | null = null

  const popStateHandler: PopStateListener = ({
    state,
  }: {
    state: StateEntry | null
  }) => {
    // 1. to 是根据参数 base 和 window.location 现算的
    //    从 window.location 对象创建一个规范化的 history location
    const to = createCurrentLocation(base, location) 
    
    // 2. from, fromState 直接来源于参数
    const from: HistoryLocation = currentLocation.value
    const fromState: StateEntry = historyState.value
    
    let delta = 0
    
    // 3. 判断 popStateEvent.state
    //    若 state 有值则根据 state 和 fromState 的 position 属性计算出 delta
    //    否则就调用 replace(to),来自于参数 RouterHistory['replace']
    if (state) {
      currentLocation.value = to
      historyState.value = state

      // 4. 若 pauseState === from,则忽略 popstate,直接返回
      if (pauseState && pauseState === from) {
        pauseState = null
        return
      }

      delta = fromState ? state.position - fromState.position : 0

    } else {
      replace(to)
    }

    // 5. 依次执行 listeners 里的回调函数
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      })
    })
  }
}

在这个事件监听器函数中,和切换前端视图相关的逻辑就一个,那就是:执行了 listeners 里的回调函数。

其中,listeners 里的回调函数是通过公有方法 listen() 动态 push 的。

// 文件 history/html5.ts
function useHistoryListeners(...) {
  let listeners: NavigationCallback[] = []
  let teardowns: Array<() => void> = []  // teardown, 拆除

  function listen(callback: NavigationCallback) {
    // 设置 listener
    listeners.push(callback)
    
   // teardown 回调
    const teardown = () => {
      const index = listeners.indexOf(callback)
      if (index > -1) listeners.splice(index, 1)  // 原地操作,摘除那一个
    }

    teardowns.push(teardown)
    return teardown
  }

  return {
    listen,
  }
}

接下来我们重点看下 listeners 里的回调函数是什么样子的,以及它们是什么时候存进来的。

2.3 调用方法 listen()

useHistoryListeners() 暴露出去的公有方法 listen(),最终是通过 createWebHistory() 方法暴露出去的,即 RouterHistory 实例的 listen() 方法。

// 文件 history/html5.ts
export function createWebHistory(base?: string): RouterHistory {
  ...
  return routerHistory
}

搜索 routerHistory.listen,结果如下:

// 文件 router.ts
export function createRouter(options: RouterOptions): Router {
  ...
  
  // 将 listener 附加到 history,以触发 navigations
  function setupListeners() {
    ...
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
      ...
      navigate(toLocation, from)
        .catch((error: NavigationFailure | NavigationRedirectError) => { })
        .then((failure: NavigationFailure | void) => { })
        .catch(noop)
    })
  }
  
  ...
  return router
}

也就是说在 createRouter() 时就 push 好了 listeners。接下来,看看它是如何使用参数 delta 以及如何切换前端视图的。

// 形参
listener(currentLocation.value, from, {
  delta,
  type: NavigationType.pop,
  direction: delta
    ? delta > 0
      ? NavigationDirection.forward
      : NavigationDirection.back
    : NavigationDirection.unknown,
})

// 实参
routerHistory.listen((to, _from, info) => {})

routerHistory.listen() 中,和 info.delta 相关的逻辑大部分是在 navigate() 的异常处理里,故暂且忽略。我们重点关注对参数 to 的处理,与之相关的代码如下:

// 文件 router.ts
routerHistory.listen((to, _from, info) => {
  ...

  // 不会是重新定向路由,因为它在 history 中
  const toLocation = resolve(to) as RouteLocationNormalized

  // 1. 如果需要重定向,比如动态路由,比如手动的 hash history(手动更改 url 或调用 history.hash = '#/somewhere'),
  const shouldRedirect = handleRedirectRecord(toLocation)
  if (shouldRedirect) {
    pushWithRedirect(
      assign(shouldRedirect, { replace: true }),
      toLocation
    ).catch(noop)
    return // 直接返回
  }

  // 2. 普通导航
  navigate(toLocation, from)
    .catch((error: NavigationFailure | NavigationRedirectError) => { })
    .then((failure: NavigationFailure | void) => { })
    .catch(noop)
})

至此,在响应 popstate 事件时执行的监听回调中,切换前端视图的任务主要由这两个方法完成:

  1. pushWithRedirect() 重定向导航

  2. navigate() 普通导航

对于这两个方法的介绍,详见 createRouter()

3. 维护 Router History 栈

经过搜索发现,和 history.pushState(), history.replaceState() 方法相关的逻辑都在 useHistoryStateNavigation() 里。

3.1 useHistoryStateNavigation()

简化后的代码,如下:

// 文件 history/html5.ts
function useHistoryStateNavigation(base: string) {
  const { history, location } = window

  const currentLocation: ValueContainer<HistoryLocation> = {
    value: createCurrentLocation(base, location),
  }
  const historyState: ValueContainer<StateEntry> = { value: history.state }

  if (!historyState.value) {
    changeLocation(...)
  }

  // 内部方法
  function changeLocation(to, state, isReplace) { }
  
  function replace(to, data) {
    // 内部调了 changeLocation()
  } 

  function push(to, data) {
    // 内部调了 changeLocation()
  }

  return {
    location: currentLocation,
    state: historyState,

    push,
    replace,
  }
}

4. 路由跳转

经过搜索发现,和 history.back(), history.forward(), history.go() 方法相关的逻辑入口都在 RouterHistory 接口的定义里。

公共逻辑

处理 base 和 href

// src/history/common.ts
// 规范化 base,删除值尾部的 /
export function normalizeBase(base?: string): string { 
  if (!base) {
    if (isBrowser) {
      // 若 base 为空,则会考虑网页里的 <base> 标签 
      const baseEl = document.querySelector('base')
      base = (baseEl && baseEl.getAttribute('href')) || '/'
      base = base.replace(/^\w+:\/\/[^\/]+/, '')  // 去除完整 URL origin
    } else {
      base = '/'
    }
  }

  // 去除前导 /,比如形如 file://
  if (base[0] !== '/' && base[0] !== '#') base = '/' + base

  // 去除尾部 / 以便所有方法都可以通过 `base + fullPath` 来构建 href
  return removeTrailingSlash(base)
}


// 移除 hash 前的所有字符
const BEFORE_HASH_RE = /^[^#]+#/
export function createHref(base: string, location: HistoryLocation): string {
  return base.replace(BEFORE_HASH_RE, '#') + location
}


// src/location.ts
const TRAILING_SLASH_RE = /\/$/
export const removeTrailingSlash = (path: string) => {
  path.replace(TRAILING_SLASH_RE, '')
}

useHistoryStateNavigation(base)

该方法根据 base 返回一个包含 location, state 属性和 push, replace 方法的对象。

// src/history/html5.ts
function useHistoryStateNavigation(base: string) {  
  ...
  return {
    location: currentLocation,
    state: historyState,

    push,
    replace,
  }
}

location 属性相关的逻辑,如下:

// src/history/html5.ts
function useHistoryStateNavigation(base: string) {
  const { location } = window
  
  // 从 window.location 对象创建一个规范化的 history location,
  const currentLocation: ValueContainer<HistoryLocation> = {
    value: createCurrentLocation(base, location),
  }

  return {
    location: currentLocation,
  }
}

// 从 window.location 对象创建一个规范化的 history location
function createCurrentLocation(
  base: string,
  location: Location
): HistoryLocation {
  const { pathname, search, hash } = location
  
  // 如果是 hash,支持形如 #, /#, #/, #!, #!/, /#!/, /folder#end
  const hashPos = base.indexOf('#')
  if (hashPos > -1) {
    let slicePos = hash.includes(base.slice(hashPos))
      ? base.slice(hashPos).length
      : 1
    let pathFromHash = hash.slice(slicePos)
    // prepend the starting slash to hash so the url starts with /#
    if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash
    return stripBase(pathFromHash, '')
  }
  
  // 去掉 location.pathname 开头的 base,不区分大小写
  const path = stripBase(pathname, base) 
  return path + search + hash
}


// src/location.ts
// 去掉 location.pathname 开头的 base,不区分大小写
export function stripBase(pathname: string, base: string): string {
  if (!base || !pathname.toLowerCase().startsWith(base.toLowerCase()))
    return pathname
  return pathname.slice(base.length) || '/'
}

state 属性相关的逻辑,如下:

// src/history/html5.ts
function useHistoryStateNavigation(base: string) {
  const { history } = window
  const historyState: ValueContainer<StateEntry> = { value: history.state }
  
  return {
    state: historyState,
  }
}

其中,当 state 的值为 false 时,会调用 changeLocation 方法。该方法比较重要,因为 pushreplace 方法的实现也用到了它。

// src/history/html5.ts
function useHistoryStateNavigation(base: string) {

  // 构建当前 history 条目因为这是一个全新的导航
  if (!historyState.value) {
    changeLocation(
      currentLocation.value,
      {
        back: null,
        current: currentLocation.value,
        forward: null,
        position: history.length - 1, // length-1
        replaced: true,
        scroll: null,
      },
      true
    )
  }
  
  function changeLocation(
    to: HistoryLocation,
    state: StateEntry,
    replace: boolean
  ): void {
    let createBaseLocation = () => location.protocol + '//' + location.host
   
    const hashIndex = base.indexOf('#')
    const url =
      hashIndex > -1
        ? (location.host && document.querySelector('base')
            ? base
            : base.slice(hashIndex)) + to
        : createBaseLocation() + base + to

    try {
      // BROWSER QUIRK
      // Safari 会抛出 SecurityError 当在30s内调用该函数100次时
      history[replace ? 'replaceState' : 'pushState'](state, '', url)
      historyState.value = state
    } catch (err) {
      if (__DEV__) {
        warn('Error with push/replace State', err)
      } else {
        console.error(err)
      }
      // 强制导航,也会重置 call count
      location[replace ? 'replace' : 'assign'](url)
    }
  }
 
}

附录

相关的 typeinterface 定义

state 相关

// 文件 history/common.ts
// HTML5 history state 中允许的变量的 value,注意没有 symbol 和 function
export type HistoryStateValue =
  | string
  | number
  | boolean
  | null
  | undefined
  | HistoryState
  | HistoryStateArray

// state 的 key 不能是 symbol
export interface HistoryState {
  [x: number]: HistoryStateValue
  [x: string]: HistoryStateValue
}

export interface HistoryStateArray extends Array<HistoryStateValue> {}
// 文件 history/html5.ts
interface StateEntry extends HistoryState {
  back: HistoryLocation | null
  current: HistoryLocation
  forward: HistoryLocation | null
  position: number
  replaced: boolean
  scroll: _ScrollPositionNormalized | null | false
}

// 文件 history/common.ts
export type ValueContainer<T> = { value: T }

export type HistoryLocation = string

相关的 interface 定义如下:

// 文件 history/common.ts
export interface NavigationCallback {
  (
    to: HistoryLocation,
    from: HistoryLocation,
    information: NavigationInformation
  ): void
}

export interface NavigationInformation {
  type: NavigationType
  direction: NavigationDirection
  delta: number
}

export enum NavigationType {
  pop = 'pop',
  push = 'push',
}

export enum NavigationDirection {
  back = 'back',
  forward = 'forward',
  unknown = '',
}

Last updated