vue-router v5.x 路由导航流程

0 阅读5分钟

vue-router 的 router.push 利用底层函数 pushWithRedirect 来实现,pushWithRedirect最后会利用 navigate 来实现真正的导航。

navigate

做了什么?

拆解路由匹配记录→按『离开→全局前置→更新→进入→解析前』的固定顺序执行各类守卫→全程校验并发导航取消。

  1. 准备阶段:
    声明守卫变量,收集路由记录leavingRecords,updatingRecords,enteringRecords
  2. 守卫执行顺序:
    • 组件离开守卫(beforeRouteLeave),按从子组件到父组件的顺序执行,同时执行路由记录上的 leaveGuards
    • 全局前置守卫(beforeEach) :执行所有注册的全局前置守卫。
    • 组件更新守卫(beforeRouteUpdate) :执行复用组件的更新守卫 ,同时执行路由记录上的 updateGuards
    • 路由独享守卫(beforeEnter)
    • 组件进入守卫(beforeRouteEnter
    • 全局解析守卫(beforeResolve)
  3. 导航校验。
    在每轮守卫执行前,都会插入 canceledNavigationCheck 函数,用于检查导航是否被取消(如并发导航冲突)。
    • 如果是取消,直接返回错误。
    • 其他,继续抛出,给上层捕获。
  4. 错误处理。
    捕获导航过程中的错误,特别是导航取消的情况,进行特殊处理。
  /**
   * 守卫执行
   * @param to 目标路由
   * @param from 当前路由
   * @returns
   */
  function navigate(
    to: RouteLocationNormalized,
    from: RouteLocationNormalizedLoaded
  ): Promise<any> {

    // 声明守卫队列变量
    let guards: Lazy<any>[]

    // 拆解路由记录(离开/更新/进入)
    const [
      leavingRecords, 
      updatingRecords, 
      enteringRecords
    ] = extractChangingRecords(to, from)

    // all components here have been resolved once because we are leaving
    // 提取组件离开守卫(beforeRouteLeave)
    guards = extractComponentsGuards(
      leavingRecords.reverse(),  // 反转:子组件守卫先执行,父组件后执行
      'beforeRouteLeave', // 组件离开守卫
      to,
      from
    )

    // leavingRecords is already reversed
    for (const record of leavingRecords) {
      // leaveGuards 是提前缓存的(在组件挂载 / 路由匹配时注册)
      record.leaveGuards.forEach(guard => {
        guards.push(guardToPromiseFn(guard, to, from))
      })
    }

    const canceledNavigationCheck = checkCanceledNavigationAndReject.bind(
      null,
      to,
      from
    )

    // 将校验函数加入守卫列表
    guards.push(canceledNavigationCheck)

    // run the queue of per route beforeRouteLeave guards
    //    离开守卫(beforeRouteLeave)
    //  → 全局前置(beforeEach)
    //  → 更新守卫(beforeRouteUpdate)
    //  → 路由进入(beforeEnter)
    //  → 组件进入(beforeRouteEnter)
    //  → 全局解析前(beforeResolve)
    return (
      runGuardQueue(guards)
        .then(() => {
          // check global guards beforeEach
          guards = []
          for (const guard of beforeGuards.list()) {
            guards.push(guardToPromiseFn(guard, to, from))
          }
          // 插入并发导航校验(每轮守卫前必加)
          guards.push(canceledNavigationCheck)
          return runGuardQueue(guards)
        })
        .then(() => {
          // check in components beforeRouteUpdate
          // 提取组件内 beforeRouteUpdate 守卫(复用组件的更新守卫)
          guards = extractComponentsGuards(
            updatingRecords,
            'beforeRouteUpdate',
            to,
            from
          )

          for (const record of updatingRecords) {
            record.updateGuards.forEach(guard => {
              guards.push(guardToPromiseFn(guard, to, from))
            })
          }
          // 插入并发导航校验(每轮守卫前必加)
          guards.push(canceledNavigationCheck)

          // run the queue of per route beforeEnter guards
          return runGuardQueue(guards)
        })
        .then(() => {
          // check the route beforeEnter
          guards = []
          for (const record of enteringRecords) {
            // do not trigger beforeEnter on reused views
            if (record.beforeEnter) {
              if (isArray(record.beforeEnter)) {
                for (const beforeEnter of record.beforeEnter)
                  guards.push(guardToPromiseFn(beforeEnter, to, from))
              } else {
                guards.push(guardToPromiseFn(record.beforeEnter, to, from))
              }
            }
          }
          // 插入并发导航校验(每轮守卫前必加)
          guards.push(canceledNavigationCheck)

          // run the queue of per route beforeEnter guards
          return runGuardQueue(guards)
        })
        .then(() => {
          // NOTE: at this point to.matched is normalized and does not contain any () => Promise<Component>

          // clear existing enterCallbacks, these are added by extractComponentsGuards
          // 清空之前的 enterCallbacks(避免重复执行)
          to.matched.forEach(record => (record.enterCallbacks = {}))

          // check in-component beforeRouteEnter
          guards = extractComponentsGuards(
            enteringRecords,
            'beforeRouteEnter',
            to,
            from,
            runWithContext
          )
          // 插入并发导航校验(每轮守卫前必加)
          guards.push(canceledNavigationCheck)

          // run the queue of per route beforeEnter guards
          return runGuardQueue(guards)
        })
        .then(() => {
          // check global guards beforeResolve
          guards = []
          for (const guard of beforeResolveGuards.list()) {
            guards.push(guardToPromiseFn(guard, to, from))
          }
          guards.push(canceledNavigationCheck)

          return runGuardQueue(guards)
        })
        // catch any navigation canceled
        .catch(err =>
          isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED)
            ? err
            : Promise.reject(err)
        )
    )
  }

