从0到0.8实现Vue-Router4-核心路由系统的实现(第一章)

285 阅读7分钟

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

课程目录

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

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

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

完整代码gitee地址

写在前面

相信有背过面试题的技术大佬们知道vue-router的基本原理

  • hash路由中通过修改hash值不会触发页面请求和重新加载来实现的,并监听hashchange事件更新路由。
  • HTML5 history是通过控制浏览器历史记录栈,通过pushStatereplaceState方法控制路由历史跳转实现不重新加载页面不请求服务器的动态加载,并通过popstate事件监听浏览器前进后退实现。

具体细节不再赘述,在vue-router4中,由于无需做兼容处理,所以已经放弃使用hashchang来实现hash路由了,而是统一通过pushStatereplaceState方法,通过浏览器控制台简单打印一下,看看是否可以实现hash路由。

image.png

因此,在vue-router4中统一使用pushStatereplaceState方法处理hash模式和history模式。

实现思路

在此之前我们简单了解一下两个浏览器的全局对象historylocation(部分属性方法):

  • window.history属性指向 History 对象,它表示当前窗口的浏览历史。

    • state 返回当前页面的状态对象,该对象是通过pushState()或replaceState()方法设置的。
    • length返回浏览器历史记录中的页面数量。
    • pushState 将新的状态添加到浏览器历史记录中,同时改变当前URL但不加载新页面。该方法接受三个参数:state(状态对象),title(标题,现在大多数浏览器都忽略这个参数),url(新的URL)。
    • replaceState 替换当前的状态对象,不会添加新的历史记录。该方法接受三个参数:state(状态对象),title(标题),url(新的URL)。
    • onpopstate 浏览器前进、后退按钮触发回调
  • window.location可以获取当前页面的地址信息,还可以修改某些属性,实现页面的跳转和刷新等。

    • pathname路径(以"/"开头)
    • search 查询字符串,以""开头
    • hash 页面锚点,以 "#"开头

要实现路由跳转,我们必须利用history.state这个属性去记录上一页、下一页我们需要的信息,并通过这些信息去加载对应的vue页面,参考一下官方vue-router中在history.state中保存了什么,启动一个vue项目,打印结果。

image.png

  • state
    • back 是否后退
    • current 当前路由路径
    • forward 前一个路由的路径(来源路径)
    • position 层级,根据history.length计算得出,history.length默认是从2开始
    • replaced 是否替换当前页面
    • scroll 记录滚动条位置

当我们在history.state中存储这些信息时,浏览器会帮我们保存好这些信息,并在对应的页面栈返回,我们拿到想要的数据去做相应的逻辑操作,所以要第一步需要实现history.state的存储读取管理逻辑。

具体实现

npm create vue@latest快速搭建一个项目,安装依赖并启动项目,删掉不必要的文件简化项目结构,在src目录下创建一个vue-router目录和index.js文件,目录结构如下

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

无特殊说明,所有代码编写在src/vue-router/history/html5.js文件下。

构建路由信息和路由状态

/**
 * 获取location URL信息
 */
function createCurrentHistoryLocation() {
    const { pathname, hash, search } = window.location;
    return pathname + hash + search;
}

/**
 * 路由状态导航
 */
function useHistoryStateNavigation() {
    // 路由信息
    const currentLocation = {
        value: createCurrentHistoryLocation()
    };
    // 路由状态
    const historyState = {
        value: window.history.state
    };
}

/**
 * 创建页面的路由函数入口
 */
function createWebHistory() {
    const historyNavigation = useHistoryStateNavigation();
}

  • 代码26行,createWebHistory是主入口,创建一个useHistoryStateNavigation函数,用于记录当前的路由和state信息
  • 代码12行,通过前面讲解的window.locationwindow.history两个全局对象获取当前路由信息和路由状态。
  • 代码15行和19行,因为我们需要在外部修改currentLocationhistoryState的值,所以需要用引用类型声明这两个变量。

初始化路由信息

...代码省略
/**
 * 路由状态导航
 */
function useHistoryStateNavigation() {
    // 路由信息
    const currentLocation = {
        value: createCurrentHistoryLocation()
    };
    // 路由状态
    const historyState = {
        value: window.history.state
    };

    /**
     * 路由跳转
     * @param {string} - to 跳转的路由路径
     * @param {object} - state 路由状态
     * @param {boolean} -isReplace 是否替换当前路由
     */
    function changeLocation(to, state, isReplace) {
        history[isReplace ? 'replaceState' : 'pushState'](state, null, to);
        historyState.value = state;
    }

    /**
     * 构建路由状态
     * @param {string} - forward 来源路由
     * @param {string} - current 当前路由路径
     * @param {string} - to 要跳转的路由路径
     * @param {boolean} - replaced 是否替换当前页面
     * @param {computedScroll} - 是否记录滚动条位置
     */
    function buildHistoryState(forward, current, to, replaced = false, computedScroll = false) {
        return {
            forward,
            current,
            to,
            replaced,
            scroll: computedScroll ? { left: window.scrollX, top: window.scrollY } : null,
            position: window.history.length - 1,
        };
    }
    if (!historyState.value) {
        const state = buildHistoryState(null, currentLocation.value, null, true);
        // 初始化,默认跳转一次,记录路由状态
        changeLocation(currentLocation.value, state, true);
    }
}
...代码省略
  • 代码44行,判断当前路由状态是否是空,如果为空,则进入初始化方法
  • 代码34行,声明一个buildHistoryState函数,用于构建标准的路由状态信息,其中包含的信息和文章开头打印的官方vue-router存储的路由状态结构保持一致,其中history.length属性是默认从2开始的,所以position需要-1,可以往上翻翻回忆一下。
  • 代码47行,构建完初始路由状态之后,利用replaceState方法将其同步到浏览器的history.state中,重新声明了一个changeLocation函数方便后续调用。

