2
本文基于vue-router 4.1.6版本源码进行分析

前言

在上一篇《Vue3相关源码-Vue Router源码解析(一)》文章中,我们已经分析了createWebHashHistory()createRouter()的相关内容,本文将继续下一个知识点app.use(router)展示分析

// 1. 定义路由组件.
// 也可以从其他文件导入
const Home = { template: '<div>Home</div>' }
const About = { template: '<div>About</div>' }

// 2. 定义一些路由
// 每个路由都需要映射到一个组件。
// 我们后面再讨论嵌套路由。
const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About },
]

// 3. 创建路由实例并传递 `routes` 配置
// 你可以在这里输入更多的配置,但我们在这里
// 暂时保持简单
const router = VueRouter.createRouter({
  // 4. 内部提供了 history 模式的实现。为了简单起见,我们在这里使用 hash 模式。
  history: VueRouter.createWebHashHistory(),
  routes, // `routes: routes` 的缩写
})

// 5. 创建并挂载根实例
const app = Vue.createApp({})
//确保 _use_ 路由实例使
//整个应用支持路由。
app.use(router)

app.mount('#app')

初始化

app.use(router)使用VueRouter

use(plugin, ...options) {
  if (plugin && isFunction(plugin.install)) {
    installedPlugins.add(plugin);
    plugin.install(app, ...options);
  }
  else if (isFunction(plugin)) {
    installedPlugins.add(plugin);
    plugin(app, ...options);
  }
  return app;
}

router.install(app)

从上面Vue3的源码可以知道,最终会触发Vue Routerinstall()方法

const START_LOCATION_NORMALIZED = {
  path: '/',
  name: undefined,
  params: {},
  query: {},
  hash: '',
  fullPath: '/',
  matched: [],
  meta: {},
  redirectedFrom: undefined,
};
const currentRoute = vue.shallowRef(START_LOCATION_NORMALIZED);
const router = {
  //....
  addRoute,
  removeRoute,
  push,
  replace,
  beforeEach: beforeGuards.add,
  isReady,
  install(app) {
    //...省略,外部app.use()时调用
  },
};

如下面代码块所示,主要执行了:

  • 注册了RouterLinkRouterView两个组件
  • 注册routerVue全局对象,保证this.$router能注入到每一个子组件中
  • push(routerHistory.location):初始化时触发push()操作(局部作用域下的方法)
  • provide: routerreactiveRoute(本质是currentRoutereactive结构模式)、currentRoute
install(app) {
    const router = this;
    app.component('RouterLink', RouterLink);
    app.component('RouterView', RouterView);
    app.config.globalProperties.$router = router;
    Object.defineProperty(app.config.globalProperties, '$route', {
        enumerable: true,
        get: () => vue.unref(currentRoute), // 自动解构Ref,拿出.value
    });

    if (isBrowser &&
        // used for the initial navigation client side to avoid pushing
        // multiple times when the router is used in multiple apps
        !started &&
        currentRoute.value === START_LOCATION_NORMALIZED) {
        // see above
        started = true;
        push(routerHistory.location).catch(err => {
            warn('Unexpected error when starting the router:', err);
        });
    }
    const reactiveRoute = {};
    for (const key in START_LOCATION_NORMALIZED) {
        reactiveRoute[key] = vue.computed(() => currentRoute.value[key]);
    }
    app.provide(routerKey, router); // 如上面代码块所示
    app.provide(routeLocationKey, vue.reactive(reactiveRoute));
    app.provide(routerViewLocationKey, currentRoute); // 如上面代码块所示

    // 省略Vue.unmount的一些逻辑处理...
}
Vue Router整体初始化的流程已经分析完毕,一些基础的API,如router.push等也在初始化过程中分析,因此下面将分析Vue Router提供的自定义组件以及组合式API的内容

组件

RouterView

我们使用代码改变路由时,也在改变<router-view></router-view>Component内容,实现组件渲染
<script src="https://unpkg.com/vue@3"></script>
<script src="https://unpkg.com/vue-router@4"></script>

<div id="app">
  <h1>Hello App!</h1>
  <p>
    <!--使用 router-link 组件进行导航 -->
    <!--通过传递 `to` 来指定链接 -->
    <!--`<router-link>` 将呈现一个带有正确 `href` 属性的 `<a>` 标签-->
    <router-link to="/">Go to Home</router-link>
    <router-link to="/about">Go to About</router-link>
  </p>
  <!-- 路由出口 -->
  <!-- 路由匹配到的组件将渲染在这里 -->
  <router-view></router-view>
</div>

<router-view></router-view>就是一个Vue Component

<router-view>代码量还是挺多的,因此下面将切割为2个部分进行分析
const RouterViewImpl = vue.defineComponent({
    setup(props, { attrs, slots }) {
        //========= 第1部分 =============
        const injectedRoute = vue.inject(routerViewLocationKey);
        const routeToDisplay = vue.computed(() => props.route || injectedRoute.value);
        const injectedDepth = vue.inject(viewDepthKey, 0);
        const depth = vue.computed(() => {
            let initialDepth = vue.unref(injectedDepth);
            const { matched } = routeToDisplay.value;
            let matchedRoute;
            while ((matchedRoute = matched[initialDepth]) &&
                !matchedRoute.components) {
                initialDepth++;
            }
            return initialDepth;
        });
        const matchedRouteRef = vue.computed(() => routeToDisplay.value.matched[depth.value]);
        vue.provide(viewDepthKey, vue.computed(() => depth.value + 1));
        vue.provide(matchedRouteKey, matchedRouteRef);
        vue.provide(routerViewLocationKey, routeToDisplay);

        const viewRef = vue.ref();
        vue.watch(() => [viewRef.value, matchedRouteRef.value, props.name], ([instance, to, name], [oldInstance, from, oldName]) => {
            if (to) {
                to.instances[name] = instance;
                if (from && from !== to && instance && instance === oldInstance) {
                    if (!to.leaveGuards.size) {
                        to.leaveGuards = from.leaveGuards;
                    }
                    if (!to.updateGuards.size) {
                        to.updateGuards = from.updateGuards;
                    }
                }
            }
            if (instance &&
                to &&
                (!from || !isSameRouteRecord(to, from) || !oldInstance)) {
                (to.enterCallbacks[name] || []).forEach(callback => callback(instance));
            }
        }, { flush: 'post' });
        return () => {
            //========= 第2部分 =============
            //...
        };
    },
});

第1部分:初始化变量以及初始化响应式变化监听

routerViewLocationKey拿到目前的路由injectedRoute
// <router-view></router-view>代码
const injectedRoute = vue.inject(routerViewLocationKey);

app.use(router)的源码中,我们可以知道,routerViewLocationKey代表的是目前的currentRoute,每次路由变化时,会触发finalizeNavigation(),同时更新currentRoute.value=toLocation
因此currentRoute代表的就是目前最新的路由

install(app) {
    //...
    const router = this;
    const reactiveRoute = {};
    for (const key in START_LOCATION_NORMALIZED) {
        reactiveRoute[key] = vue.computed(() => currentRoute.value[key]);
    }
    app.provide(routerKey, router); // 如上面代码块所示
    app.provide(routeLocationKey, vue.reactive(reactiveRoute));
    app.provide(routerViewLocationKey, currentRoute); // 如上面代码块所示
    // 省略Vue.unmount的一些逻辑处理...
}
const START_LOCATION_NORMALIZED = {
    path: '/',
    name: undefined,
    params: {},
    query: {},
    hash: '',
    fullPath: '/',
    matched: [],
    meta: {},
    redirectedFrom: undefined,
};
function createRouter(options) {
  const currentRoute = vue.shallowRef(START_LOCATION_NORMALIZED);
}
function finalizeNavigation(toLocation, from, isPush, replace, data) {
    //...
    currentRoute.value = toLocation;
    //...
}
routeToDisplay实时同步为目前最新要跳转的路由

使用computed监听路由变化,优先获取props.route,如果有发生路由跳转现象,则routeToDisplay会动态变化

// <router-view></router-view>代码
const routeToDisplay = vue.computed(() => props.route || injectedRoute.value);
depth实时同步为目前要跳转的路由对应的matched数组的index

当路由发生变化时,会触发routeToDisplay发生变化,depth使用computed监听routeToDisplay变化

const depth = vue.computed(() => {
    let initialDepth = vue.unref(injectedDepth);
    const { matched } = routeToDisplay.value;
    let matchedRoute;
    while ((matchedRoute = matched[initialDepth]) &&
        !matchedRoute.components) {
        initialDepth++;
    }
    return initialDepth;
});
Vue Router是如何利用depth来不断加载目前路由路径上所有的Component的呢?

如下面代码块所示,我们在push()->resolve()的流程中,会收集当前路由的所有parent

