本文是手拉手系列中的 “实现mini-vue-router” ,通过本文你可以了解到,SPA的路由系统是如何实现的、代理设计模式、beforeEach是如何实现的(中间件)、hash和history模式的区别等等,废话不多说,现在我们开始吧!
原文:lucasfe.cn
源码仓库: github.com/xinlong-che…
相关文章: 深度解析中间件
一、Vue-Router如何使用
如果已经是对VueRouter Api使用很熟悉的伙计,可以跳过标题一部分的内容,直接看标题二
实例化一个Router,后面会把实例化的Router,放到Vue根实例中进行注册
// /src/router/index.js
import Vue from "vue";
Vue.use(VueRouter);
const routes = [
{
path: "/",
name: "Home",
component: Home,
},
{
path: "/about",
name: "About",
component: () =>
import(/* webpackChunkName: "about" */ "../views/About.vue"),
children: [
{
path: "a",
component: {
render() {
return <h1>A views</h1>;
},
},
},
{
path: "b",
component: {
render() {
return <h1>B views</h1>;
},
},
},
],
},
];
const router = new VueRouter({
mode: "hash",
routes,
});
export default router;
在Vue根实例中,对Router进行注册操作
// /src/main.js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
new Vue({
router,
render: (h) => h(App),
}).$mount("#app");
以上Router的初始化工作已经结束,现在介绍两个面试中提到次数很多的Api,分别是addRoutes和beforeEach
addRoutes的作用是动态添加路由,可用于权限校验
beforeEach是router中的钩子,在路由跳转之前进行callback的执行
// /src/router/index.js
router.addRoutes([
{
path: "/about",
children: [
{
path: "c",
component: {
render() {
return <h1>C views</h1>;
},
},
},
],
},
]);
// setTimeout代表异步操作
router.beforeEach((from, to, next) => {
setTimeout(() => {
next();
}, 1000);
});
router.beforeEach((from, to, next) => {
setTimeout(() => {
next();
}, 1000);
});
二、实现一个mini-vue-router需要几步?
-
对用户传入options进行格式化,提供matched和addRoutes方法
-
给每个Vue实例绑定$router
-
实现transitionTo ( 这个方法是Router内置方法,用于路由跳转 ) ,并绑定$route
-
实现 router-view 和 router-link
各位伙计先大致看一下这四步,可能会云里雾里的,但是没关系,下面老哥我会一步一步带伙计们来实现这个mini-vue-router!
三、options的格式化操作
其实很多的JS库,首先第一步都是对用户传入的参数进行格式化的操作,vue-router也不例外,这样做的目的是,为了作者后面更好的操作数据结构做准备。用户会给Router类传入一个数组,后面我们会通过url去匹配到对应的组件以及父组件,所以数组的数据结构满足不了我们的需求了,我们需要一个 key=>value 的数据结构,key是路由的path,value就是component props meta path等等参数
(1) 首先我们声明一个VueRouter,之后我们实现addRoutes、beforeEach等方法都加在这个类的上面。createMatcher就是格式化的主要功能函数,它会接收用户传入的options数组,并返回两个方法,分别是this.matcher.addRoutes和this.matcher.match
// /vue-router/index.js
import createMatcher from './create-matcher'
class VueRouter {
constructor(options = {}) {
this.matcher = createMatcher(options.routes ?? []);
}
}
export default VueRouter;
(2) createMatcher的作用有两个,一个是格式化options,另外一个就是服务于addRoutes
// /vue-router/create-matcher.js
export default function createMatcher(routes) {
const { patchRoutes } = createRoutes(routes);
function addRoutes(routes) {
createRoutes(routes, patchRoutes);
}
return {
match, // 之后再实现
addRoutes,
};
}
createRoutes方法:用于创建一个路由映射表,入参是用户提供的options数组,返回patchRoutes数据结构为 '/about' => { ... } '/about/a' => { ... } 如果入参有oldPatch,不是进行创建了,而且往oldPatch中进行添加操作
handleRoutes方法:用于遍历递归options树,把树结构进行拍扁操作,转化为键值对结构,需要注意如果patchRoutes中已经key将不会进行覆盖操作。
// /vue-router/create-matcher.js
// patchRoutes的数据结构 概览
// patchRoutes = {
// '/': { path: '/', component: ..., parent: { .... } },
// '/about': { path: '/about', component: ..., parent: { .... } },
// '/about/a': { path: '/about/a', component: ..., parent: { .... } },
// }
export function createRoutes(routes, oldPatch) {
const patchRoutes = oldPatch ? oldPatch : Object.create(null);
routes?.forEach((route) => {
handleRoutes(patchRoutes, route);
});
return {
patchRoutes,
};
}
function handleRoutes(patchRoutes, route, parentRoute) {
const path = !parentRoute ? route.path : parentRoute.path + "/" + route.path;
const target = {
path,
component: route.component,
parent: parentRoute,
};
if (!patchRoutes[path]) {
patchRoutes[path] = target;
}
route.children &&
route.children.forEach((child) => {
handleRoutes(patchRoutes, child, target);
});
}
接下来,我们来实现,通过url匹配到对应的组件。如果我们路由为/about/a,是需要渲染/about/a组件和/about组件,所以说我们需要通过parent参数,找到所有的父节点
// /vue-router/create-matcher.js
export default function createMatcher(routes) {
const { patchRoutes } = createRoutes(routes);
// new
function match(router) {
const target = patchRoutes[router];
if (!target) {
return undefined;
}
const matches = [];
let parent = target;
while (parent) {
matches.unshift(parent.path);
parent = parent.parent;
}
return {
path: matches,
matched: matches.map((route) => {
return patchRoutes[route] ?? {};
}),
};
}
function addRoutes(routes) {
createRoutes(routes, patchRoutes);
}
return {
match,
addRoutes,
};
}
四、Vue实例绑定$router
我们在每个Vue组件都可以通过this.router属性
首先只有Vue的根实例才有$options.router属性,所以在根实例上面添加一个_routerRoot属性,后面的Vue实例通过找父实例的_routerRoot来为自己绑定_routerRoot属性,最后通过Object.defineProperty完成代理模式
// /vue-router/install.js
const install = (_Vue) => {
const Vue = _Vue;
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
this._routerRoot = this;
this._router = this.$options.router;
this._router.init(this);
} else if (this.$parent && this.$parent._routerRoot) {
this._routerRoot = this.$parent._routerRoot;
}
},
});
Object.defineProperty(Vue.prototype, "$router", {
get() {
return this._routerRoot._router;
},
});
};
export default install;
// /vue-router/index.js
import install from '/vue-router/install.js'
class VueRouter {
.....
// 实例beforeCreate的时候会调用init
init(app) {
.....
}
}
VueRouter.install = install;
五、实现transitionTo方法,并绑定$route
transitionTo方法是VueRouter的核心方法,作用是用于路由跳转。首先路由系统的基本需求有两点,一个是路由改变了我应该能感知到,并且渲染不同的组件,另一个是对应的路由信息是响应式的。然后我们再来说说hash和history的区别, hash模式url会有一个#,看起来不是很好看,但是兼容性比较好,监听hashchange事件能感知到路由改变;history模式是h5的新api,但是兼容性一般般,监听popstate事件能感知到路由改变,部署后需要改变nginx,访问任何页面返回首页html,由前端定位到具体路由
// /vue-router/history/base.js
import { createRoutes } from "../create-matcher";
export default class Base {
constructor(router) {
// 子类super的
this.router = router;
this.current = createRoutes(null, {
path: "/",
});
}
/**
* 1.初始化的时候会触发
* 2.hashchange的时候会触发
*/
transitionTo(location, listener) {
const route = this.router.match(location);
listener && listener();
}
}
hash类的具体实现:
// /vue-router/history/hash.js
import Base from "./base";
function ensuoreSlash() {
if (!window.location.hash) {
window.location.hash = "/";
}
}
function getHash() {
return window.location.hash.slice(1);
}
export default class HashHistory extends Base {
constructor(router) {
super(router);
ensuoreSlash();
}
push(to) {
window.location.hash = to;
}
getCurrentLocation() {
return getHash();
}
// 监听变化
setupListener = () => {
window.addEventListener("hashchange", () => {
this.transitionTo(getHash());
});
};
}
history类的具体实现:
// /vue-router/history/history.js
import Base from "./base";
function getPathName() {
return window.location.pathname;
}
export default class BrowserHistory extends Base {
constructor(router) {
super(router);
}
/**
* 1. 先渲染view
* 2. 改变路由
*/
push(to) {
this.transitionTo(to, () => {
window.history.pushState({}, "", to);
});
}
getCurrentLocation() {
return getPathName();
}
// 监听变化
setupListener = () => {
window.addEventListener("popstate", () => {
this.transitionTo(getPathName());
});
};
}
实现了路由系统,现在我们来实现调用。beforeCreate的hook中会调用init方法,调用transitionTo方法,初始化路由
// vue-router/index.js
class VueRouter {
constructor(options = {}) {
this.matcher = createMatcher(options.routes ?? []);
switch (options.mode) {
case "hash":
this.history = new HashHistory(this);
break;
case "history":
this.history = new BrowserHistory(this);
break;
}
}
init(app) {
const history = this.history;
history.transitionTo(history.getCurrentLocation(), history.setupListener);
}
addRoutes(routes) {
this.matcher.addRoutes(routes);
}
match(location) {
return this.matcher.match(location);
}
push(to) {
this.history.push(to);
}
beforeEach(hooks) {
this.beforeEachHooks.push(hooks);
}
}
路由的跳转我们已经实现了,但是组件不会正常渲染,是因为route的信息不是响应式的,所以我们需要对route进行响应式处理,我这里用的是Vue的一个内置方法Vue.util.defineReactive,每次transitionTo会触发setter
// /vue-router/install.js
const install = (_Vue) => {
const Vue = _Vue;
Vue.mixin({
beforeCreate() {
if (this.$options.router) {
....
// 响应式处理_route
Vue.util.defineReactive(this, "_route", this._router.history.current);
} else if (this.$parent && this.$parent._routerRoot) {
....
}
},
});
....
Object.defineProperty(Vue.prototype, "$route", {
get() {
return this._routerRoot._route;
},
});
};
// /vue-router/history/base.js
export default class Base {
....
transitionTo(location, listener) {
const route = this.router.match(location);
/**
* current无法暴露到外面
*/
this.current = route;
/**
* 触发this._route的响应式
*/
this.cb && this.cb(route);
listener && listener();
}
listen(cb) {
this.cb = cb;
}
}
// /vue-router/index.js
class VueRouter {
.....
init(app) {
const history = this.history;
history.transitionTo(history.getCurrentLocation(), history.setupListener);
// 每次transitionTo会触发这个callback
// 目的是触发_route的setter
history.listen((route) => {
app._route = route;
});
}
}
六、实现router-view和router-link
这两个是全局组件,用Vue.component来registry,然后这两个组件我们用函数式组件,性能更优
// /vue-router/install.js
import RouterView from "./compoents/router-view";
import RouterLink from "./compoents/router-link";
const install = (_Vue) => {
const Vue = _Vue;
.....
Vue.component("RouterLink", RouterLink);
Vue.component("RouterView", RouterView);
};
export default install;
router-view组件通过depth来计数,比如现在该渲染/about/a的组件了,while它的parent,depth就是1,通过route.matched[depth]找到/about/a对应的组件并渲染它
// /vue-router/component/router-view.js
export default {
functional: true,
render(h, { parent, data }) {
const route = parent.$route;
let depth = 0;
data.routerView = true;
while (parent) {
if (parent.$vnode && parent.$vnode.data.routerView) {
depth++;
}
parent = parent.$parent;
}
let record = route.matched[depth];
if (!record) {
return h();
}
return h(record.component, data);
},
};
// /vue-router/component/router-link.js
export default {
props: {
tag: {
type: String,
default: "a",
},
to: {
type: String,
required: true,
},
},
methods: {
handleClick() {
this.$router.push(this.to);
},
},
render(h) {
return h(
this.tag,
{
on: {
click: this.handleClick,
},
},
this.$slots.default
);
},
};
七、beforeEach的原理
beforeEach方法是一个hook,在路由跳转之前进行callback执行,他的原理跟koa、express的中间件原理一样,可以看我的另一篇文章深度解析中间件
// /vue-router/index.js
// 核心方法
function runQueue(queue, from, to, callback) {
function next(index) {
if (index === queue.length) {
return callback();
}
const handler = queue[index++];
handler(from, to, () => next(index));
}
next(0);
}
class VueRouter {
constructor(options = {}) {
.....
this.beforeEachHooks = [];
}
push(to) {
runQueue(
this.beforeEachHooks,
this.history.getCurrentLocation(),
to,
() => {
this.history.push(to);
}
);
}
// 先注册
beforeEach(hooks) {
this.beforeEachHooks.push(hooks);
}
}
VueRouter.install = install;
export default VueRouter;
到这里mini-vue-router的核心方法就已经大致实现了,俗话说,光说不练假把式!伙计们快按照目录的思路,自己来实现一波吧,深度体会一把vue-router的思路!