打开控制台,运行以下代码查看打印结果。

提供push和replace更新路由状态

/**
 * 路由状态导航
 */
function useHistoryStateNavigation() {
    ...省略代码
    /**
     * 跳转路由
     * @param {string} - to 跳转的路由路径
     * @param {object} - data 路由状态
     */
    function push(to, data) {
        // 需要先更新跳转前的状态,再进行跳转
        const currentState = Object.assign({}, historyState.value, data, {
            to,
            scroll: {left: window.scrollX, top: window.scrollY}
        });
        changeLocation(currentState.current, currentState, true);
        // 开始跳转新的路由
        const state = Object.assign({}, buildHistoryState(currentState.current, to, null, true), data, {
            position: currentState.position + 1
        });
        changeLocation(to, state);
        currentLocation.value = to;
    }
    /**
     * 替换路由
     * @param {string} - to 跳转的路由路径
     * @param {object} - data 路由状态
     */
    function replace(to, data) {
        const state = Object.assign({}, historyState.value, data, {
            current: to
        })
        changeLocation(to, state, true);
        currentLocation.value = to;
    }
    ...省略代码
    return {
        push,
        replace,
        location: currentLocation,
        state: historyState
    }
}
  • 代码11行,声明一个push方法,要实现路由跳转需要分为两个步骤

    • 跳转之前,需要先将即将跳转的状态replaceState同步给history.state,因为需要更新其中的to属性,将传入的data状态与当前状态进行合并,再将to修改为需要跳转的路径,存储当前的滚动条位置。
    • 跳转之后,buildHistoryState构建一个新的状与传入的data合并,更新position层级,pushState同步状态给history.state
  • 最后将这pushreplace方法和currentLocationhistoryState抛出提供给外部使用。

监听浏览器前进后退更新路由信息

上面已经实现了路由状态初始化,并且封装了更新路由状态的方法,但是当用户点击浏览器前进后退按钮时,也需要同步更新路由信息,继续完善代码。

/**
 * 监听路由变化
 */
function useHistoryListeners() {
    const listeners = []; // 存储listen Api的所有回调
    window.addEventListener('popstate', popStateHandler);
    function popStateHandler({ state }) {
        const to = createCurrentHistoryLocation();
        const from = currentLocation.value;
        const fromState = historyState.value;
        currentLocation.value = to;
        historyState.value = state;
        const isBack = state.position - fromState.position < 0;
        listeners.forEach(listen => listen(to, from, { isBack }))
    }
    // 实现listen Api
    function listen(callBack) {
        listeners.push(callBack);
    }
    return {
        listen
    }
}
/**
 * 创建页面的路由函数入口
 */
export function createWebHistory() {
    const historyNavigation = useHistoryStateNavigation();
    const historyListeners = useHistoryListeners();
    const historyRouter = Object.assign({}, historyNavigation, historyListeners);
    Object.defineProperty(historyRouter, 'location', {
        get: () => historyNavigation.location.value
    });
    Object.defineProperty(historyRouter, 'state', {
        get: () => historyNavigation.state.value
    });
    return historyRouter;
}
  • 代码29行,重新声明一个useHistoryListeners方法,功能区分
  • 代码6行,监听popstate方法,并传入回调函数popStateHandler
  • 代码7行,to获取当前最新的location信息,from就是currentLocation,注意当前currentLocationhistoryState还未被更新,所以能够取到旧值。
  • 代码11行,更新currentLocationhistoryState
  • 代码13行,根据新旧状态的position判断用户点击的是前进还是后退按钮
  • 代码17行,为了实现官方vue-router同样的Api,Api地址listen方法接收一个函数,将函数放进listeners存起来,当浏览器前进、后退时会触发,官方解释是:给历史实现附加一个监听器,当导航从外部被触发时 (像浏览器的前进后退按钮) 或者向RouterHistory.back 和 RouterHistory.forward 传递 true 时,监听器就会被触发。
  • 代码31行,做了一个优化,对于外部想要使用historyNavigation.locationhistoryNavigation.state都需要通过.value属性,所以用defineProperty进行了代理优化。
  • 最后30行,将所有属性和方法抛出给外部使用。

测试代码

<template>
    <button @click="clickFn('/A')">跳转A</button>
    <button @click="clickFn('/B')">跳转B</button>
    <button @click="clickFn('/C', true)">替换C</button>
</template>

<script setup>
import { createWebHistory } from '@/vue-router'
const router = createWebHistory();
function clickFn(path, isReplace) {
    router[isReplace ? 'replace' : 'push'](path);
}
</script>