诗号:六道同坠,魔劫万千,引渡如来。

/img/bdx/yiyeshu-001.jpg

慎入😢

vue-router-next 源码分析流程图,此文重点在图,附带一些总结性的文字分 析内容(图一般比较大,只保证自己能看懂系列~~~~),学习过程中一些零碎的笔记。

vue-router-next

脑图: /img/vue3/vue-router/vue-router-next-start.svg

简要分析:

vue-router 实现从使用上来说有三个部分:

  1. 路由注册初始化,以 VueRouter.createRoute({history, routes}) 为执行入口

    • 创建匹配器 matcher 路由的一些匹配、添加、查找啊什么的操作都是有这个 matcher 来实现的

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      
      export interface RouterMatcher {
        addRoute: (record: RouteRecordRaw, parent?: RouteRecordMatcher) => () => void;
        removeRoute: {
          (matcher: RouteRecordMatcher): void;
          (name: RouteRecordName): void;
        };
        getRoutes: () => RouteRecordMatcher[];
        getRecordMatcher: (name: RouteRecordName) => RouteRecordMatcher | undefined;
      
        resolve;
      }
      

      而上面的接口操作的无非就是两个路由仓库:

      1
      2
      3
      4
      5
      
      // 这个无论有没有名字的路由记录都会被存储到这个数组中
      const matchers: RouteRecordMatcher[] = [];
      // 这个存储的是带 name 字段的路由 <name, record> 结构
      // 方便直接可以通过 map.get(name) 就可以去到路由记录,减少数组查找消耗
      const matcherMap = new Map<RouteRecordName, RouteRecordMatcher>();
      
    • 初始化路由守卫存储器,其实就是个包含 {list,add,result} 的一个对象

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      
       // 进入之前的的回调列表
       const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>();
       // 解析路由之前的回调列表
       const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>();
       // 进入之后的回调列表
       const afterGuards = useCallbacks<NavigationHookAfter>();
      
       export function useCallbacks<T>() {
         let handlers: T[] = [];
         function add(handler: T): () => void {}
         function reset() {
           handlers = [];
         }
         return {
           add,
           list: () => handlers,
           reset,
         };
       }
      
    • currentRoute 重要变量,是个 shallow ref 响应式类型的值,与 <router-view/> 当前显示的路由息息相关,或者说就是它,因为 RouterView 组件中有间接的监听这个值。

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      
       // RouterView.ts
       // 就是这里的 matchedRouteRef
       watch(
         () => [viewRef.value, matchedRouteRef.value, props.name] as const,
         ([instance, to, name], [oldInstance, from, oldName]) => {
           // ...
         }
       );
      
       // RouterView.ts > 为什么说是间接呢?看下面  matchedRouteRef 的由来
       const injectedRoute = inject(routerViewLocationKey)!;
       const routeToDisplay = computed(() => props.route || injectedRoute.value);
       const depth = inject(viewDepthKey, 0);
       const matchedRouteRef = computed<RouteLocationMatched | undefined>(
         () => routeToDisplay.value.matched[depth]
       );
      
       // router.ts > routerViewLocationKey ???  不记得了吗? router.install... 啊
       app.provide(routerKey, router);
       app.provide(routeLocationKey, reactive(reactiveRoute));
       app.provide(routerViewLocationKey, currentRoute);
      
       // 看到没,关联上了吧!!!
       // app.provide -> currentRoute ->
       // injectedRoute -> routeToDisplay ->
       // routeToDisplay.value.matched[depth]
      
       /*
        并且注意看 ~RouterView~ 组件中 setup最后返回的值是个函数,这个函数中有对
        routeToDisplay, matchedRouteRef进行引用也就是在执行的时候会触发 track 操
        作将它收集到这写值的依赖列表中,只要这些值发生变更就会 trigger这个 setup
        执行,来更新 ~<router-view/>~
       ,*/
      
    • 初始化 router 实例

      包含一些 api :

      路由的增删改查主要来源 matcher: {addRoute, currentRoute, removeRoute, hasRoute, getRoutes, resolv}

      路由的跳转行为: {push, replace, go, back: () => go(-1), forward: () => go(1)}, 这里的 push, replace 函数最终调用的都是 finalizeNavigation() 而 这里面主要有两个关键地方,一是 routerHistory.push/replace, 二是更新了 currentRoute.value 而正是这个更新会触发 <router-view/> 组件的更新。go 是直接使用了 routerHistory.go(delta) 接口

      可以看到,不管是 push/replace 还是 go 最后都是使用了 history 的 api 。

      路由插件的安装函数 install(app/* vue app */), 这里需要注意它做了几件事情:

      注册 RouterLink, RouterView 两个 vue 组件
      定义了全局属性 $route 指向 currentRoute
      provide routerKey -> router 当前 router 实例
      provide routeLocationKey -> reactiveRoute location 相关信息
      provide routerViewLocationKey -> currentRoute 当前路由记录
      重写 vue 组件的 unmount 函数,执行路由的清理工作,比如:移除事件监听,重置路由属性等
  2. <router-view/> 组件的实现原理,通过 <router-link to/>router.push/replace/go api 触发路由跳转动作实现

  3. history 的实现原理(结合 Ref + history hash/H5api),这个对用户是不可见的