举个例子,路径path="/child1/child2",那么我们拿到的matched就是
[{path: '/child1', Component: parent}, {path: '/child1/child2', Component: son}]
function push(to) {
    return pushWithRedirect(to);
}
function pushWithRedirect(to, redirectedFrom) {
  const targetLocation = (pendingLocation = resolve(to));
  //...
}
function resolve(location, currentLocation) {
    //....
    const matched = [];
    let parentMatcher = matcher;
    while (parentMatcher) {
        // reversed order so parents are at the beginning
        matched.unshift(parentMatcher.record);
        parentMatcher = parentMatcher.parent;
    }
    return {matched, ...}
}

当我们跳转到path="/child1/child2"时,会首先加载path="/child1"对应的Child1组件,此时injectedDepth=0,在depthcomputed()计算中,得出initialDepth=0,因为matchedRoute.components是存在的,无法进行initialDepth++
因此此时<router-view>组件拿的数据是matched[0]={path: '/child1', Component: parent}

[{path: '/child1', Component: parent}, {path: '/child1/child2', Component: son}]
// Child1组件
<div>目前路由是Child1</div>
<router-view></router-view>

const injectedDepth = vue.inject(viewDepthKey, 0);
const depth = vue.computed(() => {
    let initialDepth = vue.unref(injectedDepth);
    const { matched } = routeToDisplay.value;
    let matchedRoute;
    while ((matchedRoute = matched[initialDepth]) &&
        !matchedRoute.components) {
        initialDepth++;
    }
    return initialDepth;
});
vue.provide(viewDepthKey, vue.computed(() => depth.value + 1));

此时Child1组件仍然有<router-view></router-view>组件,因此我们再次初始化一次RouterView,加载path="/child1/child2"对应的Child2组件
在上面的代码我们可以知道,viewDepthKey会变为depth.value + 1,因此此时<router-view>injectedDepth=1,在depthcomputed()计算中,得出initialDepth=1,因为matchedRoute.components是存在的,无法进行initialDepth++

从这里我们可以轻易猜测出,如果路径上有一个片段,比如path='/child1/child2/child3'child2没有对应的components,那么就会跳过这个child2,直接渲染child1child3对应的组件

因此此时<router-view>组件拿的数据是matched[1]={path: '/child1/child2', Component: son}

// Child2组件
<div>目前路由是Child2</div>

const injectedDepth = vue.inject(viewDepthKey, 0);
const depth = vue.computed(() => {
    let initialDepth = vue.unref(injectedDepth);
    const { matched } = routeToDisplay.value;
    let matchedRoute;
    while ((matchedRoute = matched[initialDepth]) &&
        !matchedRoute.components) {
        initialDepth++;
    }
    return initialDepth;
});
vue.provide(viewDepthKey, vue.computed(() => depth.value + 1));
matchedRouteRef实时同步为目前最新要跳转的路由对应的matcher
// <router-view></router-view>代码
const matchedRouteRef = vue.computed(() => routeToDisplay.value.matched[depth.value]);
更新组件示例,触发beforeRouteEnter回调
const viewRef = vue.ref();
vue.watch(() => [viewRef.value, matchedRouteRef.value, props.name], ([instance, to, name], [oldInstance, from, oldName]) => {
    if (to) {
        to.instances[name] = instance;
        if (from && from !== to && instance && instance === oldInstance) {
            if (!to.leaveGuards.size) {
                to.leaveGuards = from.leaveGuards;
            }
            if (!to.updateGuards.size) {
                to.updateGuards = from.updateGuards;
            }
        }
    }
    // trigger beforeRouteEnter next callbacks
    if (instance &&
        to &&
        (!from || !isSameRouteRecord(to, from) || !oldInstance)) {
        (to.enterCallbacks[name] || []).forEach(callback => callback(instance));
    }
}, { flush: 'post' });
viewRef.value是在哪里赋值的呢?请看下面组件渲染的分析

第2部分:组件渲染

setup()中监听routeToDisplay变化触发组件重新渲染

当响应式数据发生变化时,会触发setup()重新渲染,调用vue.h进行新的Component的渲染更新,此时的viewRef.value通过vue.h赋值

const RouterViewImpl = /*#__PURE__*/ vue.defineComponent({
    name: 'RouterView',
    setup() {

        const viewRef = vue.ref();
        return () => {
            const route = routeToDisplay.value;
            const currentName = props.name; //默认值为"default"
            const matchedRoute = matchedRouteRef.value;
            const ViewComponent = matchedRoute && matchedRoute.components[currentName];
            const component = vue.h(ViewComponent, assign({}, routeProps, attrs, {
                onVnodeUnmounted,
                ref: viewRef,
            }));
            //...省略了routeProps和onVnodeUnmounted相关代码逻辑
            return (
                // pass the vnode to the slot as a prop.
                // h and <component :is="..."> both accept vnodes
                normalizeSlot(slots.default, { Component: component, route }) ||
                component);
        }
    }
});

RouterLink

从下面的代码块可以知道,整个组件本质就是使用useLink()封装的一系列方法,然后渲染一个"a"标签,上面携带了点击事件、要跳转的href、样式等等

因此这个组件的核心内容就是分析useLink()到底封装了什么方法,这个部分我们接下来会进行详细的分析
const RouterLinkImpl = /*#__PURE__*/ vue.defineComponent({
    name: 'RouterLink',
    props: {
        to: {
            type: [String, Object],
            required: true,
        },
        replace: Boolean,
        //...
    },
    useLink,
    setup(props, { slots }) {
        const link = vue.reactive(useLink(props));
        const { options } = vue.inject(routerKey);
        const elClass = vue.computed(() => ({
            [getLinkClass(props.activeClass, options.linkActiveClass, 'router-link-active')]: link.isActive,
            [getLinkClass(props.exactActiveClass, options.linkExactActiveClass, 'router-link-exact-active')]: link.isExactActive,
        }));
        return () => {
            const children = slots.default && slots.default(link);
            return props.custom
                ? children
                : vue.h('a', {
                    'aria-current': link.isExactActive
                        ? props.ariaCurrentValue
                        : null,
                    href: link.href,
                    onClick: link.navigate,
                    class: elClass.value,
                }, children);
        };
    },
});

组合式API

onBeforeRouteLeave

在上面app.use(router)的分析中,我们可以知道,一开始就会初始化RouterView组件,而在初始化时,我们会进行vue.provide(matchedRouteKey, matchedRouteRef),将目前匹配路由的matcher放入到key:matchedRouteKey

install(app) {
    const router = this;
    app.component('RouterLink', RouterLink);
    app.component('RouterView', RouterView);
    //...
}
const RouterViewImpl = /*#__PURE__*/ vue.defineComponent({
    name: 'RouterView',
    //...
    setup(props, { attrs, slots }) {
        const routeToDisplay = vue.computed(() => props.route || injectedRoute.value);
        const matchedRouteRef = vue.computed(() => routeToDisplay.value.matched[depth.value]);
        vue.provide(viewDepthKey, vue.computed(() => depth.value + 1));
        vue.provide(matchedRouteKey, matchedRouteRef);

        return () => {
            //...
        }
    }
})

onBeforeRouteLeave()中,我们拿到的activeRecord就是目前路由对应的matcher,然后将外部传入的leaveGuard放入到我们的matcher["leaveGuard"]中,在路由跳转的navigate()方法中进行调用

function onBeforeRouteLeave(leaveGuard) {
    if (!vue.getCurrentInstance()) {
        warn('getCurrentInstance() returned null. onBeforeRouteLeave() must be called at the top of a setup function');
        return;
    }
    const activeRecord = vue.inject(matchedRouteKey,
        // to avoid warning
        {}).value;
    if (!activeRecord) {
        warn('No active route record was found when calling `onBeforeRouteLeave()`. Make sure you call this function inside a component child of <router-view>. Maybe you called it inside of App.vue?');
        return;
    }
    registerGuard(activeRecord, 'leaveGuards', leaveGuard);
}
function registerGuard(record, name, guard) {
    const removeFromList = () => {
        record[name].delete(guard);
    };
    vue.onUnmounted(removeFromList);
    vue.onDeactivated(removeFromList);
    vue.onActivated(() => {
        record[name].add(guard);
    });
    record[name].add(guard);
}

onBeforeRouteUpdate

跟上面分析的onBeforeRouteLeave一模一样的流程,拿到目前路由对应的matcher,然后将外部传入的updateGuards放入到我们的matcher["updateGuards"]中,在路由跳转的navigate()方法中进行调用

