2

vue-router源码实现

前言

这个月会实现一下Vue, Vuex, vue-router。我会以倒推的模式边开发边写文章。话不多说开始跟着我一起撸。仓库地址

本文只是实现了一个基础版本的vue-router.本文所写的代码,不会每个地方都做异常判断。实现一个能够体现vue-router核心逻辑即可。

我大致捋了下vue-router的流程图如下:
vue-router思维导图

在写源码之前,我先展示下routes的数据结构,在根据这个数据结构来进行vue-router的开发

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    component: () =>
      import(/* webpackChunkName: "about" */ '../views/About.vue'),
    children: [
      {
        path: 'a',
        name: 'about.a',
        component: {
          render: (h) => <div>this is a</div>,
        },
      },
      {
        path: 'b',
        name: 'about.b',
        component: {
          render: (h) => <div>this is b</div>,
        },
      },
    ],
  },
];

安装 install

回忆下我们平时代码里用到vue-router必定会有的两行代码

import VueRouter from 'vue-router';

Vue.use(VueRouter);

Vueuse VueRouter的时候会调用VueRouterinstall方法,并传入Vue构造函数。

所以我们接下来第一步就是创建一个VueRouter构造函数,并实现一个install方法。

// vue-router/index.js
const install = (Vue) => {
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        this._routerRoot = this;
        this._router = this.$options.router;
      } else {
        this._routerRoot = this.$parent && this.$parent._routerRoot;
      }
    },
  });

  // FIXME:
  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      return 'this is $route';
    },
  });

  Object.defineProperty(Vue.prototype, '$router', {
    get() {
      return this._routerRoot._router;
    },
  });

  // FIXME:
  Vue.component('router-view', {
    render() {
      return <div>this is router-view</div>;
    },
  });
  // FIXME:
  Vue.component('router-link', {
    render() {
      return <div>this is router-link</div>;
    },
  });
};

export default install;

vue-router也会与vuex一样,通过Vue.mixin方法添加beforeCreate生命周期,通过在这个生命周期函数,向每个组件实例挂载_routerRoot根实例对象,根实例会多挂载一个_router对象,这样子组件就可以通过_routerRoot._router来获取路由实例对象了。同时会往Vue.prototype上挂载$router $route,方便每个子组件获取。

并挂载全局组件router-viewrouter-link组件;

实例