1、路由调整导航流程示例

http://localhost:5173/yo/dashboard 页面点击按钮跳转至 http://localhost:5173/yo/user

导航守卫流程

  1. 组件内离开守卫 beforeRouteLeave,在导航离开渲染该组件的对应路由时调用。
  2. 全局前置守卫 router.beforeEeach。当一个导航触发时,全局前置守卫按照创建顺序调用。
  3. 路由独享守卫 beforeEnter只在进入路由时触发
  4. 组件内进入守卫 beforeRouteEnter,在渲染该组件的对应路由被验证前调用。
  5. 全局解析守卫 router.beforeResolvenavigate只到这里!)。
  6. 全局后置守卫 router.afterEach

image.png

2、全局路由守卫配置

// 全局前置守卫
router.beforeEach((to, from) => {
  console.log('全局前置守卫', to,from)
  return true
})

// 全局解析守卫
router.beforeResolve((to, from) => {
    console.log('全局解析守卫', to,from)
  return true
})

// 全局后置守卫
router.afterEach((to, from, failure) => {
  if(failure) {
    console.log('全局后置守卫-failure', failure);
    return;
  }
  console.log('全局后置守卫', to,from)
  document.title = to.meta.title ? `Vue3 管理端 | ${to.meta.title}` : `Vue3 管理端`;
})

3、路由独享守卫配置

{
  path: '/user',
  name: 'user',
  component: () => import('@/views/user/UserView.vue'),
  meta: {
    title: '用户管理',
    icon: 'user',
    roles: ['admin']
  },
  children: [ // 嵌套路由
    {
      path: 'lists',
      name: 'user-list',
      component: () => import('@/views/user/UserList.vue'),
      beforeEnter: (to, from) => {
        console.log('user-list独享路由', to,from)
        return true
      }
    },
    {
      path: ':id',
      // name: 'user-detail',
      component: () => import('@/views/user/UserDetail.vue'),
      beforeEnter: (to, from) => {
        console.log('user-detail独享路由', to,from)
        return true
      }
    }
  ],
  beforeEnter: (to, from) => {
    console.log('user-view独享路由', to,from)
    return true
  },
},

4、组件内路由守卫(组合式)

<script setup lang="ts">
defineOptions({
  name: "UserDetail",
  // 路由进入守卫
  beforeRouteEnter(to, from) {
    console.log("user-detail-enter", to, from);
    return true;
  },
  beforeRouteUpdate(to, from) {
    console.log("user-detail-update", to, from);
    return true;
  },
  // 路由离开守卫
  beforeRouteLeave(to, from) {
    console.log("user-detail-leave", to, from);
    return true;
  },
});
</script>
<script lang="ts">
import { defineComponent } from "vue";
import { useRouter } from "vue-router";
export default defineComponent({
  name: "NotFound",
  beforeRouteEnter(to, from) {
    console.log("not-found-enter", to, from);
    return true;
  },
  beforeRouteUpdate(to, from) {
    console.log("not-found-update", to, from);
    return true;
  },
  beforeRouteLeave(to, from) {
    console.log("not-found-leave", to, from);
    return true;
  },
  setup() {
    const router = useRouter();
    const handleClick = () => {
      router.push("/404?from=not-found");
    };
    return {
      handleClick,
    };
  },
});
<script lang="ts">
import { ref } from "vue";
import { onBeforeRouteLeave, onBeforeRouteUpdate } from "vue-router";