function onBeforeRouteUpdate(updateGuard) {
    if (!vue.getCurrentInstance()) {
        warn('getCurrentInstance() returned null. onBeforeRouteUpdate() must be called at the top of a setup function');
        return;
    }
    const activeRecord = vue.inject(matchedRouteKey,
        // to avoid warning
        {}).value;
    if (!activeRecord) {
        warn('No active route record was found when calling `onBeforeRouteUpdate()`. Make sure you call this function inside a component child of <router-view>. Maybe you called it inside of App.vue?');
        return;
    }
    registerGuard(activeRecord, 'updateGuards', updateGuard);
}
function registerGuard(record, name, guard) {
    const removeFromList = () => {
        record[name].delete(guard);
    };
    vue.onUnmounted(removeFromList);
    vue.onDeactivated(removeFromList);
    vue.onActivated(() => {
        record[name].add(guard);
    });
    record[name].add(guard);
}

useRouter

在之前的分析app.use(router)中,我们可以知道,我们将当前的router使用provide进行存储,即app.provide(routerKey, router);

install(app) {
    //...
    const router = this;
    const reactiveRoute = {};
    for (const key in START_LOCATION_NORMALIZED) {
        reactiveRoute[key] = vue.computed(() => currentRoute.value[key]);
    }
    app.provide(routerKey, router); // 如上面代码块所示
    app.provide(routeLocationKey, vue.reactive(reactiveRoute));
    app.provide(routerViewLocationKey, currentRoute); // 如上面代码块所示
    // 省略Vue.unmount的一些逻辑处理...
}

因此useRouter()本质就是拿到目前Vue使用Vue Router示例

function useRouter() {
  return vue.inject(routerKey);
}

useRoute

从上面useRouter()的分析中,我们可以知道,routeLocationKey对应的就是当前路由currentRoute封装的响应式对象reactiveRoute
因此const route = useRoute()代表的就是当前路由的响应式对象

function useRoute() {
  return vue.inject(routeLocationKey);
}

useLink

Vue RouterRouterLink的内部行为作为一个组合式 API 函数公开。它提供了与 v-slotAPI 相同的访问属性:

import { RouterLink, useLink } from 'vue-router'
import { computed } from 'vue'

export default {
  name: 'AppLink',

  props: {
    // 如果使用 TypeScript,请添加 @ts-ignore
    ...RouterLink.props,
    inactiveClass: String,
  },

  setup(props) {
    const { route, href, isActive, isExactActive, navigate } = useLink(props)

    const isExternalLink = computed(
      () => typeof props.to === 'string' && props.to.startsWith('http')
    )

    return { isExternalLink, href, navigate, isActive }
  },
}
RouterLink组件提供了足够的 props 来满足大多数基本应用程序的需求,但它并未尝试涵盖所有可能的用例,在某些高级情况下,你可能会发现自己使用了v-slot。在大多数中型到大型应用程序中,值得创建一个(如果不是多个)自定义 RouterLink 组件,以在整个应用程序中重用它们。例如导航菜单中的链接,处理外部链接,添加 inactive-class
useLink()是为了扩展RouterLink而服务的

从下面的代码块可以知道,userLink()主要提供了

  • route: 获取props.to进行router.resolve(),拿到要跳转的新路由的route对象
  • href: 监听route.value.href
  • isActive: 当前路由与props.to部分匹配
  • isExactActive: 当前路由与props.to完全精准匹配
  • navigate(): 跳转方法,实际还是调用router.push(props.to)/router.replace(props.to)
function useLink(props) {
    const router = vue.inject(routerKey);
    const currentRoute = vue.inject(routeLocationKey);
    const route = vue.computed(() => router.resolve(vue.unref(props.to)));
    const activeRecordIndex = vue.computed(() => {
        //...
    });
    const isActive = vue.computed(() => activeRecordIndex.value > -1 &&
        includesParams(currentRoute.params, route.value.params));
    const isExactActive = vue.computed(() => activeRecordIndex.value > -1 &&
        activeRecordIndex.value === currentRoute.matched.length - 1 &&
        isSameRouteLocationParams(currentRoute.params, route.value.params));
    function navigate(e = {}) {
        if (guardEvent(e)) {
            return router[vue.unref(props.replace) ? 'replace' : 'push'](vue.unref(props.to)
                // avoid uncaught errors are they are logged anyway
            ).catch(noop);
        }
        return Promise.resolve();
    }
    return {
        route,
        href: vue.computed(() => route.value.href),
        isActive,
        isExactActive,
        navigate,
    };
}

其中activeRecordIndex的逻辑比较复杂,我们摘出来单独分析下

const activeRecordIndex = vue.computed(() => {
    const { matched } = route.value;
    const { length } = matched;
    const routeMatched = matched[length - 1];
    const currentMatched = currentRoute.matched;
    if (!routeMatched || !currentMatched.length)
        return -1;
    const index = currentMatched.findIndex(isSameRouteRecord.bind(null, routeMatched));
    if (index > -1)
        return index;
    const parentRecordPath = getOriginalPath(matched[length - 2]);
    return (
        length > 1 &&
            getOriginalPath(routeMatched) === parentRecordPath &&
            currentMatched[currentMatched.length - 1].path !== parentRecordPath
            ? currentMatched.findIndex(isSameRouteRecord.bind(null, matched[length - 2]))
            : index);
});
直接看上面的代码可能有些懵,直接去源码中找对应位置提交的git记录

截屏2023-03-22 00.21.25.png
RouterLink.spec.ts中可以发现增加以下这段代码

it('empty path child is active as if it was the parent when on adjacent child', async () => {
  const { wrapper } = await factory(
    locations.child.normalized,
    { to: locations.childEmpty.string },
    locations.childEmpty.normalized
  )
  expect(wrapper.find('a')!.classes()).toContain('router-link-active')
  expect(wrapper.find('a')!.classes()).not.toContain(
    'router-link-exact-active'
  )
})

RouterLink.spec.ts拿到locations.childlocations.childEmpty的值,如下所示

child: {
    string: '/parent/child',
    normalized: {
        fullPath: '/parent/child',
        href: '/parent/child',
        matched: [records.parent, records.child]
    }
}
childEmpty: {
    string: '/parent',
    normalized: {
        fullPath: '/parent',
        href: '/parent',
        matched: [records.parent, records.childEmpty]
    }
}

上面实际就是进行/parent/child->/parent的跳转,并且childEmpty对应的/parent还有两个matched元素,说明它的子路由的path=""
结合上面router-link-activerouter-link-exact-active/parent/child->/parent出现的关键字,以及Vue Router源码提交记录的App.vuerouter.ts的修改记录,我们可以构建出以下的测试文件,具体代码放在github地址

<div id="app-wrapper">
    <div class="routerLinkWrapper">
        <router-link to="/child/a">跳转到/child/a</router-link>
    </div>
    <div class="routerLinkWrapper">
        <router-link :to="{ name: 'WithChildren' }">跳转到父路由/child</router-link>
    </div>
    <div class="routerLinkWrapper">
        <router-link :to="{ name: 'default-child' }">跳转到没有路径的子路由/child</router-link>
    </div>
    <!-- route outlet -->
    <!-- component matched by the route will render here -->
    <router-view></router-view>
</div>
const routes = [
    {path: '/', component: Home},
    {
        path: '/child',
        component: TEST,
        name: 'WithChildren',
        children: [
            { path: '', name: 'default-child', component: TEST },
            { path: 'a', name: 'a-child', component: TEST  },
        ]
    }
]

当我们处于home路由时

截屏2023-03-22 00.28.12.png
当我们点击跳转到/child/a路由时,我们可以发现

  • 完全匹配的路由增加两个class: router-link-activerouter-link-exact-active
  • 路径上只有/child匹配的路由增加了class: router-link-active

截屏2023-03-22 00.28.33.png
从上面的测试结果中,我们可以大概猜测出
如果<route-link to=xxx>中的to所代表的路由是可以在当前路由中找到的,则加上router-link-activerouter-link-exact-active
如果to所代表的路由(或者它的嵌套parent路由,只要它path=嵌套parent路由的path)是可以在当前路由的嵌套parent路由中找到的,加上router-link-active
现在我们可以尝试对activeRecordIndex代码进行分析

const route = computed(() => router.resolve(unref(props.to)))
const activeRecordIndex = vue.computed(() => {
    const { matched } = route.value;
    const { length } = matched;
    const routeMatched = matched[length - 1];
    const currentMatched = currentRoute.matched;
    if (!routeMatched || !currentMatched.length)
        return -1;
    const index = currentMatched.findIndex(isSameRouteRecord.bind(null, routeMatched));
    if (index > -1)
        return index;
    const parentRecordPath = getOriginalPath(matched[length - 2]);
    return (
        length > 1 &&
            getOriginalPath(routeMatched) === parentRecordPath &&
            currentMatched[currentMatched.length - 1].path !== parentRecordPath
            ? currentMatched.findIndex(isSameRouteRecord.bind(null, matched[length - 2]))
            : index);
});

由于<router-link>初始化时就会注册,因此每一个<router-link>都会初始化上面的代码,进行computed的监听,而此时route代表传进来的路由,即

<router-link :to="{ name: 'default-child' }">跳转到没有路径的子路由/child</router-link>

