从0到0.8实现Vue-Router4-响应式路由实现(第二章)

1,401 阅读10分钟

本篇文章将借助基于vue3讲解vue-router4是如何实现的,简化了很多源码逻辑,只关注核心原理去实现基本的路由系统。

课程目录

核心路由系统的实现(第一章)

响应式路由实现(第二章)

Router-view和link实现(第三章)

完整代码gitee地址

写在前面

上一章,已经实现了浏览器history历史记录栈状态的存储和更新,说白了就是数据层的操作,并未跟vue关联起来,本章将通过浏览器历史记录栈与vue的响应式。

实现思路

vue-router中用户需要调用API来操作浏览器历史记录栈,vue-router会将路由的router路由方法route路由属性暴露,用户可以调用相应的API使用对应的方法,需要根据上一章内容,将浏览器的历史记录状态与之双向响应提供给用户调用,在调用pushreplaceAPI时匹配到对应的路由组件返回。

具体实现

hash模式和history模式

先来看一下vue-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')

// 现在,应用已经启动了!

官方示例提供一个createRouter方法,接收一个对象作为参数,history路由模式和routes是路由定义,路由模式是通过createWebHashHistorycreateWebHistory来配置hash路由还是history路由,然后将createRouter返回值用use安装路由插件。因此我们也照葫芦画瓢接着修改上一章的代码。 上一章的代码已经实现了history路由模式,因此我们只需要在hash.js中实现hash模式即可,在vue-router4中,由于无需做兼容处理,所以已经放弃使用hashchang来实现hash路由了,而是统一通过pushStatereplaceState方法。

// hash.js
import { createWebHistory } from './html5.js';

function createWebHashHistory() {
    return createWebHistory('#');
}
// index.js
import { createWebHistory } from './history/html5';
import { createWebHashHistory } from './history/hash';

function createRouter() {

}

export {
    createRouter,
    createWebHistory,
    createWebHashHistory
}
  • 代码5行,调用createWebHistory方法,传入一个#标识创建hash模式。

修改createWebHistory代码

// html5.js
...省略代码
function createCurrentHistoryLocation(base) {
    const { pathname, hash, search } = window.location;
    let hashUrl = hash;
    const hasWell = base.indexOf('#'); // hash模式,路径不要#号,把#号去掉
    if (hasWell > -1) {
        hashUrl = hashUrl.slice(1) === '' ? '' : hashUrl.slice(2); // 如果是空补充/
    }
    return pathname + hash + search;
}
function useHistoryStateNavigation(base) {
// 路由信息
       const currentLocation = {
        value: createCurrentHistoryLocation(base)
     };
...省略代码
function changeLocation(to, state, isReplace) {
        const hasWell = base.indexOf('#'); // hash模式,路径不要#号,把#号去掉
        const url = hasWell > -1 ? base + to : to;
        history[isReplace ? 'replaceState' : 'pushState'](state, null, to);
        historyState.value = state;
    }
}

function useHistoryListeners(base, historyState, currentLocation) {
    function popStateHandler({ state }) {
            const to = createCurrentHistoryLocation(base);
            ...省略代码
    }
}

...省略代码
export function createWebHistory(base = '') {
    const historyNavigation = useHistoryStateNavigation(base);
    const historyListeners = useHistoryListeners(base, 
    ...省略代码
}
  • 代码34行,createWebHistory新增参数base作为hash的标记
  • 代码3行,接收参数,主要修改两个点
    • createCurrentHistoryLocation创建路由信息时,如果base存在,需要将location信息中的#号去掉。
    • 与之对应,在修改路由信息时changeLocation需要重新将#号恢复再重新保存信息。

由于文章长度原因,省略无需修改的代码,完整代码可点击代码块script查看

路由配置

跟着官方配置我们自己的路由信息,src目录下新建routerindex.js

.
├── README.md
├── index.html
├── jsconfig.json
├── package.json
├── src
│   ├── App.vue
│   ├── components
│   ├── main.js
│   ├── router
│   │   └── index.js
│   ├── view
│   │   ├── AboutView.vue
│   │   └── HomeView.vue
│   └── vue-router
│       ├── history
│       │   ├── hash.js
│       │   └── html5.js
│       └── index.js
└── vite.config.js

修改/router/index.js

// /router/index.js
import { createRouter, createWebHistory } from '@/vue-router'
import HomeView from '../views/HomeView.vue'
import About from '../views/AboutView.vue'
import A from '../components/A.vue';
import B from '../components/B.vue';

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
      children: [
        {
          path: '/a',
          name: 'a',
          component: A
        },
        {
          path: '/b',
          name: 'b',
          component: B
        }
      ]
    },
    {
      path: '/about',
      name: 'about',
      // route level code-splitting
      // this generates a separate chunk (About.[hash].js) for this route
      // which is lazy-loaded when the route is visited.
      component: About
    }
  ]
})

export default router