interface Project {
  name: string;
  status: string;
  rate: string;
  icon: string;
  iconColor: string;
}

export default {
  name: "ProjectList",
  setup() {
    onBeforeRouteLeave((to, from) => {
      console.log("project-list-leave", to, from);
      return true;
    });
    onBeforeRouteUpdate((to, from) => {
      console.log("project-list-update", to, from);
      return true;
    });
    const projects = ref<Project[]>([]);

    return {
      projects,
    };
  },
};
</script>

extractChangingRecords

/**
 * Split the leaving, updating, and entering records.
 * @internal
 *
 * @param  to - Location we are navigating to 目标
 * @param from - Location we are navigating from 来源
 */
export function extractChangingRecords(
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): [
  leavingRecords: RouteRecordNormalized[],
  updatingRecords: RouteRecordNormalized[],
  enteringRecords: RouteRecordNormalized[],
] {
  const leavingRecords: RouteRecordNormalized[] = []
  const updatingRecords: RouteRecordNormalized[] = []
  const enteringRecords: RouteRecordNormalized[] = []

  const len = Math.max(from.matched.length, to.matched.length)
  for (let i = 0; i < len; i++) {
    const recordFrom = from.matched[i] // 来源记录

    // 来源记录存在
    if (recordFrom) {
      if (to.matched.find(record => isSameRouteRecord(record, recordFrom)))
        // 路径相同,记录为更新记录
        updatingRecords.push(recordFrom)
      // 路径不同,记录为离开记录
      else leavingRecords.push(recordFrom)
    }

    // 目标记录存在
    const recordTo = to.matched[i]
    if (recordTo) {
      // the type doesn't matter because we are comparing per reference
      if (!from.matched.find(record => isSameRouteRecord(record, recordTo))) {
        // 路径不同,记录为进入记录
        enteringRecords.push(recordTo)
      }
    }
  }

  return [leavingRecords, updatingRecords, enteringRecords]
}

extractComponentsGuards

/**
 * 从匹配的路由记录里提取组件内路由守卫
 * @param matched 路由记录
 * @param guardType 守卫类型
 * @param to 目标路由
 * @param from 来源路由
 * @param runWithContext 
 * @returns 
 */