初始化时的props.to="{ name: 'default-child' }"
当目前的路由发生变化时,即currentRoute发生变化时,也会触发computed(fn)重新执行一次,此时会去匹配props.to="/child"所对应的matched[最后一位index]能否在currentRoute对应的matched找到,如果找到了,直接返回index
如果找不到,则使用<router-link>目前对应的props.tomatched的倒数第二个matched[最后第二位index],看看这个倒数第二个matched[最后第二位index]能不能在currentRoute对应的matched找到,如果找到了,直接返回index

使用props.tomatched数组的倒数第二个matched[最后第二位index]的前提是<router-link to="/child">对应的路由是它path=嵌套parent路由的path

VueRouter-activeIndex.svg

activeRecordIndex的用处是什么呢?

在上面userLink的源码分析中,我们知道了isActiveisExactActive的赋值是通过computed计算

const isActive = vue.computed(() => activeRecordIndex.value > -1 &&
    includesParams(currentRoute.params, route.value.params));
const isExactActive = vue.computed(() => activeRecordIndex.value > -1 &&
    activeRecordIndex.value === currentRoute.matched.length - 1 &&
    isSameRouteLocationParams(currentRoute.params, route.value.params));

在嵌套路由中找到符合条件的<router-link>isActive=true,但是isExactActive=false
只有符合currentRoute.matched.length - 1条件下匹配的<router-link>才是isActive=trueisExactActive=true

isActiveisExactActive也就是添加router-link-active/router-link-exact-active的条件

image.png

issues分析

Vue Router中有大量注释,其中包含一些issues的注释,本文将进行简单地分析Vue Router 4.1.6源码中出现的issues注释

每一个issues都会结合github上的讨论记录以及作者提交的源码修复记录进行分析,通过issues的分析,可以明白Vue Router 4.1.6源码中很多看起来不知道是什么东西的逻辑

issues/685注释分析

function changeLocation(to, state, replace) {
    /**
     * if a base tag is provided, and we are on a normal domain, we have to
     * respect the provided `base` attribute because pushState() will use it and
     * potentially erase anything before the `#` like at
     * https://github.com/vuejs/router/issues/685 where a base of
     * `/folder/#` but a base of `/` would erase the `/folder/` section. If
     * there is no host, the `<base>` tag makes no sense and if there isn't a
     * base tag we can just use everything after the `#`.
     */
    const hashIndex = base.indexOf('#');
    const url = hashIndex > -1
        ? (location.host && document.querySelector('base')
            ? base
            : base.slice(hashIndex)) + 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) {
        {
            warn('Error with push/replace State', err);
        }
        // Force the navigation, this also resets the call count
        location[replace ? 'replace' : 'assign'](url);
    }
}

issues/685问题描述

当部署项目到子目录后,访问[https://test.vladovic.sk/router-bug/](https://test.vladovic.sk/router-bug/)

  • 预想结果路径变为: https://test.vladovic.sk/router-bug/#/
  • 实际路径变为: https://test.vladovic.sk/#/
提出issues的人还提供了正确部署链接: [https://test.vladovic.sk/router](https://test.vladovic.sk/router),能够正常跳转到https://test.vladovic.sk/router/#/,跟错误链接代码的区别在于<html>文件使用了<base href="/">
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= htmlWebpackPlugin.options.title %></title>
    <base href="/">
  </head>
  <body>
    <noscript>
      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>
HTML <base> 元素 指定用于一个文档中包含的所有相对 URL 的根 URL。一份中只能有一个 <base> 元素。
一个文档的基本 URL,可以通过使用 document.baseURI(en-US) 的 JS 脚本查询。如果文档不包含 <base> 元素,baseURI 默认为 document.location.href。

issues/685问题发生的原因

VueRouter 4.0.2版本中,changeLocation()直接使用base.indexOf("#")进行后面字段的截取

function changeLocation(to, state, replace) {
    // when the base has a `#`, only use that for the URL
    const hashIndex = base.indexOf('#');
    const url = hashIndex > -1
        ? base.slice(hashIndex) + 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 ((process.env.NODE_ENV !== 'production')) {
            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);
    }
}

将发生问题的项目git clone在本地运行,打开http://localhost:8080/router-bug/时,初始化时会触发changeLocation("/"),从而触发
history['pushState'](state, '', '#/')逻辑

截屏2023-03-22 20.15.58.png

  • 目前的location.hrefhttp://localhost:8080/router-bug/
  • <html></html>对应的<base href="/">
  • 触发了history['pushState'](state, '', '#/')

上面3种条件使得目前的链接更改为:http://localhost:8080/#/

issues/685修复分析

github issues的一开始的讨论中,最先建议的是更改<html></html>对应的<base href="/router-bug/">,因为history.pushState会用到<base>属性
后面提交了一个修复记录,如下图所示:

截屏2023-03-22 20.32.37.png

那为什么要增加<base>标签的判断呢?

我们在一开始初始化的时候就知道,createWebHashHistory()支持传入一个base默认的字符串,如果不传入,则取location.pathname+location.search,在上面http://localhost:8080/router-bug/这个例子中,我们是没有传入一个默认的字符串,因此base="/router-bug/#"

function createWebHashHistory(base) {
    // Make sure this implementation is fine in terms of encoding, specially for IE11
    // for `file://`, directly use the pathname and ignore the base
    // href="https://example.com"的location.pathname也是"/"
    base = location.host ? (base || location.pathname + location.search) : '';
    // allow the user to provide a `#` in the middle: `/base/#/app`
    if (!base.includes('#'))
        base += '#';
    return createWebHistory(base);
}

<base> HTML 元素指定用于文档中所有相对 URL 的基本 URL。如果文档没有 <base> 元素,则 baseURI 默认为 location.href
很明显,目前<html></html>对应的<base href="/">跟目前的base="/router-bug/#"是冲突的,因为url=base.slice(hashIndex)+to是建立在history.pushState对应的地址包含location.pathname的前提下,而可能存在<html></html>对应的<base>就是不包含location.pathname

// VueRouter 4.0.2,未修复前的代码
function changeLocation(to, state, replace) {
    // when the base has a `#`, only use that for the URL
    const hashIndex = base.indexOf('#');
    const url = hashIndex > -1
        ? base.slice(hashIndex) + to
        : createBaseLocation() + base + to;
}

因此当我们检测到<html></html>存在<base>标签时,我们直接使用base(经过格式化处理过的,包含location.pathname)的字符串进行当前history.pushState()地址的拼接,杜绝这种可能冲突的情况

下面代码中的base不是<html></html><base>标签!!是我们处理过的base字符串
// VueRouter修复后的代码
function changeLocation(to, state, replace) {
    const hashIndex = base.indexOf('#');
    const url =
      hashIndex > -1
        ? (location.host && document.querySelector('base')
            ? base
            : base.slice(hashIndex)) + to
        : createBaseLocation() + base + to
}

issues/366注释分析

function push(to, data) {
    // 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/router/issues/366
        historyState.value, history.state, {
        forward: to,
        scroll: computeScrollPosition(),
    });
    if (!history.state) {
        warn(`history.state seems to have been manually replaced without preserving the necessary values. Make sure to preserve existing history state if you are manually calling history.replaceState:\n\n` +
            `history.replaceState(history.state, '', url)\n\n` +
            `You can find more information at https://next.router.vuejs.org/guide/migration/#usage-of-history-state.`);
    }
    changeLocation(currentState.current, currentState, true);
    const state = assign({}, buildState(currentLocation.value, to, null), { position: currentState.position + 1 }, data);
    changeLocation(to, state, false);
    currentLocation.value = to;
}

issues/366问题描述

这个issues包含了两个人的反馈

手动history.replaceState没有传递当前state,手动触发router.push报错

开发者甲在router.push(...)之前调用window.history.replaceState(...)

window.history.replaceState({}, '', ...)
router.push(...)

然后就报错:router push gives DOMException: Failed to execute 'replace' on 'Location': 'http://localhost:8080undefined' is not a valid URL

currentState.current拿不到具体的地址
function push(to, data) {
    const currentState = assign({}, history.state, {
        forward: to,
        scroll: computeScrollPosition(),
    });
    changeLocation(currentState.current, currentState, true);    // this is the line that fails
}
跳转到授权页面,授权成功后回来进行router.push报错

开发者乙从A页面跳转到B页面后,B页面授权成功后,重定向回来A页面,然后调用router.push()
报错:router push gives DOMException: Failed to execute 'replace' on 'Location': 'http://localhost:8080undefined' is not a valid URL

currentState.current拿不到具体的地址
function push(to, data) {
    const currentState = assign({}, history.state, {
        forward: to,
        scroll: computeScrollPosition(),
    });
    changeLocation(currentState.current, currentState, true);    // this is the line that fails
}

issues/366问题发生的原因