写完install方法后,我们接下来开始动手VueRouter。初始化时,该在constructor中写些什么呢?初始化当然是为了后续的一些方法做准备了。他将包含以下几点

  • 创建matcher对象

    • addRoute: 用于动态添加路由
    • match: 重中之重,用于根据path匹配出对应的route对象,用于router-view的渲染
  • 根据对应的mode创建对应的history对象(我这里开发的router默认使用HashHistory
class VueRouter {
  constructor(options) {
    this.matcher = createMatcher(options.routes);

    this.history = new HashHistory();
  }

  init() {}
}

createMatcher

那么createMatcher到底该怎么实现?还是反推的模式,它肯定返回上面列的两个方法addRoute match方法。那么就先实现下大致的样子吧。

// create-matcher.js

function createMatcher(routes) {
  function addRoute() {}

  function match() {}

  return {
    addRoute,
    match,
  };
}

export default createMatcher;
match

接下来就是实现match方法了。前面说过match是用来匹配path所对应的route对象。所以他肯定有个path参数,然后通过一个路由映射表来筛选出对应的route对象。那就开始着手编码吧!

// create-matcher.js
function match(location) {
    return pathMap[location]
  }

这个时候你肯定会有个疑惑,这个pathMap哪里来的呢?上面我已经说过pathMap是一个路由映射表。那就开始着手编码吧!

// create-matcher.js
function createMatcher(routes) {
  const { pathList, pathMap } = createRouteMap(routes);
  function addRoute() {}

  function match(locaiton) {
    return pathMap[locaiton];
  }

  return {
    addRoute,
    match,
  };
}

match方法中并不会那么简单的返回route对象就ok了。肯定是经过一个函数保证过返回matched path等字段。这里先忽略,等讲到history的时候再讲解。

createRouteMap

你可以看到代码中调用了createRouteMap,那么这个方法该怎么实现呢?首先我们已知这个方法是用来创建pathMap的,那么先搭一个大致的函数框架

// create-route-map.js
function createRouteMap(routes) {
  const pathList = [];
  const pathMap = Object.create(null);

  return {
    pathList,
    pathMap,
  };
}

export default createRouteMap;

大致会是这个样子。执行返回路由映射表。那么该怎么创建pathList pathMap对象呢?当然用传入的routes来构造了。那就开始着手编码吧!

// create-route-map.js
function createRouteMap(routes) {
  const pathList = [];
  const pathMap = Object.create(null);

  routes.forEach(route => {
    addRouteRecord(route, pathList, pathMap)
  })
  return {
    pathList,
    pathMap,
  };
}

function addRouteRecord (route, pathList, pathMap) {
  const path = route.path
  const record = {
    path,
    component: route.component
  }
  if (!pathList.includes(path)) {
    pathList.push(path)
    pathMap[path] = record
  }
}
export default createRouteMap;

上面我通过遍历routes,调用addRouteRecord方法,并传入routeaddRouteRecord根据传入的route构建pathList pathMap,我想大家看代码应该就理解这段代码是什么意思了。当然上面只是非常非常基础版本的。还有一些场景没考虑到,比如传入的route对象还有children,就需要继续遍历他的children并调用addRouteRecord方法。那就开始着手编码吧!

// create-route-map.js
function addRouteRecord(route, pathList, pathMap, parent) {
  const path = parent ? `${parent.path}/${route.path}` : route.path;
  const record = {
    path,
    component: route.component,
    parent,
  };
  if (!pathList.includes(path)) {
    pathList.push(path);
    pathMap[path] = record;
  }

  if (route.children) {
    route.children.forEach((o) => {
      addRouteRecord(o, pathList, pathMap, record);
    });
  }
}

你会发现addRouteRecord多了个parent参数,是因为有子路由。给record增加parent属性是因为方便后面父子组件递归渲染。

match 实现完了,就该实现下addRoute。那么addRoute又该怎么实现呢?其实非常的简单,就是给pathList pathMap增加routes对象罢了。那么我们就开始动手吧!

// create-matcher.js
function addRoute(routes) {
  createRouteMap(routes, pathList, pathMap);
}

你会发现这里的createRouteMap增加了2个参数,所以createRouteMap方法就需要去兼容了。那么我们就开始动手吧!

// create-route-map.js

function createRouteMap(routes, oldPathList, oldPathMap) {
  - const pathList = [];
  + const pathList = oldPathList || [];
  - const pathMap =Object.create(null);
  + const pathMap = oldPathMap || Object.create(null);
  // ...
}

只需要对原先创建pathList pathMap对象进行兼容即可。

History

constructor中的matcher对象创建讲完之后,接下来我们来讲讲constructor中的HashHistory

HashHistory该怎么实现?new HashHistory后做了什么呢?

因为history模式有多种,我们需要实现一个base版的history,然后HashHistory基于这个基类进行扩展。

// history/base.js
export default class History {
  constructor() {}
}
import History from './base';

export default class HashHistory extends History {
  constructor(router) {
    super(router);
    this.router = router;
  }
}

init

init 哪里调用

这个init会在哪里调用呢?当然是安装的时候调用。所以我们在install的时候加上init的调用。

init为什么要传入根实例呢?因为需要监听当前url对应的路由变化。当他变化之后,会主动将根实例的_route赋值成当前的根路由。那根实例的_route哪来的呢?可能有人忘记了。可以翻到上面install的章节,那里讲过了。

// install.js
const install = (Vue) => {
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        this._routerRoot = this;
        this._router = this.$options.router;
        + this._router.init(this)
      } else {
        this._routerRoot = this.$parent && this.$parent._routerRoot;
      }
    },
  });
}

加号位置就是我添加的代码。调用下init初始化下VueRouter

那么init方法中做了哪些事情呢?

  • 挂载当前url对应的路由组件
  • 监听路由的变化(hashchange等事件)

话不多说,进入编码模式

// vue-router/index.js
import HashHistory from './history/hash';
import install from './install';
import createMatcher from './create-matcher';

class VueRouter {
  constructor(options) {
    // ...
  }
  init(app) {
    const history = this.history;
    const setupListener = () => {
      history.setupListener();
    };
    history.transitionTo(history.getCurrentLocation(), setupListener);
    history.listen((route) => {
      app._route = route;
    });
  }
}
init中做了哪些工作
transitionTo

init中调用了history中的transitionTo,那么这个方法是干嘛的呢?用于路由的跳转,根据传入的pathpathMap中筛选出对应的route,这个方法会触发下面讲到的listen方法。这个方法触发后,会修改根实例的_route。修改之后,router-view就会响应式的改变,以达到刷新路由渲染页面的目的。因为调用transitionTo方法会有多种途径。一种是主动调用push方法等,需要主动修改浏览器地址栏hash值,一种是页面初始化调用,这个时候又需要监听hashchange等事件,所以transitionTo增加第二个参数用于回调。这样每个调用transitionTo后,可执行自己的逻辑。

话不多说上代码