修改main.js

import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'

createApp(App).use(router).mount('#app')

具体路由配置可自定义,这里仅作为示例

createRouter的实现

根据vue插件文档,定义插件需要提供install方法,在Vue.use方法中会默认调用install方法进行插件的注册,修改index.js

function createRouter(options) {
    const router = {
        install(app) {

        }
    }
    return router;
}

其中app是注入的vue实例,而options,就是在路由配置中传入的history路由模式和routes路由定义,先讲解下处理routes

根据上述路由配置,假设当前需要跳转/a路由,那么就需要匹配出/a路由需要加载的组件HomeViewA两个,因此对于用户配置的路由信息,我们需要将其格式化一遍,方便后续路路由匹配出对应的组件。

image.png 将所有的路由记录record拍平,并且构建一个父子关系,可以很方便的找到其父记录

// vue-router/index.js
/**
 * 标化路由配置信息
 */
function normalizeRouteRecord(record) {
    return {
        path: record.path, // 路径
        meta: record.meta || {},
        children: record.children || [],
        name: record.name,
        beforeEnter: record.beforeEnter, // 路由守卫
        components: {
            default: record.component
        }
    };
}

// 构建父子关系
function createRouteRecordMatcher(record, parent = null) {
    const matcher = {
        path: record.path,
        record,
        parent,
        children: []
    };
    if (parent) {
        parent.children.push(matcher);
    }
    return matcher;
}

/**
 * 格式化路由配置信息
 * @param {Array} - routes
 */
function createRouterMatcher(routes) {
    const matchers = [];
    function addRoute(record, parent) {
        let mainNormalizedRecord = normalizeRouteRecord(record);
        const matcher = createRouteRecordMatcher(mainNormalizedRecord, parent);
        if (mainNormalizedRecord.children.length) {
            for (let i = 0; i < mainNormalizedRecord.children.length; i++) {
                addRoute(mainNormalizedRecord.children[i], matcher);
            }
        }
        matchers.push(matcher);
    }
    // 递归遍历所有的router
    routes.forEach((route) => addRoute(route));
}
function createRouter(options) {
    const matcher = createRouterMatcher(options.routes);

    const router = {
        install(app) {}
    };
    return router;
}
  • 代码53行,接收到用户配置路由信息,传入声明的createRouterMatcher进行格式化,这是一个路由匹配器,得到一个格式化后的结果。
  • 代码36行,createRouterMatcher内有addRoute方法,接收两个参数,第一个参数record路由信息,第二个参数parent
    • 代码49行,forEach循环所有的路由信息,将其传入实现的addRoute方法。
    • 代码39行,normalizeRouteRecord方法是路由配置包含的的信息,返回值将其命名为mainNormalizedRecord
    • 代码41行,循环路由配置信息并递归调用addRoute方法,
    • 代码19行,构建父子关系,如果有父亲,则把当前的record推入父亲的children中。

最后打印看看结果便于理解代码:

image.png

路由的响应式

假设用户调用APIpushreplace时,需要根据传入的路径匹配到对应的路由信息交给页面进行渲染,那么数据改变页面更新就代表着这个值必须是响应式的,先看代码,再解释。

// vue-router/index.js
...省略代码
const STARE_LOCATION_NORMALIZED = {
    // 初始化路由系统中的默认参数
    path: '/',
    // params: {}, // 路由参数
    // query: {}, // 查询参数
    matched: [] // 当前路径匹配到的record记录
};

function createRouter(options) {
    const matcher = createRouterMatcher(options.routes);
    const currentRoute = shallowRef(STARE_LOCATION_NORMALIZED); // 创建响应式数据,shallowRef进行浅代理
    const router = {
        install(app) {
            // 将STARE_LOCATION_NORMALIZED所有属性设置成computed
            const reactiveRoute = {};
            for (let key in STARE_LOCATION_NORMALIZED) {
                reactiveRoute[key] = computed(() => currentRoute.value[key]);
            }
        }
    };
    return router;
}
  • 代码13行,当路由跳转时都是覆盖赋值的,因此我们只需要对currentRoute进行浅代理。
  • 代码17行,当插件注册时,通过computed计算所有的currentRoute属性,这样做的好处是,我们结构reactiveRoute的属性时并不会失去响应式。

现在已经具备一个响应式的路由信息,当首次进入页面时,需要做两件事:

  1. 根据路径匹配路由信息。
  2. 将路由状态信息初始化到浏览器的history.state