Vue Router作者在官方文档中强调:
Vue Router 将信息保存在history.state上。如果你有任何手动调用 history.pushState() 的代码,你应该避免它,或者用的router.push()history.replaceState()进行重构:

// 将
history.pushState(myState, '', url)
// 替换成
await router.push(url)
history.replaceState({ ...history.state, ...myState }, '')

同样,如果你在调用 history.replaceState() 时没有保留当前状态,你需要传递当前 history.state:

// 将
history.replaceState({}, '', url)
// 替换成
history.replaceState(history.state, '', url)

原因:我们使用历史状态来保存导航信息,如滚动位置,以前的地址等。

以上内容摘录于Vue Router官方文档

开发者甲的问题将当前的history.state添加进去后就解决了问题,即

// 将
history.replaceState({}, '', url)
// 替换成
history.replaceState(history.state, '', url)

而开发者乙的问题是在Vue Router的A页面->B页面->Vue Router的A页面的过程中丢失了当前的history.state

开发者乙也在使用的授权开源库提了amplify-js issues

截屏2023-03-23 23.21.45.png

Vue Router作者则建议当前history.state应该保留,不应该清除

截屏2023-03-23 22.53.25.png
因此无论开发者甲还是开发者乙,本质都是没有保留好当前的history.state导致的错误

issues/366修复分析

经过上面问题的描述以及原因分析后,我们知道要修复问题的关键就是保留好当前的history.state
因此Vue Router作者直接使用变量historyState来进行数据合并

截屏2023-03-23 23.26.34.png
当你实际的window.history.state丢失时,我们还有一个自己维护的historyState数据,它在正常路由情况下historyState.value就等于window.history.state
无论因为什么原因丢失了当前浏览器自身的window.history.state,最起码有个自己定义的historyState.value,就能保证currentState.current不会为空,执行到语句history.replaceState或者history.pushState时都能有正确的state

如下面代码块所示,historyState.value初始化就是history.state,并且在每次路由变化时都实时同步更新
function useHistoryStateNavigation(base) {
    const historyState = { value: history.state };

    function changeLocation(to, state, replace) {
        //...
        history[replace ? 'replaceState' : 'pushState'](state, '', url)
        historyState.value = state;
    }
}

function useHistoryListeners(base, historyState, currentLocation, replace) {
    const popStateHandler = ({ state, }) => {
        const to = createCurrentLocation(base, location);
        //...
        if (state) {
            currentLocation.value = to;
            historyState.value = state;
        }
    }
    window.addEventListener('popstate', popStateHandler);
}

issues/328分析

function resolve(rawLocation, currentLocation) {
    //...
    return assign({
        fullPath,
        // keep the hash encoded so fullPath is effectively path + encodedQuery +
        // hash
        hash,
        query:
            // if the user is using a custom query lib like qs, we might have
            // nested objects, so we keep the query as is, meaning it can contain
            // numbers at `$route.query`, but at the point, the user will have to
            // use their own type anyway.
            // https://github.com/vuejs/router/issues/328#issuecomment-649481567
            stringifyQuery$1 === stringifyQuery
                ? normalizeQuery(rawLocation.query)
                : (rawLocation.query || {}),
    }, matchedRoute, {
        redirectedFrom: undefined,
        href,
    });
}

issues/328问题描述

Vue Router 4.0.0-alpha.13版本中,并没有考虑query嵌套的情况,比如下面这种情况

<div id="app">
  目前的地址是:{{ $route.fullPath}}
  <ul>
    <li><router-link to="/">Home</router-link></li>
    <li><router-link :to="{ query: { users: { page: 1 } } }">Page 1</router-link></li>
    <li><router-link :to="{ query: { users: { page: 2 } } }">Page 2</router-link></li>
  </ul>
  <router-view></router-view>
</div>

点击Page1$route.fullPath=/?users%5Bpage%5D=1
点击Page2$route.fullPath还是/?users%5Bpage%5D=1

理想情况下,应该有变化,$route.fullPath会变为/?users%5Bpage%5D=2

issues/328问题发生的原因

Vue Router 4.0.0-alpha.13版本中,在进行路由跳转时,会触发isSameRouteLocation()的检测

function push(to) {
    return pushWithRedirect(to);
}
function pushWithRedirect(to, redirectedFrom) {
    const targetLocation = (pendingLocation = resolve(to));
    //...
    if (!force && isSameRouteLocation(from, targetLocation)) {
       failure = createRouterError(4 /* NAVIGATION_DUPLICATED */, { to: toLocation, from });
    }
    return (failure ? Promise.resolve(failure) : navigate(toLocation, from));
}

isSameRouteLocation()中,会使用isSameLocationObject(a.query, b.query)进行检测,如果此时的query是两个嵌套的Object数据,会返回true,导致Vue Router认为是同一个路由,无法跳转成功,自然也无法触发$route.fullPath改变

function isSameRouteLocation(a, b) {
    //...
    return (aLastIndex > -1 &&
        aLastIndex === bLastIndex &&
        isSameRouteRecord(a.matched[aLastIndex], b.matched[bLastIndex]) &&
        isSameLocationObject(a.params, b.params) &&
        isSameLocationObject(a.query, b.query) &&
        a.hash === b.hash);
}
function isSameLocationObject(a, b) {
    if (Object.keys(a).length !== Object.keys(b).length)
        return false;
    for (let key in a) {
        if (!isSameLocationObjectValue(a[key], b[key]))
            return false;
    }
    return true;
}
function isSameLocationObjectValue(a, b) {
    return Array.isArray(a)
        ? isEquivalentArray(a, b)
        : Array.isArray(b)
            ? isEquivalentArray(b, a)
            : a === b;
}
function isEquivalentArray(a, b) {
    return Array.isArray(b)
        ? a.length === b.length && a.every((value, i) => value === b[i])
        : a.length === 1 && a[0] === b;
}
从上面的代码可以知道,如果我们的a.query是一个嵌套Object,最终会触发isSameLocationObjectValue()a===b的比较,最终应该会返回false才对,那为什么会返回true呢?

那是因为在调用isSameRouteLocation()之前会进行resolve(to)操作,而在这个方法中,我们会进行normalizeQuery(rawLocation.query),无论rawLocation.query是嵌套多少层的ObjectnormalizeQuery()中的'' + value都会变成'[object Object]',因此导致了a===b的比较实际就是'[object Object]'==='[object Object]',返回true

function createRouter(options) {
    function resolve(rawLocation, currentLocation) {
        return assign({
            fullPath,
            hash,
            query: normalizeQuery(rawLocation.query),
        }, matchedRoute, {
            redirectedFrom: undefined,
            href: routerHistory.base + fullPath,
        });
    }
    function push(to) {
        return pushWithRedirect(to);
    }
    function pushWithRedirect(to, redirectedFrom) {
        const targetLocation = (pendingLocation = resolve(to));
        //...
        if (!force && isSameRouteLocation(from, targetLocation)) {
            failure = createRouterError(4 /* NAVIGATION_DUPLICATED */, { to: toLocation, from });
        }
    }
}
function normalizeQuery(query) {
    const normalizedQuery = {};
    for (let key in query) {
        let value = query[key];
        if (value !== undefined) {
            normalizedQuery[key] = Array.isArray(value)
                ? value.map(v => (v == null ? null : '' + v))
                : value == null
                    ? value
                    : '' + value;
        }
    }
    return normalizedQuery;
}

issues/328修复分析

初始化可传入stringifyQuery,内部对query不进行处理

由于query可能是复杂的结构,因此修复该问题第一考虑的点就是放开给开发者自己解析

截屏2023-03-23 11.33.06.png

开发者可以在初始化createRouter()传入自定义的parseQuery()stringifyQuery()方法,开发者自行解析和转化目前的query参数

  • 如果开发者不传入自定义的stringifyQuery()方法,那么stringifyQuery就会等于originalStringifyQuery(一个Vue Router内置的stringifyQuery方法),这个时候query就会使用normalizeQuery(rawLocation.query)进行数据的整理,最终返回的还是一个Object对象
  • 如果开发者传入自定义的stringifyQuery()方法,那么就不会触发任何处理,还是使用rawLocation.query,在上面示例中就是一个嵌套的Object对象,避免使用normalizeQuery()'' + value变成'[object Object]'的情况

截屏2023-03-23 11.45.53.png

isSameRouteLocation比较query时,使用它们stringify处理后的字符串进行比较

开发者可以自定义传入stringifyQuery()进行复杂结构的处理,然后返回字符串进行比较
如果不传入stringifyQuery(),则使用默认的方法进行stringify,然后根据返回的字符串进行比较

默认方法的stringify不会考虑复杂的数据结构,只会当做普通对象进行stringify

截屏2023-03-23 13.57.48.png

issues/1124分析