// history/base.js
export default class History {
  constructor(router) {
    + this.router = router
  }

  + transitionTo(location, callback) {
  +  const r = this.router.match(location)
  +  console.log(r)
  +  callback && callback()
  + }
}

上面简短的代码是不是就实现了上面描述中的几个功能了。
还有两点功能没实现。

  • 怎么响应式刷新
  • 怎么实现匹配路由(match)

我们先来讲怎么响应式刷新。那么怎样才能实现router-view响应式的刷新呢?我们根据倒推的模式,router-view是根据根实例的_route做刷新的。所以需要增加个current对象用来表示当前路由。上代码🐶

// history/base.js
export default class History {
  constructor(router) {
    this.router = router
    + this.current = createRoute(null, {
    +  path: '/'
    + })
  }

  transitionTo(location, callback) {
    const r = this.router.match(location)
    + this.current = r
    callback && callback()
    console.log(r)
  }
}

可是光设置current还不能实现router-view响应式刷新。因为route-view是根据$route做响应式的。还记得在install的时候设置过$route吗?我们将它修改下。

// install.js

const install = (Vue) => {
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        // ...
        this._router.init(this)
        + Vue.util.defineReactive(this, '_route', this._router.history.current);
      } else {
        this._routerRoot = this.$parent && this.$parent._routerRoot;
      }
    },
  });

  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      - return 'this is $route';
      + return this._routerRoot._route;
    },
  });
}

这样 我们一旦修改current,页面的$route就会响应式更新了。刷新下页面试试看吧🐶。

竟然报错了。提示createRoute is not defined

抄下上面贴过的代码

// history/base.js
export default class History {
  constructor(router) {
    this.router = router
    + this.current = createRoute(null, {
    +  path: '/'
    + })
  }

  transitionTo(location, callback) {
    const r = this.router.match(location)
    + this.current = r
    callback && callback()
    console.log(r)
  }
}

我们来讲讲createRoute的作用。他的作用是将匹配到的route进行处理,返回个包含pathmatched字段。matched字段包含了,从匹配到的一级路由一直到最后一级路由。router-view也是根据这一数组进行父组件到子组件的渲染的。match方法中也用到的这个方法。下面再讲match方法。接下来我们就来实现createRoute方法。

// history/base.js
export function createRoute(record, location) {
  const matched = [];
  while (record) {
    matched.unshift(record);
    record = record.parent;
  }

  return {
    matched,
    ...location,
  };
}

第一个参数record其实就是上文createRouteMapaddRouteRecord使用到的record。其中包含了parent字段就是在这个时候用到的。所以/about/a就可以生成matched: [{path: '/about', component: componentAbout}, {path: '/about/a', component: componentAboutA}]了。再次提醒下,matched用于router-view的层层渲染。话不多说,上代码🐶

// vue-router/index.js
class VueRouter {
  constructor(options) {
    this.matcher = createMatcher(options.routes);
    // ...
  }

  match(location) {
    return this.matcher.match(location);
  }

  // ...
}

提醒下,这里的matcher上面讲过了。用来返回matchaddRoute方法。

然后再完善下上面写过的createMatcher

// create-matcher.js
+ import { createRoute } from './history/base';

function createMatcher(routes) {
  const { pathList, pathMap } = createRouteMap(routes);
  // ...

  function match(locaiton) {
    - return pathMap[locaiton];
    + return createRoute(pathMap[locaiton], {
    +   path: locaiton,
    + });
  }
  
  // ...
}

看着好像没什么问题了,再刷新下页面。

提示history.setupListener is not a function

我们再把思绪拉回到init方法中调用的history.setupListener

setupListener其实非常的简单,就是添加监听事件

export default class HashHistory extends History {
  constructor(router) {
    super(router);
    this.router = router;
  }

  getCurrentLocation() {
    return window.location.hash.slice(1);
  }

  + setupListener() {
  +   window.addEventListener('hashchange', () => {
  +     this.transitionTo(this.getCurrentLocation());
  +   });
  + }
}

监听到hashchange后,主动调用下transitionTo去切换路由。

然后我们再刷新下页面,又报错 excuse me???

提示: TypeError: history.listen is not a function

listen
class VueRouter {
  constructor(options) {
    // ...
    this.history = new HashHistory(this);
  }

  init(app) {
    const history = this.history;
    // ...
    history.transitionTo(history.getCurrentLocation(), setupListener);
    > history.listen((route) => {
    >   app._route = route;
    > });
  }
}

inithistory.listen其实也是非常的简单,就是添加订阅。当调用transitionTo后,触发下订阅的事件。并传入location对应的route。话不多说,上代码🐶