vue-router 简要图:

/img/vue3/vue-router/vue-router-next.svg

TODO 守卫函数完整执行流程

/img/vue3/vue-router/vue-router-next-parse-flow.svg

HTML5 history api

api描述
pushState(state, title, url)向历史记录中增加一条记录
replaceState(state, title, url)替换当前记录,不新增记录
back()返回上一条记录,等价于 go(-1)
go(n)跳转到第 n 条记录
forward()等价于 go(1)
onpopstate事件,当且执行 history.back()history.forward()history.go(n) 的时候触发
state记录当前页面的状态信息,在执行 pushState 或 replaceState 之前为 null ,之后为第一个传入的参数,可以在 onpopstate 回调中通过 event.state 取到该信息

changeLocation() 测试。。。,

点击下面的按钮,注意观察 location 变化和 history.length 长度变化!

针对 onpopstate 只有在执行实际跳转动作的时候才会触发,什么是实际跳转动作?

比如:浏览器的后台前进按钮,或者直接手动调用 history.back(), history.go(n), history.forward() 方法触发。

然后 vue-router 中是如何使用 history 实现路由功能的?

createWebHistory

H5 的 history api 封装,返回的结构: RouterHistory 包含以下成员

成员名描述-
base站基地址,会添加到每个 url 前面如: a.com/sub 那么 base 是 /sub
loation当前 history location非原生的 location, 封装之后的: {value: location}
state当前的 history state非原生 history state ,初始值是这个,但后续的值需要函数手动管理
push(to,data?)对应 pushState 操作不会触发 popstate
replace(to,data?)对应 replaceState 操作不会触发 popstate
go(delta, triggerListeners?)调用 history.go(delta)会触发 popstate 事件
listen(callback)用户调用添加的监听函数popstate 触发期间执行
createHref(location)构建 href 地址-
destory()注销 listen() 注册的事件-

Router 中的 back()forward() 分别是调用这里的 go(-1)go(1) 实现。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export function createWebHistory(base?: string): RouterHistory {
  base = normalizeBase(base)

  const historyNavigation = useHistoryStateNavigation(base)
  const historyListeners = useHistoryListeners(
    base,
    historyNavigation.state,
    historyNavigation.location,
    historyNavigation.replace
  )
  function go(delta: number, triggerListeners = true) {
    if (!triggerListeners) historyListeners.pauseListeners()
    history.go(delta)
  }

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

    historyNavigation,
    historyListeners
  )

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

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

  return routerHistory
}
  1. base = normalizeBase(base)

    解析网站基路径

    !base

    ? 无自定义地址首先取 <base href="http://ip:port/path/to" /> 的 href, 取出 /path/to 部分作为 base

    \: 有自定义的时候,加上开头 / 和去掉尾部 / ,如: path/to 变成 /path/to , 或 /path/to/ 变成 /path/to

  2. const historyNavigation = useHistoryStateNavigation(base)

    window.locationwindow.history 进行封装,返回

    {location, state, push, replace} 对象,所以这里重点就是这个函数。

  3. const historyListeners = useHistoryListeners(...)

    history 变更监听器。

  4. go(delta, triggerListeners) 函数

    在调用 history.go(delta) 之前检测是否暂停 history listeners

  5. 组装 routerHistory

    合并 { location: '', base, go, createHref } 和 historyNavigation, historyListeners

  6. 在 routerHistory 上定义两个 getter 属性 location & state

  7. 返回 routerHistory 这个将来会被 createRouter({ history }) 用到

useHistoryStateNavigation(base: string)

