写在开头
无论是日常业务还是面试过程中,相信大家对于前端路由这个话题或多或少都有自己的应用和理解。
或许你从未了解过 Vue-Router 底层源码实现,又或许你仅仅知道它是基于一系列 hashchange 、 popstate 事件劫持路径变化从而动态根据 js 内容渲染页面。
别担心,我会在这篇文章中跟随 Vue-Router 的源码,用最通俗易懂的方式从零到一带你打造一款属于你自己的 前端路由框架 。
从此无论是面试过程还是日常应用中,在理解了文章中的思路与代码之后,我相信在任何场景下只要提及前端路由相关话题,你都可以做到真正的游刃有余。
在开始实现之前我稍稍笔者自己稍微有一些心里话想要为大伙儿唠叨唠叨。
任何学习的过程都是枯燥且乏味的,但这恰恰是一种成长。
我们需要明白简单的东西背后一定不简单,就像我们家里拧开水龙头有自来水或者插上插座就有电,但后面供应自来水和电力的那套东西极其复杂。
文章中的确会有一些东西的确会很枯燥晦涩,但是这正是我写这篇文章的初衷,希望在加固自身对于知识的理解程度上同时为大家带来一份更加通俗易懂的源码解读文章。
相信我,在你真的理解它之后。你会觉得它的核心思路无非也不过如此。
文章的完整代码我已经放在了这个地址中,强烈建议大家可以对照代码来阅读文章
市面上存在很多关于前端路由的优秀框架,比如 React-Router、Vue-Router 等等之类。
我之所以选择 Vue-Router 的原因和大家展开前端路由的话题主要有两点:
国内大部分前端开发者对于 Vue 这个框架多少都会有了解,基于国内 Vue 的用户体量所以我选择 Vue-Router 这款优秀框架。
在进行路由分析时,我主要犹豫在 Vue-Router 和 React-Router 这两款优秀的框架之中,相较于 React-Router 我个人认为 Vue-Router 对外暴露的 API 更加利于用户。
所以自然而言,Vue-Router 的实现会相较于 React-Router 稍显复杂一些正是基于这个原因我想通过 Vue-Router 带大家彻底搞懂所谓前端路由的核心原理与实现。
之所以选择稍微比较旧版本的 vue-router,是希望更多人可以参与到文章之中。但是并不代表就 vue-router 已经过时没有必要学习,针对于 next 前后版本它们的实现原理相差无几。
当然,无论是 React-Router 还是 Vue-Router 亦或是其他任何路由框架,他们的核心实现原理都是大同小异的。
你可以对照
vue-router@3.5.3
源码来参考,文章中的代码会删除服务端渲染部分仅保留前端路由逻辑,和源码有部分出入。
工欲善其事,必先利其器。在开始首先让我们首先来创建基础的目录结构吧。
这里我利用 vue-cli 创建了一个基础的 vue 项目模板,接下来让我们为他稍加修改。
首先我们需要在 router/index.js
下拥有这样的一份路由嵌套配置列表:
同时我们需要创建对应的 about、home 文件目录以及内容:
具体的文件内容非常简单:
home-children/home1.vue
中它的内容如下:<template>
<div>
<h3>Home 1 Page!</h3>
</div>
</template>
类似 home2、about1、about2 也是同样的内容,不同的仅仅是 h3 标签中展示的内容是各自的文件名称。
你可以点击这里查看具体目录和展现效果 CodeSanBox 。
此时,基础的目录结构已经搭建完毕,让我们来看看页面展现效果:
看起来很简单吧,随着我们点击页面上的 router-link 标签页面路由变化从而渲染对应的组件。
接下来,我们就要一步一步来实现属于我们自己的 vue-router。
首先我们来看看,Vue-Router 在项目中是如何应用的:
// router/index.js
import VueRouter from 'vue-router';
// ...
Vue.use(VueRouter);
// ...
const router = new VueRouter({
mode: 'history',
base: process.env.BASE_URL,
routes,
});
// ...
export default router
首先这里我们通过 Vue.use(VueRouter) 调用 Vue 的 use() 方法来安装注册 VueRouter 插件。
那么就让我们先从 vue-router 的安装逻辑说起吧。
首先让我们现在项目的src
目录下创建一个文件夹以及两个文件:
src/vue-router
目录,用来存放我们自己实现的 vue-router 库。
src/vue-router/index.js
入口文件。
src/vue-router/install.js
安装注册 VueRouter 的注册方式。
让我们先来看看 src/vue-router/index.js
,在使用时我们通过 new VueRouter 的方式进行调用,由此可知入口文件中需要导出一个基础的 VueRouter 类对象:
class VueRouter {
constructor() {
// do something
}
}
export default VueRouter
此时再来让我们会到刚才新建的 install.js
中吧。
在 Vue 中如果你需要注册一款全局 Vue Plugin ,需要通过调用 Vue.use(Plugin) API。
稍微复习下,关于 Vue.use(Plugin) 的 Plugin,注册的 Plugin 需要满足:
当我们通过 Vue.use() 调用时,会调用对应注册插件的 install 方法,同时传入 Vue 构造函数对象作为参数。
在搞明白了 Vue.use() 方法之后,让我们来一步一步填充 install.js 中的逻辑吧:
// vue-router/install.js
let _Vue;
export default function install(Vue) {
// 如果已经安装过了
if (install.installed && _Vue === Vue) return
// 首次调用Vue.use(VueRouter)时,将install.installed变为true
install.installed = true
// 保存传入的Vue 提供给别的模块使用Vue
_Vue = Vue
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
// 如果当前options存在router对象 表示该实例是根对象
this._rootRouter = this
this._rootRouter._router = this.$options.router
} else {
// 非根组件实例
this._rootRouter = this.$parent && this.$parent._rootRouter
}
},
})
// 注册组件
Vue.component('router-link', Link)
Vue.component('router-view', View)
// 定义原型$router对象
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._rootRouter._router
}
})
// 定义原型$route对象
// to do ...
Object.defineProperty(Vue.prototype, '$route', {
get() {
return {}
}
})
}
首先我们先来完成一下最基础的 install.js,目前代码中的每一行我都已经进行了详细的注释,后续剩余未完成的逻辑,我会带你逐步为该方法补充相应的逻辑。
这里 install.js 中有一些逻辑我想刻意强调下:
首先,在 install 方法中我们提到过 Vue.use() 时会传入当前 Vue 的构造函数对象此时我们利用 _Vue 保存外部传入的 Vue ,这样可以提供给我们自己库中的任意模块获得当前版本的 Vue 对象。
在 install 方法中我们利用了 Vue.mixins API 为每一个通过该 Vue 创建的实例对象注入了一段 beforeCreate
的逻辑。
也许有朋友对于 beforeCreate 中做的事情不是很理解,最初看到源码这里我也稍微有点绕。但是没关系,让我为你稍微解读下这段逻辑:
首先在项目的入口文件中,通常是 main.js
中,我们会这样使用 router 实例对象:
import Vue from 'vue'
import App from './App.vue'
import router from './router'
Vue.config.productionTip = false
// 创建一个根Vue实例对象 同时传入创建好的router实例对象
new Vue({
router,
render: h => h(App)
}).$mount('#app')
我们在项目的入口文件中,如果使用到了 vue-router ,通常会将初始化后的 router 对象传入到 new Vue 的参数中去,此时在根组件实例上我们可以通过 this.$options.router
来获取到创建的 router 实例。
在 install 中我们为每一个组件实例通过 mixin 注入了一个 beforeCreate 钩子,在每个组件实例创建之前我们进行判断:
this.$options
上存在 router 对象上, 此时该组件是根组件对象。我们在根组件实例对象上定义了一个 _rootRouter 对象,为自身实例对象。
同时为自身实例上定义了一个 _router 属性,它的值即是我们外部传入的 router 实例对象 this.$options.router
。
Vue 组件的创建过程是从父组件到子组件的过程,简单来说也就是每次组件渲染时首先会执行根组件混入的 beforeCreate 逻辑,之后在执行子组件的 beforeCreate 逻辑。
也就是说每次子组件创建时会在自身挂载一个 _rootRouter 属性,它会指向根组件实例上对象,你可以将这个过程想象成为一个圣诞树。
你也许会好奇为什么我们需要这样做,别着急我们来看后边的这段代码:
//...
// 定义原型$router对象
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._rootRouter._router
}
})
// ...
我们在 Vue.prototyep 原型对象上定了一个名为 router 的 get() 属性,任何组件实例对象上都可以通过 this.router 访问到根组件初始化时传入的 router 对象。
之所以上边这样做是为了统一调用 API 方法,任何组件可以通过 this._rootRouter 访问到根组件,自然而言我们就可以通过 this._rootRouter._router 获取到传入的 router 实例。
看到这了,你也许会稍微反应过来一些。平常我们在代码中使用的 this.$router 其实就是通过 Object.defineProperty 代理到的根组件的 router 对象。
在使用 Vue-Router 时,它会帮我们注入两个组件分别是:
<router-view></router-view>
<router-link></router-link>
我们会在之后实现这两个组件的具体逻辑,这里仅仅需要明白是在 install 方法中对这两个组件进行了注册即可。
同时让在 src/vue-router/components
中建立这两个组件对应的文件 link.js/view.js
。
同时我们可以看到,在 install 方法的结尾我们定义了各个组件实例上的 router 和 route 两个属性的代理。
此时,install 方法的基础逻辑已经完成了。我们将 install 方法导出给 index.js
中的 VueRouter 类使用:
import install from './install';
class VueRouter {
constructor() {
// do something
}
}
VueRouter.install = install;
export default VueRouter;
此时,当我们调用 Vue.use(VueRouter) 时,会调用 install 方法。注册完成后:
$router
获取创建的 VueRouter 实例对象。
$route
属性,当然这里我们还没有实现。
routerView
以及 routerLink
标签。接下来让我们回到 vue-router/index.js
文件中来,继续完善它的逻辑。
上边的代码是通常在我们使用 VueRouter 时,初始化的方式。
可以看到,在初始化 new VueRouter 时传递了三个参数:
接下来让先来为它填充一些实现逻辑:
import { createMatcher } from './crate-matcher';
import install from './install';
class VueRouter {
constructor(options) {
this.options = options;
// 创建路由匹配器
this.matcher = createMatcher(options.routes || []);
// 获取路由模式 默认是hash
const mode = options.mode || 'hash';
switch (mode) {
case 'hash':
// do something
break;
case 'history':
// do something
break;
}
}
}
VueRouter.install = install;
export default VueRouter;
首先,我们在 VueRouter 的构造函数中:
接下来我会带你先去看看 createMatcher 方法。
首先让我们在 vue-router
目录下创建一个 createMatcher.js
文件。
在进入实现这个函数之前,我会简单和你聊聊这个函数的目的是要做什么。
通常我们在 new VueRouter(options)
时,传入的是一个拥有 children 的嵌套结构的路由映射表。
我们正是需要 createMatcher 方法将传入的多维度路由数据表格式化成为一维列表,比如我们上方配置的:
可以看到它是一个嵌套结构,VueRouter 这样设计是为了开发者在开发时拥有更加直观的路由嵌套结构,它在源码中是将多维度的嵌套结构展开变成一维映射表方便后续处理。
比如上边的结构会转变成为:
{
'/': {
component: Home,
path: '/',
name: '/',
},
'/home1': {
component: Home1,
path: '/home1',
name: 'Home1'
}
// ...
}
同时在 createMatcher 方法中也会定义一系列 API 暴露出来,比如:
addRoute()
添加一条新路由规则。
addRoutes()
动态添加更多的路由规则。参数必须是一个符合 routes
选项要求的数组。
getRoutes()
获取所有活跃的路由记录列表。
match()
根据传入路径,获得当前路由对象匹配的所有记录对象。
上边我们说到过 createMatcher 方法会将外部传入的路由数据表进行扁平化,自然他的内部也会维护一份扁平化后的路由列表。
那么此时,如果需要寻找路径匹配的路由记录或者动态注册路由,自然都是对于映射表数据结构的增删改查操作就可以快速实现内容。
接下里就让我们去实现这个 createMatcher 这个函数。
import createRouteMap from './create-router-map';
/**
*
*
* @export
* @param {*} routes 初始化时传入的路哟配置列表
*/
export function createMatcher(routes) {
// 首先初始化需要格式化路由对象 将传入的路由列表进行扁平化
const { pathList, pathMap, nameMap } = createRouteMap(routes);
// 动态注册单个路由 本质上还是参数的重载
// 当动态注册单个路由时 支持覆盖同名路由
// 同时注册单个路由支持指定在特定的路由中添加子路由 支持parent参数
function addRoute(parentOrRoute, route) {
// 如果第一个参数传递了非Object对象,那么表示它不是路由对象 代表传递的是对应的parent路由的名称
const parent =
typeof parentOrRoute !== 'object' ? nameMap[parentOrRoute] : undefined;
return createRouteMap(
[route || parentOrRoute],
pathList,
pathMap,
nameMap,
parent
);
}
// 动态注册多个路由
function addRoutes(routes) {
return createRouteMap(routes, pathList, pathMap, nameMap);
}
// 获取当前所有活跃的路由记录
function getRoutes() {
return pathList.map((path) => pathMap[path]);
}
// TODO:通过路径寻找当前路径匹配的所有record记录
function match() {
// do something
}
return {
addRoute,
addRoutes,
getRoutes,
match,
};
}
在 createMatcher
方法内部抽离了数据格式转化的逻辑,它在方法内部调用了 createRouteMap(routes)
方法,传入初始化时的路由配置列表。
createRouteMap(routes)
是真正扁平化路由列表的方法,稍微我会带你深入这个方法。此时,我们仅仅专注于 createMatcher
方法内部的逻辑即可。
上边我们提到过,在 createMatcher 方法中需要维护一份格式化后的路由映射表以及对应的路由方法。
接下来我为你解释一下这个函数内部的详细内容:
首先在创建 matcher 的开头,用户传入的静态路由配置表通过 createRouteMap 函数进行扁平化并且返回了三个值分别为:
pathList
这是一个包含当前所有路由路径的数组,比方上边我们的路由配置表格式化的 pathList 便是['/home1', '/home2', '/', '/about/about1', '/about/about2', '/about']
。
pathMap
这是一份包含当前所有路由的映射表对象,它类似于这样的结构:
{
"/home1": {
"name": "Home1", // 路径对应的路由名称
"component": {}, // 路径对应的渲染组件
"path": "/home1", // 路由路径
"props": {}, // 路径对应的组件props
"meta": {}, // 路径对应的组件meta
"parent": { ... } // 该路径的父路由记录对象
},
...
}
通过 createRouteMap 方法返回的 pathMap 正是维护着这样一份路由路径为 key,路径记录对象为 value 的映射表。
所谓路径记录 Record 对象即是表示 pathMap 中对应的 value 值。
nameMap
nameMap 与 pathMap 同理,它维护的是一个份名称映射表:
{
"Home1": {
"name": "Home1",
"component": {},
"path": "/home1",
"props": {},
"meta": {},
"parent": { ... }
},
// ...
}
可以看到在 createMatcher 函数中做的事情是非常纯粹的,通过这个函数我们创建了一个匹配器。
匹配器内部会维护处理后的路由数据结构,同时暴露方法提供给外部使用。,至于处理数据的细节 createMatcher 匹配器函数并不关心如何实现。
这一步我们在 VueRouter 的构造函数初始化时,通过 this.matcher = createMatcher(routes) 方法为 VueRouter 后续实例对象定义了一个 matcher 匹配器属性。
它的内部维护了格式化后的路由匹配列表以及暴露出对应可以修改路由列表的 API 。
VueRouter 中的 createMatcher 源码你可以在这里看到,它的内部还会处理一些关于 alias、redirect 之类的逻辑。
上边通过 createMatcher 方法在 VueRouter 实例中创建了一个匹配器对象,在 createMatcher 函数中正是通过 createRouteMap 方法来格式化路由对象的,接下里让我们一步一步来实现这个方法。
首先 createRouteMap 方法上边提到过它需要暴露三个数据对象,分别是:
在 createMatcher 匹配器章节我已经和大家探讨过这三个对象分别代表的含义,这里我就不在累赘了。
同时在 createMatcher 方法中,有以下几个地方用到了 createRouteMap 方法:
在搞清楚了 createRouteMap 内部需要做的事情之后让我们来一起来实现这个方法,首先在 vue-router
目录下创建 create-router-map.js
:
/**
* @export
* @param {*} routes 需要注册的路由表(未格式化)
* @param {*} oldPathList 已经格式化好的路径列表
* @param {*} oldPathMap 已经格式化好的路径关系表
* @param {*} oldNameMap 已经格式化好的名称关系表
*/
export default function createRouteMap(
routes,
oldPathList,
oldPathMap,
oldNameMap,
parentRoute
) {
// 获取之前的路径对应表
const pathList = oldPathList || [];
// 创建本次格式化的 pathMap 和 nameMap 对象
const pathMap = oldPathMap || Object.create(null);
const nameMap = oldNameMap || Object.create(null);
// 递归格式化路径记录
routes.forEach((route) => {
addRouteRecord(pathList, pathMap, nameMap, route, parentRoute);
});
return {
pathList,
pathMap,
nameMap,
};
}
首先你可以看到 createRouteMap 支持传入:
createRouteMap 函数内部逻辑也非常简单,它对于在内部初始化了一系列 pathList、pathMap、nameMap 对象。
同时对于本次需要注册的对象数组 routes 中每一个路由对象调用 addRouteRecord 方法进行格式化路由处理,这是一个递归的过程。
在 createRouteMap 函数内部将递归处理数据的过程单独抽离成为了一个 addRouteRecord 方法,它的作用就是根据传入的 route 对象,更新 pathList,pathMap,nameMap 的值。
我们先来看看它的实现:
function addRouteRecord(pathList, pathMap, nameMap, route, parent) {
const { path, name } = route;
const normalizedPath = normalizePath(path, parent);
// 根据route构造record对象
const record = {
name: route.name,
component: route.component,
path: normalizedPath,
props: route.props || {},
meta: route.meta || {},
parent,
};
// 递归添加children属性
if (route.children) {
route.children.forEach((child) => {
addRouteRecord(pathList, pathMap, nameMap, child, record);
});
}
// 不存在则添加进入pathMap
if (!pathMap[record.path]) {
pathList.push(record.path);
pathMap[record.path] = record;
}
// 不存在则添加进入nameMap
if (name) {
if (!nameMap[name]) {
nameMap[name] = record;
}
}
}
我们一起来看看 addRouteRecord 方法的具体实现过程,这个方法内部接受的参数在上边我们已经啰嗦过很多次了。
首先这个方法内部获取到传入的 route 对象的 path 属性和 name 属性,关于 normalizePath
方法之后我们回去实现它,它的作用即是之前提到过关于嵌套路由的路径拼接。
这里你仅仅需要了解通过 normalizePath 方法我们获取到了当前 route 对象格式化后的路径 normalizedPath 。
之后我们根据本次传入的 route 对象创建了一个路由记录对象,我们称它为 Record 。
如果传入的 route 存在 children 属性的话递归调用该方法将 route.children 中的路由对象创建 Record 添加进入 pathList,pathMap 以及 nameMap 中去。
在之后的逻辑就很简单了,判断对应 pathMap 与 nameMap 中是否已经存在当前路由对象了,如果不存在时则进行添加。
此时我们再回过头来看看 normalizePath
方法:
/**
*
* 格式化路径 主要用于拼接嵌套路由的路径
* @param {*} path
* @param {*} parent
* @returns
*/
function normalizePath(path, parent) {
// 如果不存parent记录
if (!parent) {
return path;
}
// 如果path以/开头 表示不需要拼接路径
if (path.startsWith('/')) {
return path;
}
// 判断parent.path 是否以/结尾
if (parent.path.endsWith('/')) {
return `${parent.path}${path}`;
} else {
return `${parent.path}/${path}`;
}
}
这个方法做的其实很简单,通俗来说就是格式化路径,对于嵌套路由进行路径拼接。
到这一步我们的 createRouteMap 会根据传入的参数最终将路由进行格式化,比如文章开头我们 Deom 中传入的路由配置列表在格式化后会返回以下数据:
通过 createRouteMap 方法会返回这三个主要对象提供给 createMatcher 函数。
同时 createMatcher 内部会维护这份格式化后的路由映射表,并且暴露出一系列 API 提供提供给开发者来操作 router 中维护的这份路由映射表。
此时让我们来回到最初的 vue-router/index.js
中来完善这几个方法:
import { createMatcher } from './crate-matcher';
import install from './install';
class VueRouter {
constructor(options) {
this.options = options;
// 创建路由匹配器
this.matcher = createMatcher(options.routes || []);
// 获取路由模式 默认是hash
const mode = options.mode || 'hash';
switch (mode) {
case 'hash':
// do something
break;
case 'history':
// do something
break;
}
}
// 注册多个路由
addRoutes(routes) {
this.matcher.addRoutes(routes);
}
// 注册路由
addRoute(parentOrRoute, route) {
this.matcher.addRoute(parentOrRoute, route);
}
// 根据获取当前所有活跃的路由Record对象
getRoutes() {
return this.matcher.getRoutes();
}
}
VueRouter.install = install;
export default VueRouter;
我们在 VueRouter 类上分别定义了三个原型方法,它们内部都是通过调用 this.matcher 进行实现的。
首先恭喜大家可以坚持到这里,在之前我们完成了 VueRouter 中初始的逻辑:
在创建 VueRouter 实例对象时格式化传入的 routes 路由表,同时在 VueRouter 原型上定义 addRoute、addRoutes 等方法修改内部路由表从而实现动态增加路由的效果。
如果你对之前的逻辑存在疑惑,那么此时我建议你稍稍回头在读一读文章中不太明白的点。
之前我们所的事情主要就是一点:格式化外部传入的路由表,创建对应的路由映射记录关系。
在之前,我们获得了格式化后的路由映射表,接下来就让我们去实现核心的跳转逻辑吧。
当前前端路由主要分为两种类型
传统的前端路由代表主要就是 hash 模式,在 URL 通过 onhashchange 事件从而根据路由匹配到的 record 对象动态渲染页面,这种方式最直接的体现就是路由路径上会出现 # 显得非常丑陋。
另一种比较火热的模式即是基于 HTML History API 进行的 H5 路由,基于 window.onpopstate 事件,每当当前激活状态的历史记录发生变化时触发 window.popstate 事件从而执行相应的逻辑从而渲染页面。
关于 H5 History 模式的路由方式,是额外需要服务端支持的。因为基于这种前端路由的方法,当路由发生变化有时会是会去发送完整路径去请求服务端,而 hash 则不会。
这两种模式更多的区别你可以参考 MDN History API 查阅。
首先,让我们回到 vue-router/index.js
中,来补充之前遗留关于 mode 的处理:
import { createMatcher } from './crate-matcher';
import { HashHistory } from './history/hash';
import { HTML5History } from './history/html5';
import install from './install';
class VueRouter {
constructor(options) {
this.options = options;
// 创建路由匹配器
this.matcher = createMatcher(options.routes || []);
// 获取路由模式 默认是hash
const mode = options.mode || 'hash';
this.mode = mode;
switch (mode) {
case 'hash':
this.history = new HashHistory(this);
break;
case 'history':
this.history = new HTML5History(this);
break;
}
}
...
}
VueRouter.install = install;
export default VueRouter;
这里我们会根据不同的 mode 从而创建不同的路由方式创建不同的类实例方法赋值给 this.history 属性。
之所以将 hash 和 history 模式的实例对象都定义给 this.history 属性,是因为针对于两种不同的路由方式我们希望提供给外部的 API 是一致的。
比如我们可以统一调用 this.histroy.push 方法进行跳转,而在各自的类实现中分别实现自身的 push 方法中的不同逻辑即可。
接下来让我们去实现对应的 HashHistroy 和 HTML5History 这个两个类。
首先让我们在 vue-router
中创建一个 history
文件夹来存放对应的路由类:
vue-router/history/base.js
export class BaseHistory {
// ...
}
vue-router/history/hash.js
import { BaseHistory } from './base';
export class HashHistory extends BaseHistory {
// ...
}
vue-router/history/html5.js
import { BaseHistory } from './base';
export class HTML5History extends BaseHistory {
// ...
}
我们分别创建了三个文件 base.js
、 hash.js
以及 html5.js
。
可以看到 HashHistory 和 HTML5History 都继承了 BaseHistory 这个父类,这样做的好处是我们在上层子类中定义各自模式下不同的细节实现而在 BaseHistory 抽离相同的逻辑分别继承给两个子类进行调用。
在开始完善详细的路由逻辑之前我们来考虑这样一件事情,通常在首次加载页面时需要初始化路由,也就是所谓的监听 URL 变化执行跳转逻辑等等一系列操纵。
此时让我们重新回到 vue-loader/install.js
中先来初始化路由吧。
let _Vue;
export default function install(Vue) {
// ...
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
// 如果当前options存在router对象 表示该实例是根对象
this._rootRouter = this;
this._rootRouter._router = this.$options.router;
// 调用 _router 实例上的init方法初始化路由
this._router.init(this);
} else {
// 非根组件实例
this._rootRouter = this.$parent && this.$parent._rootRouter;
}
},
});
// ...
}
此时我们在 install 方法中的 mixin 中添添加了一段
这里我们将路由初始化的逻辑放在根组件的 beforeCreate 中,也就是当该 Vue 根组件实例传入 router 属性时,我们会在当前根组件 beforeCreate 时调用 init 方法来初始化路由。
接下来让我们一起来看看 VueRouter 的 init 方法吧。
// ...
class VueRouter {
// ...
// 定义初始化路由方法
init(app) {
this.app = app;
const history = this.history;
// 路由变化监听函数
const setupListeners = (route) => {
history.setupListeners();
};
// 初始化时 首先根据当前页面路径渲染一次页面
history.transitionTo(history.getCurrentLocation(), setupListeners);
}
}
我们在 VueRouter 上定义了一个实例方法 init ,它接受传入的根组件实例对象。
我们来稍微看一下它的逻辑,首先 init 方法保存了外部传入的组件实例 this.app ,同时获取到的我们在初始化时得到的 this.history 对象,此时虽然 this.history 我们仅仅填充了骨架,不过没关系之后我们会一步一步来补充它。
可以看到首先我们定义了一个 setupListeners 方法,这个方法内部调用了 history.setupListeners() 。
所谓 history.setupListeners() 正是监听页面路径变化的事件监听函数,针对不同的路由模式存在不同的监听事件 API 。
我们在之前定义了公用的 Base class 以及两个子类 HashHistory、HTML5History,不难想到针对 history.setupListeners() 不同的模式需要有各自不同的监听函数,所以这个方法应该放在各自子类上去实现是最好不过的。
此时我们再来看看 history.transitionTo 方法,这个方法是 VueRouter 路由跳转的核心方法。
它接受两个参数分别是:
在首次初始化页面路由时,在 init 方法上首先定义了一个路由变化监听函数。
其次在首次打开页面时我们需要跳转到当前页面匹配的路由来渲染对应的组件。
接下来我们去挨个填补 init 函数中缺失的逻辑:
首先我们先来看看 hash.js
文件:
import { BaseHistory } from './base';
export class HashHistory extends BaseHistory {
constructor(router) {
super(router);
// 初始化hash路由时 确保路由存在#并且 #后一定是拼接/
ensureSlash();
}
// 推入记录跳转方法
push(location) {
this.transitionTo(location, (route) => {
// location
window.location.hash = route.path;
});
}
// 替换当前路由记录跳转
replace(location) {
this.transitionTo(location, (route) => {
window.location.replace(route.path);
});
}
// 设置监听函数
setupListeners() {
// 源码中hash路由做了判断优先使用 popstate 不支持情况下才会考虑 hashchange
// const eventType = supportsPushState ? 'popstate' : 'hashchange'
// 这里Demo为了简化逻辑直接使用hashchange
window.addEventListener('hashchange', () => {
// 当路由变化时获取当前最新hash进行跳转
this.transitionTo(getHash());
});
}
// 获取当前#之后的路径
getCurrentLocation() {
return getHash();
}
}
/**
* 确保路由
*
* @returns
*/
function ensureSlash() {
const path = getHash();
// 如果 # 之后是以 / 开头,return true 什么操作都不进行
if (path.charAt(0) === '/') {
return true;
}
// 如果getHash() 返回以非 / 开头
replaceHash('/' + path);
return false;
}
/**
* 比如传入
* 首先获取当前#前的基础路径 比如http://hycoding.com/#/a/b
* 此时 base为 http://hycoding.com/
* 之后使用传入的路径拼接 `${base}#${path}` 返回
* @param {*} path
* @returns
*/
function getUrl(path) {
const href = window.location.href;
const i = href.indexOf('#');
const base = i >= 0 ? href.slice(0, i) : href;
return `${base}#${path}`;
}
// 替换当前页面路径
function replaceHash(path) {
window.location.replace(getUrl(path));
}
/**
* 获取当前页面中#之后的路径
* 比如 http://hycoding.com/#/a/b 则会返回 /a/b
* 如果页面当前url不存在 # 那么直接返回 ''
* @export
* @returns
*/
export function getHash() {
let href = window.location.href;
const index = href.indexOf('#');
if (index < 0) return '';
href = href.slice(index + 1);
return href;
}
#
之后的路径,同时 setupListeners 内部设置了监听函数:每当页面路径发生变化时会调用 this.transitionTo 更新页面。
到此对于 hash.js
中的逻辑已经基本实现,它支持了 VueRouter 中 init 方法的:
同时,HashHistory 也提供了两个跳转方法分别为 push、replace 方法提供通过 JS API 跳转的方式。
在实现了 hash.js
之后,让我们继续来填补 base.js
中的逻辑。
BaseHistory 它主要就是基于 transitionTo 方法实现路由的核心核心跳转逻辑。
export class BaseHistory {
constructor(router) {
this.router = router;
// 表示当前路由对象 初始化时会赋予 / 未匹配任何路由
this.current = createRoute(null, {
path: '/',
});
}
// 核心跳转方法
transitionTo(location, onComplete) {
// 寻找即将跳转路径匹配到的路由对象
const route = this.router.matcher.match(location);
// 禁止重复跳转
if (
this.current.path === route.path &&
route.matched.length === this.current.matched.length
) {
// 这里不仅仅判断了前后的path是否一致
// 同时判断了匹配路由对象的个数
// 这是因为在首次初始化时 this.current 的值为 { path:'/',matched:[] }
// 假如我们打开页面同样为 / 路径时,此时如果单纯判断path那么就会造成无法渲染
return;
}
this.updateRoute(route);
onComplete && onComplete(route);
}
// 更新current的值
updateRoute(route) {
this.current = route;
}
}
new BaseHisory 时,会通过 createRoute 方法初始化一个路由对象,createRoute 方法返回的是一个全量匹配的路由记录。
比方说,文章开头的配置表中如果访问 /about/about1
记录,那么根据路由的嵌套规则会匹配到两条路由记录。分别是父路由 /about
对应的 Record 对象以及自身路由 /about/about1
对应的 Record 对象。
总之,在 new BaseHisotry 时,我们通过 createRoute 方法创建了一个初始的默认路由匹配列表将它赋给 this.current。
在继续往下之前我们先来看看 createRoute 方法,上边提到过这个方法需要实现的作用即是接受传入路由 record 对象以及 location 对象(这里 location 我们暂时仅考虑 path 和 name 属性)返回一个路由匹配映射的数组。
细心的小伙伴也许会想起来,在之前 VueRouter 上的 matcher 匹配器属性中也维护了一份路由映射表。
这里它们的区别主要是:
也许此时你仍然不太明白 createRoute 究竟是怎么一回事,没关系。我先带你来实现这个方法:
/**
*
* 寻找完全匹配的路由对象 比如 /about/about1
* 它会匹配出两个Record路由对象 [{ path:'/about', ... },{ path:'/about/about1',... }]
* @export
* @param {*} record 路由记录对象 (通过 matcher 匹配器属性中维护的列表获取)
* @param {*} location 用户传入的参数
* @returns
*/
export function createRoute(record, location) {
const matched = [];
if (record) {
while (record) {
// 首部添加
matched.unshift(record);
// 依次递归寻找父路由记录
record = record.parent;
}
}
return {
matched,
...location,
};
}
这个方法内部会递归当前路由匹配的 record 对象,寻找当前路径匹配的所有路由对象。
VueRouter 中最核心的跳转方法就是 transitionTo 方法,这里我将它进行了简化:
// 核心跳转方法
transitionTo(location, onComplete) {
// 寻找即将跳转路径匹配到的路由对象
const route = this.router.matcher.match(location);
// 禁止重复跳转
if (
this.current.path === route.path &&
route.matched.length === this.current.matched.length
) {
// 这里不仅仅判断了前后的path是否一致
// 同时判断了匹配路由对象的个数
// 这是因为在首次初始化时 this.current 的值为 { path:'/',matched:[] }
// 假如我们打开页面同样为 / 路径时,此时如果单纯判断path那么就会造成无法渲染
return;
}
if (route) {
this.updateRoute(route);
onComplete && onComplete(route);
}
}
无论是调用 push、replace JavaSCript API 还是直接修改 URL 地址,核心页面路由变化就是这个 transitionTo 方法。
在它的内部首先通过 this.router.matcher.match(location)
寻找当前需要跳转的 location 匹配的路由记录。
在 vue-router/index.js
的 class VueRouter 上的匹配器属性 matcher 上还遗留了一个没有实现的 match 方法。
此时,让我们回到 vue-router/crate-matcher.js
中来完善这个方法:
export function createMatcher(routes) {
// ...
// 通过路径寻找当前路径匹配的所有record记录
function match(location) {
// 判断传入的location是否为字符串 如果为字符串则表示是通过路径跳转
// 如果为字符串则格式化location返回一个{ path:location } 对象 否则 返回location本身
const next = typeof location === 'string' ? { path: location } : location;
const { name, path } = next;
// 如果存在name属性,那么优先会去nameMap中查找当前name对应的Record路由记录
if (name) {
const record = nameMap[name];
// 如果没有匹配的路由记录
if (!record) return createRoute(null, location);
// 返回时调用createRoute方法 返回完全匹配的路由映射数据(包含嵌套节点)
return createRoute(record, next);
} else if (path) {
// 不存在name时则会寻找path对应的Record对象
const record = pathMap[path];
if (!record) return createRoute(null, location);
// 返回时调用createRoute方法 返回完全匹配的路由映射数据(包含嵌套节点)
return createRoute(record, next);
}
}
return {
addRoute,
addRoutes,
getRoutes,
match,
};
}
这里我们填充了 match 方法的逻辑。
首先这个方法内部会格式化参数 location,同时根据格式化后的 next 对象寻找对应的 Record 对象。
同时通过调用 history/base.js
中的 createRoute 方法寻找全量匹配的路由对象列表。
此时在 transitionTo 函数内部即通过 this.router.matcher.match(location)
获取到了即将跳转路由的匹配对象。
举个例子,假如我们调用 this.$router.push('/about/about1')
,相当于在 transitionTo 方法内部会进行 transitionTo('/about/about1',() => window.locaiton.hash = '/about/about1')
。
此时 this.router.matcher.match(location)
会返回这样一个对象:
刚才我们说到过 this.router.matcher.match 内部仍然会调用 createRoute 方法来包裹 Record 路由记录。
this.router.matcher.match(location)
返回的匹配对象中目前会存在两个属性:
当然这里的 location 是用户传入的参数,比如我们调用
this.$router.push({name:'19Qingfeng',params:{ age:23 }})
此时{name:'19Qingfeng',params:{ age:23 }
就是 location 参数。
此时在 transitionTo 方法中我们可以拿到即将跳转到的路由对应的 Record 路由记录列表。
之后我们会判断是否是重复跳转,如果是重复跳转那么函数会终止执行。
需要额外注意的是这里在判断重复跳转时,并没有单纯的使用路径进行判断。 比如在首次打开页面时,我们在 BaseHistory 规定的初始化路径 this.current 中的 path 是 / ,同时匹配到的路由对象为 []
,此时它是一个这样的对象:
如果单纯使用路径判断的话,首次页面加载 router.init 方法调用时,如果同样访问 / 路径,使用路径判断的话会一直被认为重复路径从而无法渲染正确页面。
之后的逻辑就非常简单了,当调用 transitionTo 方法时我们得到了匹配到的所有 Record 记录赋值给 route 变量,判断如果没有重复跳转那么即会更新 this.current 的值。
transitionTo 方法中最核心的逻辑就是每当该方法被调用,更新 this.current 的值。
这里我在 updateRoute
中增加一句打印:
// ...
// 更新current的值
updateRoute(route) {
// 每次更新 this.current 时都会打印最新的this.current
this.current = route;
console.log('更新后的 current', this.current);
}
// ...
来看看此时页面实现的效果:
每次页面的 URL 变化时,页面都会打印出匹配到所有路由对象以及当前路由属性。
在有了 hash.js
的实现基础下,html5.js
的实现稍微能简单一些。
在 HTML5History 中我们仅需要提供了和 HashHistory 相同的 API ,只是具体的实现逻辑将 hashchange 替换成为 popState 等 HTML5 History API。
import { BaseHistory } from './base';
export class HTML5History extends BaseHistory {
constructor(router) {
super(router);
}
// 推入记录跳转方法
push(location) {
this.transitionTo(location, (route) => {
window.history.pushState({}, null, route.path);
});
}
// 替换当前路由记录跳转
replace(location) {
this.transitionTo(location, (route) => {
window.history.replaceState({}, null, route.path);
});
}
// 设置监听函数
setupListeners() {
// 源码中hash路由做了判断优先使用 popstate 不支持情况下才会考虑 hashchange
// const eventType = supportsPushState ? 'popstate' : 'hashchange'
// 这里Demo为了简化逻辑直接使用hashchange
window.addEventListener('popstate', () => {
// 当路由变化时获取当前最新hash进行跳转
this.transitionTo(window.location.pathname);
});
}
// 获取当前#之后的路径
getCurrentLocation() {
return window.location.pathname;
}
}
这里的逻辑和 hash 是类似的,我就不过多累赘了。还是定义了一系列的实例方法,唯一不同的是就是将 hash 对应的 API 替换成为了 HTML 5 History API 。
在上边我们已经在每次页面 URL 发生变化时,BaseHistory 中的 current 属性都会发生变化。
current 中保存着当前路径内所有的路由信息以及当前路径匹配到的所有 Vue 组件。
接下来我们需要做的即使将 current 的值变为响应式数据,每当 current 发生变化时页面需要重新渲染。
无疑接下来我们需要将 current 处理成为响应式数据,让我们回到 vue-router/install.js
中:
// vue-router/install.js
...
export default function install(Vue) {
// ...
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
// 如果当前options存在router对象 表示该实例是根对象
this._rootRouter = this;
this._rootRouter._router = this.$options.router;
// 调用 _router 实例上的init方法初始化路由
this._router.init(this);
// 当根组件挂载 _router 时候 我们在根组件上定义了一个_route响应式属性 初始值为 this._router.history.current
Vue.util.defineReactive(this, '_route', this._router.history.current);
} else {
// 非根组件实例
this._rootRouter = this.$parent && this.$parent._rootRouter;
}
},
});
// ...
}
在根组件 new Vue 时如果传递了 router 属性的话,我们会在初始化路由之后通过 Vue.util.defineReactive(this, '_route', this._router.history.current)
为根组件实例对象上定义了一个 _route 属性,值为 BaseHistory 中的 current 属性,即为 this._router.history.current 。
在 install 方法中,我们说到过通过 Vue.mixin 中的 beforeCreate 生命周期,我们将根组件实例暴露给了每个子孙组件可以通过 this.rootRouter 属性访问根组件的实例对象。
同理,既然所有组件都可以通过 this.rootRouter 访问到根组件实例,那么同样可以通过 this.rootRouter._route 访问到当前路由对象 current 。
所谓的 current 即是我们日常使用的 $route 对象,让我们继续来补充 install.js
中的内容:
export default function install(Vue) {
//...
// 定义原型$router对象
Object.defineProperty(Vue.prototype, '$router', {
get() {
return this._rootRouter._router;
},
});
// 定义原型$route对象
Object.defineProperty(Vue.prototype, '$route', {
get() {
return this._rootRouter._route;
},
});
// ...
}
同样我们通过 Object.definedProperty 在 Vue.prototype 上定义了 $route 属性,代理访问到了各个实例上的 this._rootRouter._route 的值。
此时我在 App.vue 的 created
生命周期中打印 this.$route
来看一看结果:
我们可以看到此时打印的 $route 属性存在了 get/set 变成了一个响应式数据。
此时我们的确通过 Vue.util.defineReactive 将 $route 定义成为了响应式对象,不过当路径改变或者通过 JavaScript API 调用 push 等方法时:
这个时候改变的是 BaseHistory 中的 current 属性的值,根组件实例上的 this._route 值并不会被改变。
他俩是完全不同的对象,在 install 方法初始化的逻辑中,你可以理解 Vue.util.defineReactive 将 this._router.history.current 的值做了一层深拷贝,变成了响应式数据。
这之后,这两个属性不存在任何关联了。此时我们需要在每次 BaseHistory 的 current 属性改变后同步改变根组件实例上的 _route 响应式属性。
让我们回到 vue-loader/index.js
中:
// ...
class VueRouter {
// ...
// 定义初始化路由方法
// init方法会接受 根组件实例
init(app) {
this.app = app;
const history = this.history;
// 路由变化监听函数
const setupListeners = () => {
history.setupListeners();
};
// 初始化时 首先根据当前页面路径渲染一次页面
history.transitionTo(history.getCurrentLocation(), setupListeners);
// 额外定义history.listen方法 传入一个callback
// 在每次BaseHistory中的current属性改变时 传入最新的值 从而更新 app._route
history.listen((route) => {
app._route = route;
});
}
// ...
}
// ...
我们在 VueRouter 的 init 方法之中定义了一个 history.listen 的方法,在初始化时这个方法会被调用它会在每次更新 BaseHistory 的 current 属性值时调用传入的 callback 为根组件的 _route 赋值为最新的 current 值。
不难想到这是一个通用方法,无论是 HTML5 还是 Hash 都需要这段逻辑,所以我们将它定义在 vue-router/history/base.js
中:
// history/base.js
export class BaseHistory {
// ...
// current改变同步修改$route
listen(cb) {
this.cb = cb;
}
// 更新current的值
updateRoute(route) {
this.current = route;
this.cb && this.cb(route);
}
}
这里我们增加了一个 listen 方法,它会接受一个 callback函数,同时在自身实例上定义一个 this.cb = cb。
在每次调用 updateRoute 方法时,如果存在 this.cb 就会调用它同时传入最新的 this.current 的值,从而达到更新根组件实例上的 $route 属性。
让我们一起来看一看此时页面的展现效果:
这里我在 App.vue 的 template 中 加入了
<div>$route<div>
。
此时我们可以看到,当页面 URL 变化时 App.vue 模板中依赖的 $route 属性的值也会变化从而造成页面重新渲染。
当然不要忘记我们需要在 VueRouter 补充这些已经实现的 API 提供给 $router 对象调用:
class VueRouter {
// ...
// 跳转
push(location) {
this.history.push(location);
}
// 替换
replace(location) {
this.history.replace(location);
}
// ...
}
在上边我们已经实现了 router和route 两个对象,每当页面 URL 发生变化时。
我们在组件内部的 $route
寻找最新路径匹配的路由,同时这个属性我们将它转变成为了一个响应式属性。
在使用 VueRouter 时,会注入两个两个全局全局组件分别是:
关于 RouterLink 更加详细的用法你可以查阅 VueRouter 官方文档。
本质上,它就是一个承载跳转的节点。通过点击该节点触发跳转,我们来一起看一看它的简单实现:
export default {
name: 'RouterLink',
props: {
to: {
type: String,
required: true,
},
tag: {
type: String,
default: 'a',
},
},
methods: {
handleJump() {
this.$router.push(this.to);
},
},
render(h) {
return h(
this.tag,
{
on: {
click: () => {
this.handleJump();
},
},
},
this.$slots.default
);
},
};
我简化了它的实现,仅仅保留了基础的内容,它会接受外部传入的插槽内容同时渲染对应标签。在点击时,触发 this.$router.push 方法。
RouterLink 的实现非常简单,这里我就不在累赘了。
关于 router-view 这个组件就稍稍有点复杂,我们先来看看它的实现:
// components/router-view.js
export default {
name: 'RouterView',
functional: true,
render(h, ctx) {
// 标记当前 dataView 为true
ctx.data.dataView = true;
let { parent, data } = ctx;
const route = parent.$route;
// 表示当前RouterView需要渲染的层级
let depth = 0;
// 当寻找到根节点时停止
while (parent && parent._routerRoot !== parent) {
// 获取父节点标签上的 data
const vnodeData = parent.$vnode ? parent.$vnode.data : {};
if (vnodeData.dataView) {
// 如果 parent.$vnode.dataView 为 true,则表示当前 routerView 已经渲染过了
depth++;
}
// 递归向上查找
parent = parent.$parent;
}
// 根据depth判断当前router-view 承载的是第几个匹配组件
const matchRoute = route.matched[depth];
if (!matchRoute) {
return h();
}
return h(matchRoute.component, data);
},
};
首先这里使用了 functional 函数式组件,组件内部并没有 this 实例。
在渲染每一个 RouterView 时,我们将当前组件上下的 data 属性中标记 dataView 为 true ,你可以这样理解它 <routerView :data="true">
,相当于为该标签增加一个 attribute 。
在每次渲染 RouterView 时,我们正是通过当前 routerView 向上查找,每当页面渲染一层 RouterView 我们都会将该 RouterView 的 dataView 的属性设置为 true 。
这样在进行嵌套渲染时,我们只需要向上递归查找 dataView 有几个为 true ,则表示该 RouterView 是嵌套匹配到的第几层路由。
关于 $vnode ,也许大部分同学经常使用的是 _vode 。这里你可以通过这样图来看看他的区别:
简单来说,$vnode 代表当前组件渲染的占位标签节点,而 _vnode 代表当前组件渲染的 VNode 节点内容。
写到这里,其实关于 VueRouter 的基础功能目前我们已经实现了,一起来看看此时的效果吧:
当我们点击页面上对应的 router-link 标签时,发生跳转。
此时 URL 变化,从而造成 $route 改变,RouterView 收集到的响应式数据变化,从而造成页面渲染。
我不太清楚会有多少小伙伴看到这里,但是真的非常感谢每一位可以坚持到结尾的朋友。
文章中是对于源码的一个最小化实现,大家在理解了文章中思路之后可以尝试自己阅读 VueRouter 源码。
说句心里话,很多时候我们觉得困难并不是因为它真的困难,在我看来大概率只是源于你对他的不了解。。
就好比文章中讲到的 VueRouter 那样,在你真的理解了文章的内容后你会发现,它无非就是在路径变化时修改响应式数据的值导致页面重新渲染,只是这之中一些具体的实现细节可能会比较繁琐而已。
如果之后对 Vue 感兴趣的小伙伴,我之后会在专栏 从原理玩转Vue 为大家带来更多有趣的内容。