// history/base.js
export default class History {
  transitionTo(location, callback) {
    const r = this.router.match(location);
    // ...
    this.cb && this.cb(r)
  }

  listen(cb) {
    this.cb
  }
}

这个时候再刷新下页面。打印下.vue文件里的this.$route看了下好像没问题😆。

上文说过router-view是根据$route层层渲染的,既然$route都冇问题了,那就开始编写router-view组件吧。

router-view

在我看router-view源码之前,我根本不知道router-view怎么实现。原来它是根据$routematched匹配到的组件进行层层渲染的。
举个🌰,就举上面打印出来的matched来讲好了,app.vue中的router-view会渲染matched的第一项中的component对应的about.vue组件,about.vue中的router-view会渲染matched中第二个component对应的about/a组件。可能上代码更简单易懂🐶,那我就开始开发了(本文都是边开发边写文章的)。

router-view组件是用的函数式组件,因为函数式组件无状态 (没有响应式数据),也没有实例 (没有 this 上下文)

// components/router-view.js
export default {
  functional: true,

  render(h, {parent, data}) {
    console.log(parent, 'parent')
  }
}

修改installrotuer-view组件定义。

// install.js
import RouterView from './components/router-view';

const install = (Vue) => {
  - Vue.component('router-view', {
  -   render() {
  -     return <div>this is router-view</div>;
  -   },
  - });
  + Vue.component('router-view', RouterView);

};

export default install;

上面代码写完之后,好像一切正常,打印出来也是正常的。接下来就根据我上面说的,他是根据$route.matched来渲染的去实现它吧。

export default {
  functional: true,

  render(h, { parent, data }) {
    console.log(parent, 'parent');
    const route = parent.$route;
    let depth = 0;
    // 判断是否渲染过,如果没有渲染过,就渲染对应matched里的组件,并将该组件data.routerView = 1。以达到不会重复渲染。
    while (parent) {
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth++;
      }
      parent = parent.$parent;
    }

    data.routerView = 1;

    const record = route.matched[depth];

    if (!record) {
      return h();
    }

    return h(record.component, data);
  },
};

while的作用是判断是否渲染过,如果没有渲染过,就渲染对应matched里的组件,并将该组件data.routerView = 1。以达到不会重复渲染。刷新下页面看看吧。好像子页面都出来了。

router-link

router-link非常的简单。我这里就实现下比较常见的一些操作。比如参数tag,to

export default {
  props: {
    tag: {
      type: String,
      default: 'a',
    },
  },

  render() {
    const tag = this.tag;
    return <tag>{this.$slots.default}</tag>;
  },
};

我这里先写个比较简单的架子。非常的简单将文字展示在tag

然后再修改下install中的引入

// install.js
import RouterLink from './components/router-link';

const install = (Vue) => {
  - Vue.component('router-link', {
  -   render() {
  -     return <div>this is router-link</div>;
  -   },
  - });
  + Vue.component('router-link', RouterLink);

};

export default install;

刷新下页面试试。页面一切展示正常🐶。这就大功告成了吗?当然没有啦,还有router-link的点击事件没写,那就开始动手吧。

// router-link.js

export default {
  props: {
    tag: {
      type: String,
      default: 'a',
    },

    + to: {
    +   type: String,
    +   required: true,
    + },
  },

  + methods: {
  +   handler() {
  +     this.$router.push(this.to);
  +   },
  + },

  render() {
    const tag = this.tag;
    - return <tag>{this.$slots.default}</tag>;
    + return <tag onClick={this.handler}>{this.$slots.default}</tag>;
  },
};

tag增加点击事件。我上面的例子将to定义成String类型了。其实这个to可以是对象类型。我的例子只要能体现出router-link的功能就行了。

接下来刷新下页面试试看。

报了个错: TypeError: this.$router.push is not a function

原来我push忘记写了。那就在vue-router实例中加一个吧。

// vue-router/index.js

class VueRouter {
  constructor(options) {
    // ...
    this.history = new HashHistory(this);
  }

  + push(location) {
  +   this.history.transitionTo(location, () => {
  +     window.location.hash = location;
  +   });
  + }
}

调用下history.transitionTo进行路由的匹配替换,触发router-view渲染后,还有在回调中主动修改下hash地址。

刷新下页面。一切正常。再点击router-link标签。emmmm 一切正常。(重复点击一个link跳转没有处理,我就不处理。我觉得没有必要,我的目的就是能表达我对router的理解就够了)

最后

非常感谢你能读完这篇文章。我已经非常努力写这篇文章了(vue-router写了三遍才开始写文章的)。但是读起来好像还是不是很顺。


我是南方大汉
762 声望54 粉丝

爱技术,爱分享