function insertMatcher(matcher) {
    let i = 0;
    while (i < matchers.length &&
        comparePathParserScore(matcher, matchers[i]) >= 0 &&
        // Adding children with empty path should still appear before the parent
        // https://github.com/vuejs/router/issues/1124
        (matcher.record.path !== matchers[i].record.path ||
            !isRecordChildOf(matcher, matchers[i])))
        i++;
    matchers.splice(i, 0, matcher);
    // only add the original record to the name map
    if (matcher.record.name && !isAliasRecord(matcher))
        matcherMap.set(matcher.record.name, matcher);
}

issues/1124问题描述

Vue Router 4.0.11

  • 使用动态添加路由addRoute()为当前的name="Root"路由添加子路由后

    • 我们想要访问Component:B,使用了router.push("/"),但是无法渲染出Component:B,它渲染的是它的上一级路径Component: Root
    • 而如果我们使用router.push({name: 'Home'})时,就能正常访问Component:B
  • 如果我们不使用动态添加路由,直接在初始化的时候,如下面注释children那样,直接添加Component:B,当我们使用router.push("/"),可以正常渲染出Component:B
const routes = [
    {
        path: '/',
        name: 'Root',
        component: Root,
        // Work with non dynamic add and empty path
        /*children: [
          {
            path: '',
            component: B
          }
        ]*/
    }
];
// Doesn't work with empty path and dynamic adding
router.addRoute('Root', {
    path: '',
    name: 'Home',
    component: B
});

issues/1124问题发生的原因

当静态添加路由时,由于是递归调用addRoute(),即父addRoute()->子addRoute->子insertMatcher()->父addRoute(),因此子matcher是排在父matcher前面的,因为从《Vue3相关源码-Vue Router源码解析(一)》文章中计算路由权重的逻辑可以知道,路径相同分数则相同,comparePathParserScore()的值为0,因此先调用insertMatcher(),位置就越靠前,因此子路由位置靠前

function addRoute(record, parent, originalRecord) {
    if ('children' in mainNormalizedRecord) {
        const children = mainNormalizedRecord.children
        for (let i = 0; i < children.length; i++) {
            addRoute(
                children[i],
                matcher,
                originalRecord && originalRecord.children[i]
            )
        }
    }
    //...
    insertMatcher(matcher);
}

function insertMatcher(matcher) {
    let i = 0;
    while (i < matchers.length &&
        comparePathParserScore(matcher, matchers[i]) >= 0 &&
        // Adding children with empty path should still appear before the parent
        // https://github.com/vuejs/router/issues/1124
        (matcher.record.path !== matchers[i].record.path))
        i++;
    matchers.splice(i, 0, matcher);
    // only add the original record to the name map
    if (matcher.record.name && !isAliasRecord(matcher))
        matcherMap.set(matcher.record.name, matcher);
}

但是动态添加路由时,同样是触发matcher.addRoute(),只不过由于是动态添加,因此要添加的新路由的parent之前已经插入到matchers数组中去了,由上面的分析可以知道,路由权重相同,因此先调用insertMatcher(),位置就越靠前,此时子路由位置靠后

function createRouter(options) {
    function addRoute(parentOrRoute, route) {
        let parent;
        let record;
        if (isRouteName(parentOrRoute)) {
            parent = matcher.getRecordMatcher(parentOrRoute);
            record = route;
        }
        else {
            record = parentOrRoute;
        }
        return matcher.addRoute(record, parent);
    }
}
function createRouterMatcher(routes, globalOptions) {
    function addRoute(record, parent, originalRecord) {
        if ('children' in mainNormalizedRecord) {
            const children = mainNormalizedRecord.children
            for (let i = 0; i < children.length; i++) {
                addRoute(
                    children[i],
                    matcher,
                    originalRecord && originalRecord.children[i]
                )
            }
        }
        //...
        insertMatcher(matcher);
    }
}

在上一篇文章《Vue3相关源码-Vue Router源码解析(一)》的分析中,我们知道,当我们使用router.push("/")或者router.push({path: "/"})时,会触发正则表达式的匹配,如下面代码所示,matchers.find()会优先匹配位置靠前的路由matcher,从而发生了动态添加子路由找不到子路由(位置比较靠后),初始化添加子路由能够渲染子路由(位置比较靠前)的情况

path = location.path;
matcher = matchers.find(m => m.re.test(path));
if (matcher) {
    params = matcher.parse(path);
    name = matcher.record.name;
}

issues/1124修复分析

既然是添加顺序导致的问题,那么只要让子路由动态添加时,遇到它的parent不要i++即可,如下面修复提交代码所示,当子路由动态添加时,检测目前对比的是不是它的parent,如果是它的parent,则阻止i++,子路由位置就可以顺利地放在它的parent前面
截屏2023-03-23 16.12.01.png

issues/916分析

很长的一段navigte()失败之后的处理.....简化下
function setupListeners() {
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
        //...
        const toLocation = resolve(to);
        const from = currentRoute.value;
        navigate(toLocation, from)
            .catch((error) => {
                if (isNavigationFailure(error, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 8 /* ErrorTypes.NAVIGATION_CANCELLED */)) {
                    return error;
                }
                if (isNavigationFailure(error, 2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */)) {
                    pushWithRedirect(error.to, toLocation)
                        .then(failure => {
                            // manual change in hash history #916 ending up in the URL not
                            // changing, but it was changed by the manual url change, so we
                            // need to manually change it ourselves
                            if (isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ |
                                16 /* ErrorTypes.NAVIGATION_DUPLICATED */) &&
                                !info.delta &&
                                info.type === NavigationType.pop) {
                                routerHistory.go(-1, false);
                            }
                        })
                        .catch(noop);
                    // avoid the then branch
                    return Promise.reject();
                }
                // do not restore history on unknown direction
                if (info.delta) {
                    routerHistory.go(-info.delta, false);
                }
                // unrecognized error, transfer to the global handler
                return triggerError(error, toLocation, from);
            })
            .then((failure) => {
                failure =
                    failure ||
                    finalizeNavigation(
                        // after navigation, all matched components are resolved
                        toLocation, from, false);
                // revert the navigation
                if (failure) {
                    if (info.delta &&
                        // a new navigation has been triggered, so we do not want to revert, that will change the current history
                        // entry while a different route is displayed
                        !isNavigationFailure(failure, 8 /* ErrorTypes.NAVIGATION_CANCELLED */)) {
                        routerHistory.go(-info.delta, false);
                    } else if (info.type === NavigationType.pop &&
                        isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 16 /* ErrorTypes.NAVIGATION_DUPLICATED */)) {
                        // manual change in hash history #916
                        // it's like a push but lacks the information of the direction
                        routerHistory.go(-1, false);
                    }
                }
                triggerAfterEach(toLocation, from, failure);
            })
            .catch(noop);
    });
}
function setupListeners() {
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
        //...
        const toLocation = resolve(to);
        const from = currentRoute.value;
        navigate(toLocation, from)
            .catch((error) => {
                // error是 NAVIGATION_ABORTED/NAVIGATION_CANCELLED
                return error;

                // error是 NAVIGATION_GUARD_REDIRECT
                pushWithRedirect(error.to, toLocation)
                    .then(failure => {
                        if (isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ |
                            16 /* ErrorTypes.NAVIGATION_DUPLICATED */) &&
                            !info.delta &&
                            info.type === NavigationType.pop) {

                            routerHistory.go(-1, false);
                        }
                    })
            })
            .then((failure) => {
                if (failure) {
                    if (info.type === NavigationType.pop &&
                        isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 
                        16 /* ErrorTypes.NAVIGATION_DUPLICATED */)) {

                        routerHistory.go(-1, false);
                    }
                }

            })
    });
}

issues/916问题描述

目前有两个路由,/login路由和/about路由,它们配置了一个全局的导航守卫,当遇到/about路由时,会重定向到/login路由

router.beforeEach((to, from) => {
    if (to.path.includes('/login')) {
        return true;
    } else {
        return {
            path: '/login'
        }
    }
})

目前问题是:

  • 目前是/login路由,各方面正常,手动在浏览器地址更改/login->/about
  • 期望行为是:由于router.beforeEach配置了重定向跳转,浏览器地址会重新变为/login,页面也还是保留在Login组件
  • 实际表现是:浏览器地址是/about,页面保留在Login组件,造成浏览器地址跟实际映射组件不符合的bug

issues/916问题发生的原因

vue-router-direct.svg

issues/916修复分析

如果导航守卫next()返回的是路由数据,会触发popStateHandler()->navigate()->pushWithRedirect(),然后返回NAVIGATION_DUPLICATED,因此我们要做的就是回退一个路由,并且不触发组件更新,即routerHistory.go(-1, false)

因为NAVIGATION_DUPLICATED就意味着要重定向的这个新路由(/login)跟启动重定向路由(/about)之前的路由(/login)是重复的,那么这个启动重定向路由(/about)就得回退,因为它(/about)势必会不成功