export function extractComponentsGuards(
  matched: RouteRecordNormalized[], // 路由记录
  guardType: GuardType, // 守卫类型
  to: RouteLocationNormalized, // 目标路由
  from: RouteLocationNormalizedLoaded, // 来源路由
  runWithContext: <T>(fn: () => T) => T = fn => fn()
) {
  const guards: Array<() => Promise<void>> = []

  for (const record of matched) {

    // 开发警告:无组件、无子路由
    // if (
    //   __DEV__ &&
    //   !record.components &&
    //   // in the new records, there is no children, only parents
    //   record.children &&
    //   !record.children.length
    // ) {
    //   warn(
    //     `Record with path "${record.path}" is either missing a "component(s)"` +
    //       ` or "children" property.`
    //   )
    // }

    // 遍历路由记录中的组件
    for (const name in record.components) {
      let rawComponent = record.components[name]
      // if (__DEV__) {
      //   // 警告1:组件不是合法对象/函数
      //   if (
      //     !rawComponent ||
      //     (typeof rawComponent !== 'object' &&
      //       typeof rawComponent !== 'function')
      //   ) {
      //     warn(
      //       `Component "${name}" in record with path "${record.path}" is not` +
      //         ` a valid component. Received "${String(rawComponent)}".`
      //     )
      //     // throw to ensure we stop here but warn to ensure the message isn't
      //     // missed by the user
      //     throw new Error('Invalid route component')

      //     // 警告2:异步组件写成 import() 而非 () => import()
      //   } else if ('then' in rawComponent) {
      //     // warn if user wrote import('/component.vue') instead of () =>
      //     // import('./component.vue')
      //     warn(
      //       `Component "${name}" in record with path "${record.path}" is a ` +
      //         `Promise instead of a function that returns a Promise. Did you ` +
      //         `write "import('./MyPage.vue')" instead of ` +
      //         `"() => import('./MyPage.vue')" ? This will break in ` +
      //         `production if not fixed.`
      //     )
      //     const promise = rawComponent
      //     rawComponent = () => promise

      //     // 警告3:误用 defineAsyncComponent 包裹异步组件
      //   } else if (
      //     (rawComponent as any).__asyncLoader &&
      //     // warn only once per component
      //     !(rawComponent as any).__warnedDefineAsync
      //   ) {
      //     ;(rawComponent as any).__warnedDefineAsync = true
      //     warn(
      //       `Component "${name}" in record with path "${record.path}" is defined ` +
      //         `using "defineAsyncComponent()". ` +
      //         `Write "() => import('./MyPage.vue')" instead of ` +
      //         `"defineAsyncComponent(() => import('./MyPage.vue'))".`
      //     )
      //   }
      // }

      // TODO: extract the logic relying on instances into an options-api plugin
      // skip update and leave guards if the route component is not mounted
      // 非 beforeRouteEnter 守卫,且组件未挂载 → 跳过(无实例无法执行)
      if (guardType !== 'beforeRouteEnter' && !record.instances[name]) continue

      // 判断是否为同步组件
      if (isRouteComponent(rawComponent)) {
        // __vccOpts is added by vue-class-component and contain the regular options
        const options: ComponentOptions = (rawComponent as any).__vccOpts || rawComponent

        // 获取组件内路由守卫
        const guard = options[guardType]

        guard &&
          guards.push(
            // 将守卫函数封装为 Promise 格式
            guardToPromiseFn(guard, to, from, record, name, runWithContext)
          )
          
      } else {
        // start requesting the chunk already
        // 执行懒加载函数,开始加载组件
        let componentPromise: Promise<
          RouteComponent | null | undefined | void
        > = (rawComponent as Lazy<RouteComponent>)()

        // 开发环境警告:懒加载函数未返回 Promise
        // if (__DEV__ && !('catch' in componentPromise)) {
        //   warn(
        //     `Component "${name}" in record with path "${record.path}" is a function that does not return a Promise. If you were passing a functional component, make sure to add a "displayName" to the component. This will break in production if not fixed.`
        //   )
        //   componentPromise = Promise.resolve(componentPromise as RouteComponent)
        // }

        guards.push(() =>
          componentPromise.then(resolved => {
            if (!resolved)
              throw new Error(
                `Couldn't resolve component "${name}" at "${record.path}"`
              )
            const resolvedComponent = isESModule(resolved)
              ? resolved.default : resolved
            // keep the resolved module for plugins like data loaders
            record.mods[name] = resolved
            // replace the function with the resolved component
            // cannot be null or undefined because we went into the for loop
            // 替换组件为已加载组件
            record.components![name] = resolvedComponent
            // __vccOpts is added by vue-class-component and contain the regular options
            // 
            const options: ComponentOptions =
              (resolvedComponent as any).__vccOpts || resolvedComponent

            const guard = options[guardType]

            return (
              guard &&
              guardToPromiseFn(guard, to, from, record, name, runWithContext)()
            )
          })
        )
      }
    }
  }

  return guards
}

runGuardQueue

  function runGuardQueue(guards: Lazy<any>[]): Promise<any> {
    // Vue Router 的 beforeEach/beforeEnter/beforeResolve 等守卫被收集为一个数组(guards),需要串行执行
    // 只有前一个守卫通过(Promise resolve),才能执行下一个
    return guards.reduce(
      (promise, guard) => promise.then(() => runWithContext(guard)),
      Promise.resolve()
    )
  }

runWithContext

  function runWithContext<T>(fn: () => T): T {
    //  获取已安装的第一个 Vue 应用实例
    const app: App | undefined = installedApps.values().next().value
    // support Vue < 3.3
    // 兼容逻辑:优先用 Vue 3.3+ 的 app.runWithContext,否则直接执行函数
    return app && typeof app.runWithContext === 'function'
      ? app.runWithContext(fn)
      : fn()
  }

image.png

triggerAfterEach

执行全局后置守卫。

  function triggerAfterEach(
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    failure?: NavigationFailure | void
  ): void {
    // navigation is confirmed, call afterGuards
    // TODO: wrap with error handlers
    afterGuards
      .list()
      .forEach(guard => runWithContext(() => guard(to, from, failure)))
  }

checkCanceledNavigation

  function checkCanceledNavigation(
    to: RouteLocationNormalized,
    from: RouteLocationNormalized
  ): NavigationFailure | void {
    // 全局挂起的路由 ≠ 当前待完成的路由
    // 说明有新的导航请求,当前导航应被取消
    if (pendingLocation !== to) {
      // 创建并返回「导航取消」类型的失败错误
      return createRouterError<NavigationFailure>(
        ErrorTypes.NAVIGATION_CANCELLED,
        {
          from,
          to,
        }
      )
    }
  }

最后

  1. 源码阅读:github.com/hannah-lin-…
  2. 测试示例:github.com/hannah-lin-…