解构 window.history, window.location 组装 {location, state, push, replace} 结 构返回。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
function useHistoryStateNavigation(base: string) {
  const { history, location } = window;

  // private variables
  let currentLocation: ValueContainer<HistoryLocation> = {
    value: createCurrentLocation(base, location),
  };
  let historyState: ValueContainer<StateEntry> = { value: history.state };
  // build current history entry as this is a fresh navigation
  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
    );
  }

  function changeLocation(
    to: HistoryLocation,
    state: StateEntry,
    replace: boolean
  ): void {
    // ...
  }

  function replace(to: HistoryLocation, data?: HistoryState) {
    // ...
  }

  function push(to: HistoryLocation, data?: HistoryState) {
    //...
  }

  return {
    location: currentLocation,
    state: historyState,

    push,
    replace,
  };
}
  1. 解析 location { pathname, search, hash } 返回不带域名的的 path

    如:

    http://ip:port/ui/#/a/b/?limit=10&page=1 -> base: /ui/# -> /a/b

    http://ip:port/ui/a/b/?limit=10&page=1 -> base: /ui -> /a/b

    http://ip:port/a/ui/b/?limit=10&page=1 -> base: /ui -> /a/ui/b

    结构: {value: url}

  2. historyState = { value: history.state }

    如果 historyState.value 为空,需要进行初始化 -> changeLocation()

  3. changeLocation(to, state, replace) 函数

  4. replace(to, data?) 函数

  5. push(to, data?) 函数

  6. 最后返回结构 {location: currentLocation, state: historyState, push, replace}

    createCurrentLocation(base: string,location: Location)

location { pathname, search, hash } 加工返回新的 url

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function createCurrentLocation(
  base: string,
  location: Location
): HistoryLocation {
  const { pathname, search, hash } = location
  // allows hash based url
  const hashPos = base.indexOf('#')
  if (hashPos > -1) {
    // prepend the starting slash to hash so the url starts with /#
    let pathFromHash = hash.slice(1)
    if (pathFromHash[0] !== '/') pathFromHash = '/' + pathFromHash
    return stripBase(pathFromHash, '')
  }
  const path = stripBase(pathname, base)
  return path + search + hash
}

函数作用: base 中含有 # 时,直接从 location.hash 中解析出 path。

比如:

base=/ui/#/

url=https://ip:port/ui/#/base/industry/grouping?limit=10&page=1&tradeId=19×=1614652347338

最后解析出来的

path=/base/industry/grouping?limit=10&page=1&tradeId=19×=1614652347338

如果 base 不含 # 直接取出 path 中去掉 base 部分的 url,如:

base=/ui/ -> url=http://ip:port/ui/path/to... 得到 /path/to

如果 base 在 url pathname 的中间,直接返回 pathname 因为这种情况非 base 情况 http://ip:port/path/ui/to 直接返回 /path/ui/to

changeLocation(to,state,replace)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function changeLocation(
  to: HistoryLocation,
  state: StateEntry,
  replace: boolean
): void {
  //
  const hashIndex = base.indexOf("#");
  // to:list -> /base/#/ui/ -> /ui/list
  const url =
    hashIndex > -1
      ? (location.host && document.querySelector("base")
          ? base
          : base.slice(hashIndex)) + to
  // http://ip:port + base + 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 hash 部分将 to 路由组合成 url 调用 history.replace|pushState(state, title, url) 改变 url,同时修改 historyState.value 值。

replace(to, data?)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 替代新的
    // 这个会在 changeLocation 中用来计算 delta 偏移量
    { position: historyState.value.position }
  );

  // 执行 replaceState
  // 取 old historyState 然后设置 new historyState
  changeLocation(to, state, true);
  currentLocation.value = to;
}

push(to, data?)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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/vue-router-next/issues/366
    historyState.value,
    history.state as Partial<StateEntry> | null,
    {
      forward: to,
      scroll: computeScrollPosition(),
    }
  );

  // ...

  // 执行 pushState, 记录 old/new historyState
  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;
}

useHistoryListeners()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function useHistoryListeners(
  base: string,
  historyState: ValueContainer<StateEntry>,
  currentLocation: ValueContainer<HistoryLocation>,
  replace: RouterHistory["replace"]
) {
  // 1. popstate 事件处理句柄
  // 2. pause listeners
  // 3. listen(callback)
  // 4. beforeUnloadListener()
  // 5. destory()
  // 6. add event listenner: popstate + beforeunload
  // setup the listeners and prepare teardown callbacks
  window.addEventListener("popstate", popStateHandler);
  window.addEventListener("beforeunload", beforeUnloadListener);

  // 7. return { pauseListeners, listn, destory }
}

popStateHandler({ state })

因为 history.state 保存了执行跳转是 pushState/replaceState 传入的第一个参数值, 所以可以通过 to/from 上的 state 进行对比得到跳转的方向是 forward 还是 back。

但是 history.state 是实时的,执行完 push/replace 就会发生改变,这里怎么处理这个 问题呢,能让 to&from 状态得以保存?