截屏2023-03-29 16.48.11.png

除了在pushWithRedirect()上修复错误之外,还在navigate().then()上也进行同种状态的判断
截屏2023-03-29 16.56.07.png

这是为什么呢?除了next("/login")之外,还有情况导致错误吗?

这是因为除了next("/login"),还有一种可能就是next(false)也会导致错误,即

router.beforeEach((to, from) => {
    if (to.path.includes('/login')) {
        return true;
    } else {
        return false;
        // return {
        //     path: '/login'
        // }
    }
})

当重定向路由改为false时,如下面代码块所示,navigate()会返回NAVIGATION_ABORTED的错误,从而触发navigate().catch(()=> error)
Promise.catch()中返回值,这个值也会包裹触发下一个then(),也就是说NAVIGATION_ABORTED会传递给navigate().catch().then()中,因此还需要在then()里面进行routerHistory.go(-1, false)

NAVIGATION_ABORTED意味着这种routerHistory.listen传递的to路由因为next()返回false而取消,因此需要回退一个路由,因为这个to路由已经改变浏览器的记录了!
function setupListeners() {
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
        //...
        const toLocation = resolve(to);
        const from = currentRoute.value;
        navigate(toLocation, from)
            .catch((error) => {
                // error是 NAVIGATION_ABORTED/NAVIGATION_CANCELLED
                return error;
            })
            .then((failure) => {
                if (failure) {
                    if (info.type === NavigationType.pop &&
                        isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 
                        16 /* ErrorTypes.NAVIGATION_DUPLICATED */)) {

                        routerHistory.go(-1, false);
                    }
                }

            })
    });
}

总结

1. 外部定义的路由,是如何在Vue Router内部建立联系的?

Vue Router支持多种路径写法,静态路径、普通的动态路径以及动态路径和正则表达式的结合
初始化时会对routes进行解析,根据多种路径的不同形态解析出对应的正则表达式、路由权重和路由其它数据,包括组件名称、组件等等

2. Vue Router是如何实现pushreplacepop操作的?

  • push/replace

    • 通过resolve()整理出跳转数据的对象,该对象包括找到一开始初始化routes对应的matched以及跳转的完整路径
    • 然后通过navigate()进行一系列导航守卫的调用
    • 然后通过changeLocation(),也就是window.history.pushState/replaceState()实现的当前浏览器路径替换
    • 更新目前的currentRoute,从而触发<route-view>中的injectedRoute->routeToDisplay等响应式数据发生变化,从而触发<route-view>setup函数重新渲染currentRoute的matched携带的Component,实现路由更新功能
  • pop

    • 初始化会进行setupListeners()进行routerHistory.listen事件的监听
    • 监听popstate事件,后退事件触发时,触发初始化监听的routerHistory.listen事件
    • 通过resolve()整理出跳转数据的对象,该对象包括找到一开始初始化routes对应的matched以及跳转的完整路径
    • 然后通过navigate()进行一系列导航守卫的调用
    • 然后通过changeLocation(),也就是window.history.pushState/replaceState()实现的当前浏览器路径替换
    • 更新目前的currentRoute,从而触发<route-view>中的injectedRoute->routeToDisplay等响应式数据发生变化,从而触发<route-view>setup函数重新渲染currentRoute的matched携带的Component,实现路由更新功能

3. Vue Router是如何命中多层嵌套路由,比如/parent/child/child1需要加载多个组件,是如何实现的?

在每次路由跳转的过程中,会解析出当前路由对应的matcher对象,并且将它所有的parent matcher都加入到matched数组中
在实现pushreplacepop操作时,每一个路由都会在router-view中计算出目前对应的嵌套深度,然后根据嵌套深度,拿到上面matched对应的item,实现路由组件的渲染

4. Vue Router有什么导航守卫?触发的流程是怎样的?

通过Promise链式顺序调用多个导航守卫,在每次路由跳转时,会触发push/replace()->navigate()->finalizeNavigation(),导航守卫就是在navigate()中进行链式调用,可以在其中一个导航守卫中中断流程,从而中断整个路由的跳转

参考官方文档的资料,我们可以推断出/child1->/child2导航守卫的调用顺序为
  • 【组件守卫】在失活的组件里调用 beforeRouteLeave 守卫
  • 【全局守卫】beforeEach
  • 【路由守卫】beforeEnter
  • 解析异步路由组件
  • 【组件守卫】在被激活的组件里调用 beforeRouteEnter(无法访问this,实例未创建)
  • 【全局守卫】beforeResolve
  • 导航被确认
  • 【全局守卫】afterEach
  • 【vue生命周期】beforeCreatecreatedbeforeMount
  • 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入
  • 【vue生命周期】mounted

    参考官方文档的资料,我们可以推断出路由/user/:id/user/a->/user/b导航守卫的调用顺序为
  • 【全局守卫】beforeEach
  • 【组件守卫】在重用的组件里调用 beforeRouteUpdate 守卫(2.2+)
  • 【全局守卫】beforeResolve
  • 【全局守卫】afterEach
  • 【vue生命周期】beforeUpdateupdated

5. Vue Router的导航守卫是如何做到链式调用的?

navigate()的源码中,我们截取beforeEachbeforeRouteUpdate的片段进行分析,主要涉及有几个点:

  • runGuardQueue(guards)本质就是链式不停地调用promise.then(),然后执行guards[x](),最终完成某一个阶段,比如beforeEach阶段之后,再使用guards收集下一个阶段的function数组,然后再启用runGuardQueue(guards)使用promise.then()不断执行guards里面的方法
  • guards.push()添加的是一个Promise,传递的是在外部注册的方法guardtofrom三个参数
function navigate(to, from) {
    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
            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);
        })
        //....
    )
}
function runGuardQueue(guards) {
    return guards.reduce(
        (promise, guard) => promise.then(() => guard()),
        Promise.resolve());
}

对于guards.push()添加的Promise,会判断外部function(也就是guard)有多少个参数,然后调用不同的条件逻辑,最终根据外部注册的方法,比如beforeEach()返回的值,进行Promise()的返回,从而形成链式调用

Function: length就是有多少个parameters参数,guard.length主要区别在于传不传next参数,可以看下面代码块的注释部分
如果你在外部的方法,比如beforeEach携带了next参数,你就必须调用它,不然会报错

//router.beforeEach((to, from)=> {return false;}
//router.beforeEach((to, from, next)=> {next(false);}
function guardToPromiseFn(guard, to, from, record, name) {
    // keep a reference to the enterCallbackArray to prevent pushing callbacks if a new navigation took place
    const enterCallbackArray = record &&
        // name is defined if record is because of the function overload
        (record.enterCallbacks[name] = record.enterCallbacks[name] || []);
    return () => new Promise((resolve, reject) => {
        const next = (valid) => {
            resolve();
        };
        const guardReturn = guard.call(record && record.instances[name], to, from, canOnlyBeCalledOnce(next, to, from));
        let guardCall = Promise.resolve(guardReturn);
        if (guard.length < 3)
            guardCall = guardCall.then(next);
        if (guard.length > 2) {
            //...处理有next()的情况
        }
        guardCall.catch(err => reject(err));
    });
}
function canOnlyBeCalledOnce(next, to, from) {
    let called = 0;
    return function () {
        next._called = true;
        if (called === 1)
            next.apply(null, arguments);
    };
}

6. Vue RouterbeforeRouteEnterbeforeRouteUpdate的触发时机

如果复用一个路由,比如/user/:id会导致不同的path会使用同一个路由,那么就不会调用beforeRouteEnter,因此我们需要在beforeRouteUpdate获取数据