function createRouter(options) {
    const matcher = createRouterMatcher(options.routes);
    const routerHistory = options.history;
    const currentRoute = shallowRef(STARE_LOCATION_NORMALIZED); // 创建响应式数据,shallowRef进行浅代理
    function finalizeNavigation(to, from, replace) {
        // 这里需要注入路由钩子,在跳转之前可以做拦截
        if (from === STARE_LOCATION_NORMALIZED || replace) {
            // 代表第一次跳转路由,调用重定向
            routerHistory.replace(to.path);  
        } else {
            routerHistory.push(to.path);
        }
        currentRoute.value = to; // 更新最新路径
    }
    function pushWithRedirect(to) {
        // 通过路径匹配对应的路由record记录,更新currentRoute
        const targetLocation = matcher.resolve(to);
        const from = currentRoute.value;
        finalizeNavigation(targetLocation, from);
    }
    // 路由跳转
    function push(to) {
        return pushWithRedirect(to);
    }
    const router = {
        push,
        install(app) {
            // 将STARE_LOCATION_NORMALIZED所有属性设置成computed
            const reactiveRoute = {};
            for (let key in STARE_LOCATION_NORMALIZED) {
                reactiveRoute[key] = computed(() => currentRoute.value[key]);
            }
            if (currentRoute.value === STARE_LOCATION_NORMALIZED) {
                // 如果等于默认的路由信息,代表是初始化时,路由系统需要进行一次跳转,让路径能匹配到路由
                push(routerHistory.location);
            }
        }
    };
    return router;
}

  • 代码3行,获取options.history实例,也就是第一章中实现的pushreplace方法和locationstate历史记录信息。
  • 代码33行,如果currentRoute.value为初始值,则代表第一次进入
    • 代码35行,调用push方法,默认执行一次路由跳转
    • 代码17行,调用匹配器的resolve方法匹配路由记录,稍后实现。
    • 代码5行,根据入参调用路由实例的pushreplace方法更新历史记录状态。
    • 代码13行,把匹配结果更新到currentRoute中。

接下来看看匹配器createRouterMatcher的匹配路由方法resolve实现

// 路由匹配器
function createRouterMatcher(routes) {
    const matchers = [];
    ...代码省略
    // 路由匹配
    function resolve(location) { // / => { path: '/', matched: [HomeRecord] } /a => { path: '/a', matched: [HomeRecord, aRecord] }
        const p = location.path;
        const matched = [];
        let matcher = matchers.find(item => item.path === p);
        while(matcher) {
            matched.unshift(matcher.record);
            matcher = matcher.parent;
        }
        return {
            path: p,
            matched,
        }
    }
    ...代码省略
    return {
        resolve
    };
  • 代码9行,find匹配到与之对应的路由记录
  • 代码10行,循环匹配到的路由记录,并取出parent记录,记住父记录必须要放在匹配结果的头部,因为渲染时是从头开始渲染的。

现在切换路由时已经拿到了对应的记录,还知道需要渲染的哪些组件,但还有一个弊端,就是用户操作浏览器前进后退时也需要响应式的匹配路由记录,所以就用到了第一章所实现的listen方法。

...省略代码

function createRouter(options) {
    let ready;
    function markAsReady() {
        if (ready) return;
        ready = true;
        routerHistory.listen((to) => {
            const targetLocation = resolve(to);
            const from = currentRoute.value;
            finalizeNavigation(targetLocation, from, true);
        });
    }
    function finalizeNavigation(to, from, replace) {
       ...省略代码
       // 如果是初始化,需要调用listen去监听浏览器前进后退,触发更新currentRoute,只执行一次
        markAsReady();
    }
}
  • 代码17行,因为每次路由跳转都会走到finalizeNavigation方法,但是我们只需要调用一次监听popstate,所以需要确保只执行一次。
  • 代码5行,markAsReady方法,进入后将ready修改,确保只执行一次代码,调用routerHistory实例的listen方法传入回调,当触发浏览器前进后退时重新匹配路由记录并更新信息。

最后将所有的路由属性和方法暴露给用户调用

const router = {
        push,
        install(app) {
            // 将STARE_LOCATION_NORMALIZED所有属性设置成computed
            const reactiveRoute = {};
            for (let key in STARE_LOCATION_NORMALIZED) {
                reactiveRoute[key] = computed(() => currentRoute.value[key]);
            }
            app.config.globalProperties.$router = router; // 路由的方法,包括push,replace
            Object.defineProperty(app.config.globalProperties, '$route', { // 路由的属性
                enumerable: true, // 可枚举
                get: () => unref(currentRoute)
            });
            app.provide('router', router); // 路由的方法,包括push,replace
            app.provide('route location', reactive(reactiveRoute)); // 路由的属性

            if (currentRoute.value === STARE_LOCATION_NORMALIZED) {
                // 如果等于默认的路由信息,代表是初始化时,路由系统需要进行一次跳转,让路径能匹配到路由
                push(routerHistory.location);
            }
        }
    };
  • 代码9行,将当前的router实例挂载到vue全局属性中
  • 代码10行,进行了一次拆包,目的是为了调用currentRoute时不需要再.value使用。
  • 代码14行,关于vue3composition API需要调用路由方法useRouteuseRouter来获取路由的属性和方法,其中就是使用provide注入,然后再调用useRoute时利用inject获取该方法的。