答. 因为使用 historyState = { value: history.state } 做了个中介, 虽然 history.state 实时变化,但是这个 historyState 是不会的,手动用它来管理 to & from 的前后状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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) {
      currentLocation.value = to
      // 这里 state 是执行路由跳转之后触发了 popstate 事件
      // 去得到的最新状态,对应 to 更新老状态值
      historyState.value = state

      // ignore the popstate and reset the pauseState
      // 暂停?忽略事件重置 pauseState ?
      if (pauseState && pauseState === from) {
        pauseState = null
        return
      }
      // 根据 to & from state 计算出要执行跳转的方向或偏移
      delta = fromState ? state.position - fromState.position : 0
    } else {
      // 没有新状态,直接替换历史记录
      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
    listeners.forEach(listener => {
      listener(currentLocation.value, from, {
        delta,
        type: NavigationType.pop,
        direction: delta
          ? delta > 0
            ? NavigationDirection.forward
            : NavigationDirection.back
          : NavigationDirection.unknown,
      })
    })
  }

pauseListeners()

1
2
3
function pauseListeners() {
  pauseState = currentLocation.value;
}

listen(callback)

纯粹的 add 操作,更新 listeners[] 和对应的移除函数列表 teardowns[]

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 添加监听函数,返回对应的 teardown 函数
  function listen(callback: NavigationCallback) {
    // setup 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
  }

beforeUnloadListener()

整个页面执行卸载之前的事件,发生在 unload 之前。

1
2
3
4
5
6
7
8
function beforeUnloadListener() {
    const { history } = window
    if (!history.state) return
    history.replaceState(
      assign({}, history.state, { scroll: computeScrollPosition() }),
      ''
    )
  }

destroy() 注销事件

1
2
3
4
5
6
function destroy() {
  for (const teardown of teardowns) teardown();
  teardowns = [];
  window.removeEventListener("popstate", popStateHandler);
  window.removeEventListener("beforeunload", beforeUnloadListener);
}

createWebHashHistory

/vue-router-next/src/history/hash.ts

从源码可以看出,该函数是基于 createWebHistory(base) 完成的,也就是说这个也是基 于 history api 完成,只不过在这个基础上对 hash 值进行了情况分析和检测,做了进一 步优化处理。

参数 base,可以函数调用时提供,如果存在 <base href/> 标签会优先取这个标签的 href 值解析出 base 值。

如,函数注释,有以下几种可能情况(如: base=https://example.com/folder)

  1. createWebHashHistory() 无参数

    结果: https://example.com/folder#

  2. createWebHashHistory('/folder/')

    匹配 /folder 成功,结果: https://example.com/folder/#

  3. createWebHashHistory('/folder/#/app')

    中间有 # 符号的:

    匹配 /folder 成功,结果: https://example.com/folder/#/app

  4. createWebHashHistory('other-folder')

    匹配失败,会直接替换,结果: https://example.com/other-folder/#

    不推荐这种,因为它会改变根路径。

  5. 无主机的地址,比如本地文件访问: ///usr/etc/folder/index.html

    createWebHashHistory('/iAmIgnored')

    结果: ///usr/etc/folder/index.html#

    提供的 base 会被忽略。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
export function createWebHashHistory(base?: string): RouterHistory {
  // Make sure this implementation is fine in terms of encoding, specially for IE11
  // for `file://`, directly use the pathname and ignore the base
  // location.pathname contains an initial `/` even at the root: `https://example.com`
  base = location.host ? base || location.pathname + location.search : ''
  // allow the user to provide a `#` in the middle: `/base/#/app`
  if (base.indexOf('#') < 0) base += '#'

  if (__DEV__ && !base.endsWith('#/') && !base.endsWith('#')) {
    warn(
      `A hash base must end with a "#":\n"${base}" should be "${base.replace(
        /#.*$/,
        '#'
      )}".`
    )
  }
  return createWebHistory(base)
}

更多请查看 createWebHistory

TODO createMemoryHistory

通过一个队列来管理路由。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
export function createMemoryHistory(base: string = ''): RouterHistory {
  let listeners: NavigationCallback[] = []
  let queue: HistoryLocation[] = [START]
  let position: number = 0

  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 (let callback of listeners) {
      callback(to, from, info)
    }
  }

  const routerHistory: RouterHistory = {
    // rewritten by Object.defineProperty
    location: START,
    state: {},
    base,
    createHref: createHref.bind(null, base),

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

    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 = []
    },

    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', {
    get: () => queue[position],
  })

  return routerHistory
}