export default {
  data() {
    return {
      post: null,
      error: null,
    }
  },
  beforeRouteEnter(to, from, next) {
    getPost(to.params.id, (err, post) => {
      next(vm => vm.setData(err, post))
    })
  },
  // 路由改变前,组件就已经渲染完了
  // 逻辑稍稍不同
  async beforeRouteUpdate(to, from) {
    this.post = null
    try {
      this.post = await getPost(to.params.id)
    } catch (error) {
      this.error = error.toString()
    }
  },

7. Vue Routerrouterouter的区别

router是目前Vue使用Vue Router示例,具有多个方法和多个对象数据,包含currentRoute
route是当前路由的响应式对象,内容本质就是currentRoute

8. hash模式跟h5 history模式在Vue Router中有什么区别?

hash模式:监听浏览器地址hash值变化,使用pushState/replaceState进行路由地址的改变,从而触发组件渲染改变,不需要服务器配合配置对应的地址
history模式:改变浏览器url地址,从而触发浏览器向服务器发送请求,需要服务器配合配置对应的地址

9. Vue Routerhash模式重定向后还会保留浏览记录吗?比如重定向后再使用router.go(-1)会返回重定向之前的页面吗?

Vue Routerhash模式中,如果发生重定向,从push()->pushWithRedirect()的源码可以知道,会在navigate()之前就进行重定向的跳转,因此不会触发finalizeNavigation()pushState()方法往浏览器中留下记录,因此不会在Vue Routerhash模式中,不会保留浏览器history state记录

function pushWithRedirect(to, redirectedFrom) {
    const targetLocation = (pendingLocation = resolve(to));
    const from = currentRoute.value;
    const data = to.state;
    const force = to.force;
    // to could be a string where `replace` is a function
    const replace = to.replace === true;
    const shouldRedirect = handleRedirectRecord(targetLocation);
    if (shouldRedirect) {
        //...处理重定向的逻辑
        return pushWithRedirect(...)
    }
    // if it was a redirect we already called `pushWithRedirect` above
    const toLocation = targetLocation;
    toLocation.redirectedFrom = redirectedFrom;
    //...处理SameRouteLocation的情况

    // ...去除failure的处理,默认都成功
    return navigate(toLocation, from)
        .then((failure) => {
            failure = finalizeNavigation(toLocation, from, true, replace, data);
            triggerAfterEach(toLocation, from, failure);
            return failure;
        });
}

10. Vue Routerhash模式什么地方最容易导致路由切换失败?

在之前的分析中,我们可以知道,我们可以使用go(-1, false)进行pauseListeners()的调用

function go(delta, triggerListeners = true) {
    if (!triggerListeners)
        historyListeners.pauseListeners();
    history.go(delta);
}

function listen(callback) {
    // set up 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;
}
function pauseListeners() {
    pauseState = currentLocation.value;
}

从上面的代码中,我们可以总结出几个关键的问题:

  1. pauseListeners()是如何暂停监听方法执行的?pauseState什么时候使用?
  2. 什么时候触发listen()注册监听方法?
  3. listen()注册的监听方法有什么用处?
  4. 为什么要使用pauseListeners()暂停监听?

pauseListeners()是如何暂停监听方法执行的?

pauseState是如何做到暂停监听方法执行的?

当触发后退事件时,会检测pauseState是否存在以及是否等于后退之前的路由,如果是的话,则直接return,阻止后续的listeners的循环调用,达到暂停listeners的目的

const popStateHandler = ({ state, }) => {
    const from = currentLocation.value;
    //....
    let delta = 0;
    if (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.forEach(listener => {
      //...
    });
};
window.addEventListener('popstate', popStateHandler);

什么时候触发listen()注册监听方法?

在上一篇文章的setupListeners()注册pop操作相关监听方法的分析中,我们可以知道,初始化app.use(router)会触发router.install(app),然后进行一次push()操作,此时就是初始化阶段!readypush()->navigate()->finalizeNavigation(),然后触发listen()注册监听方法
截屏2023-03-29 10.50.04.png

function finalizeNavigation(toLocation, from, isPush, replace, data) {
    markAsReady();
}
function markAsReady(err) {
    if (!ready) {
        ready = !err;
        setupListeners();
    }
    return err;
}
function setupListeners() {
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
       //...navigate()
    });
}

listen()监听方法有什么用处?

在上一篇文章的setupListeners()注册pop操作相关监听方法的分析中,我们可以知道,在原生popstate后退事件触发时,会触发对应的listeners监听方法执行,将当前的路由作为参数传递过去,因此listen()监听方法本质的功能就是监听后退事件,执行一系列导航守卫,实现路由切换相关逻辑

pop()事件的监听本质跟push()执行的逻辑是一致的,都是切换路由映射的组件
function useHistoryListeners(base, historyState, currentLocation, replace) {
    const popStateHandler = ({ state, }) => {
        const to = createCurrentLocation(base, location);
        if (state) {
              currentLocation.value = to;
        }
        listeners.forEach(listener => {
            listener(currentLocation.value, from, {
                delta,
                type: NavigationType.pop,
                direction: delta
                    ? delta > 0
                        ? NavigationDirection.forward
                        : NavigationDirection.back
                    : NavigationDirection.unknown,
            });
        });
    };
    window.addEventListener('popstate', popStateHandler);
    return {
        pauseListeners,
        listen,
        destroy,
    };
}

listen()监听方法触发后,具体做了什么?

当后退事件触发,listen()监听方法触发,本质就是执行了navigate()导航,进行了对应的组件切换功能

手动模拟了主动触发路由切换,但是不会进行pushState/replaceState改变当前的路由地址,只是改变currentRoute,触发<router-view>的组件重新渲染
function setupListeners() {
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
        navigate(toLocation, from).then(() => {
            // false代表不会触发pushState/replaceState
            finalizeNavigation(toLocation, from, false);
        });
    });
}
function finalizeNavigation(toLocation, from, isPush, replace, data) {
    if (isPush) {
        if (replace || isFirstNavigation)
            routerHistory.replace(toLocation.fullPath, assign({
                scroll: isFirstNavigation && state && state.scroll,
            }, data));
        else
            routerHistory.push(toLocation.fullPath, data);
    }
    currentRoute.value = toLocation;
}

什么时候调用pauseListeners()pauseListeners()的作用是什么?

go()方法中,如果我们使用router.go(-1, false),那么我们就会触发pauseListeners()->pauseState = currentLocation.value
路由后退会触发popStateHandler(),此时我们已经注册pauseState = currentLocation.value,因此在popStateHandler()中会阻止后续的listeners的循环调用,达到暂停listeners的目的

function go(delta, triggerListeners = true) {
    if (!triggerListeners)
        historyListeners.pauseListeners();
    history.go(delta);
}
我们什么时候调用go(-xxxx, false)?第二个参数跟popStateHandler()有什么联系?

Vue Router 4.1.6的源码中,我们可以发现,所有涉及到go(-xxxx, false)都集中在后退事件对应的监听方法中,如下面代码块所示,在后退事件触发,进行组件的切换过程中,Vue Router可能会产生多种不同类型的路由切换失败,比如上面分析的issues/916一样,当我们手动更改路由,新路由重定向的路由跟目前路由重复时,我们就需要主动后退一个路由,但是我们不希望重新渲染组件,只是单纯想要回退路由记录,那么我们就可以调用routerHistory.go(-1, false)触发pauseListeners(),从而暂停listeners执行,从而阻止navigate()函数的调用,阻止导航守卫的发生和scroll滚动位置的恢复等一系列逻辑

function setupListeners() {
    removeHistoryListener = routerHistory.listen((to, _from, info) => {
        navigate(toLocation, from)
            .catch((error) => {
               if (isNavigationFailure(error, 2 /* ErrorTypes.NAVIGATION_GUARD_REDIRECT */)) {
                    pushWithRedirect(error.to, toLocation)
                        .then(failure => {
                            if (isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ |
                                16 /* ErrorTypes.NAVIGATION_DUPLICATED */) &&
                                !info.delta &&
                                info.type === NavigationType.pop) {
                                routerHistory.go(-1, false);
                            }
                        });
                } else {
                    if (info.delta) {
                        routerHistory.go(-info.delta, false);
                    }
                }
            })
            .then((failure) => {
                // false代表不会触发pushState/replaceState
                failure = failure || finalizeNavigation(toLocation, from, false);
                if (failure) {
                    if (info.delta &&
                        // a new navigation has been triggered, so we do not want to revert, that will change the current history
                        // entry while a different route is displayed
                        !isNavigationFailure(failure, 8 /* ErrorTypes.NAVIGATION_CANCELLED */)) {
                        routerHistory.go(-info.delta, false);
                    }
                    else if (info.type === NavigationType.pop &&
                        isNavigationFailure(failure, 4 /* ErrorTypes.NAVIGATION_ABORTED */ | 16 /* ErrorTypes.NAVIGATION_DUPLICATED */)) {
                        // manual change in hash history #916
                        // it's like a push but lacks the information of the direction
                        routerHistory.go(-1, false);
                    }
                }
            })
    });
}

参考文章

  1. 7张图,从零实现一个简易版Vue-Router,太通俗易懂了!
  2. VueRouter4路由权重

Vue系列其它文章

  1. Vue2源码-响应式原理浅析
  2. Vue2源码-整体流程浅析
  3. Vue2源码-双端比较diff算法 patchVNode流程浅析
  4. Vue3源码-响应式系统-依赖收集和派发更新流程浅析
  5. Vue3源码-响应式系统-Object、Array数据响应式总结
  6. Vue3源码-响应式系统-Set、Map数据响应式总结
  7. Vue3源码-响应式系统-ref、shallow、readonly相关浅析
  8. Vue3源码-整体流程浅析
  9. Vue3源码-diff算法-patchKeyChildren流程浅析

白边
209 声望37 粉丝

源码爱好者,已经完成vue2和vue3的源码解析+webpack5整体流程源码+vite4开发环境核心流程源码+koa2源码