小白前端之路:手写一个简单的vue-router

1,073 阅读5分钟

这几年,好像过的好快,怀念我的大学生活。

                                                                             -连某人

大三实习生,之前写过简单MVVM框架、简单的vuex、但是看了vue-router的源码(看了大概)之后就没有写,趁着周末不用工作(大三趁着不开学出来实习,现在水平比较低,代码也没有优化,请小喷)来写一下,写的比较仓促。

github仓储地址

使用

<template>
  <div id="app">
    <lj-view></lj-view>
    <button @click="go">tagone</button>
    <button @click="back">back</button>
    <button @click="replace">tagtwo</button>
    <div><span>lj-link</span></div>
    <lj-link :to="'/tagtwo'" :tag="'div'">tagtwo</lj-link>
    <lj-link :to="'/tagone'" :tag="'span'">tagone</lj-link>
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld'

export default {
  name: 'App',
  components: {
    HelloWorld
  },
  created() {
    console.log(this._router);
  },
  methods:{
    go() {
      this.$router.push({path:'/tagone',query:{name:12}})
    },
    back() {
      this.$router.back();
    },
    replace() {
      this.$router.replace({path:'/tagtwo'});
    }
  }
}
</script>

//main.js
import LJRouter from "./lib/LJRouter/src";
Vue.config.productionTip = false
Vue.use(LJRouter)
let router = new LJRouter({
  mode:'hash',
  routes:[
    {
      path:'/',component:hello
    },
    {
      path:'/tagone',component:tagone
    },
    {
      path:'/tagtwo',component:tagtwo
    }
  ]
});
/*router.beforeEach(function(from,to,next) {
  let path = to.path;
  if(path==='/tagtwo') {
    next();
  }else {
    next('/tagtwo')
  }
});*/

原理

vue-router核心原理其实很简单,分为hash模式和history模式(一共三种,还有一种我还没有去了解),

hash模式

其实就是监听onhashchange事件,当hash改变就会触发该事件,然后拿到对应record,然后去触发router-view的更新(这里为什么会触发更新,请看router-view的实现),当调用router的push/replace其实也是去拿到对应的record去触发组件更新重新调用render函数

history模式

其实就是监听popState事件(注意是调用history.go/history.forward的时候才会触发),然后通过pushState和replaceState去改变path,注意这里通过html5新增的这两个方法改变path是不会重新发起请求的,当改变path之后,再拿到改变的path,从而获取对应的record,再触发组件更新,重新调用render函数。

实现流程分析

前提概要

PS:(我这里是以我写的LJRouter来分析,名字可以忽略)

请各位大佬小喷,以及真心求志同道合的朋友。

install初始化

function install(vue) {
     let _vue = install._vue;
     if(_vue) {//防止多次注册安装
       throw new Error('Cannot be reinstalled');
     }
     install._vue = vue;
     mixinApply(vue);//全局混入
}

vue有一个特点就是插件化,类似vue-router、vuex这些都是以插件的方式引入,而不是内置,通过Vue.use注册(这里不讲Vue.use实现,很简单,不懂的话,可以看Vue.use的源码实现),然后会调用install函数

function mixinApply(vue) {
    vue.mixin({
        beforeCreate:routerInit
    });
    definedRouter(vue);
    vue.component('ljView',ljView);
    vue.component('ljLink',ljLink);
}

mixinApply函数主要是全局混入(不理解Vue.mixin的同学,请看官网文档)并注册了两个全局组件LJ-View以及LJ-Link。

function routerInit() {
    if(this.$options.router) {
        this._router = this.$options.router;
        this.$notFoundComponent = notFound;
        install._vue.util.defineReactive(this,'_route',this._router.history.currentRouter);
    } else {
        this._router = this.$parent._router;
    }

}

routerInit 通过Vue.mixin全局混入,所以每个组件都会调用该函数,该函数主要是的作用主要是为每个组件添加一个指针指向LJRouter实例

function definedRouter(vue) {
  Object.defineProperty(vue.prototype,'$router',{
    get() {
        return this._router.router;
    }
  });
  Object.defineProperty(vue.prototype,'$route',{
    get() {
      return this._router.route;
    }
  });
}

definedRouter这个函数比较简单,所以就不讲了

LJRouter实例构建

LJRouter的编写

import install from './install.js';
import Hashhistory from "./history/Hashhistory";
import Htmlhistory from './history/Htmlhistory';
import {deepCopy} from './util/util.js'
class LJRouter {
  static install = install;
  constructor(options) {
    this.$options = options;
    this.mode = options.mode||'hash';
    switch (this.mode) {
      case "hash":this.history = new Hashhistory(options);break;
      case "history":this.history = new Htmlhistory(options);break;
    }
  }
  addRoutes(routes) {
    this.history.addRoutes(routes);
  }
  get router() {
    return {
      push:this.push.bind(this),
      back:this.back.bind(this),
      replace:this.replace.bind(this)
    };
  }
  get route() {
    let query = this.history.currentRouter.current.query;
    query = deepCopy(query);
    return {query}
  }
  push({path,name,query}) {
    this.history.push({path,name,query});
  }
  replace({path}) {
    this.history.replace({path});
  }
  back() {
    this.history.back();
  }
  //全局前置守卫
  beforeEach(fn) {
    if(this.mode === 'hash'||this.mode === 'history') {
      this.history.beforeEach(fn);
    } else {
      throw new TypeError('beforeEach is undefined');
    }
  }
}
export default LJRouter;

我们先看LJRouter的构造器,其实LJRouter的构造器也是很简单,其实就是各种参数代理到LJRouter实例上,然后再根据mode来创建对应的实例(Hashhistory||Htmlhistory),在LJRouter显式原型上还有一些函数,其实都是一个代理。本质还是会调用实例上的history隐式原型上的方法,先讲个大概逻辑,之后再按某个功能来介绍实现逻辑

Hashhistory

import createMatcher from "../createMatcher";
class Hashhistory {
  constructor(options) {
    this.$options = options;
    let routes = options.routes;
    const {addRoutes,getCurrentRecord} = createMatcher(routes);
    this.addRoutes = addRoutes;
    this.getCurrentRecord = getCurrentRecord;
    this.currentRouter = {current:{}};
    this.pathQueue = [];
    this.beforeEachCallBack = null;
    this.transitionTo();
    this.setUpListener();
  }
  setUpListener() {
    window.onhashchange = function() {
      let hash = location.hash;


      if(hash==='') {
        location.href = location.href+'#/';
        hash = '/';
      } else if(hash === '#/') {
        hash = '/'
      }
      else {
        hash = hash.slice(1);
      }
      this.confirmTransitionTo({hash});
    }.bind(this);
  }
  transitionTo() {
    let hash = location.hash;
    if(hash.indexOf('#')===-1) {
        location.href = location.href+'#/';
        hash = '/';
    } else {
        hash = hash.slice(1);
    }
    if(hash==='') {
        hash = '/';
    }
    this.pathQueue.push(hash);
    this.confirmTransitionTo({hash});
  }
  confirmTransitionTo({hash,name,query}) {
    let currentRecord;
    currentRecord = this.getCurrentRecord({path:hash,name,query});
    let that = this;
    function next(resolve,reject) {
        return function(path) {
            if(Object.is(undefined,path)) {
               resolve(currentRecord);
            }else {
               location.hash = '#'+path;
               that.confirmTransitionTo({hash:path});
            }
        }
    }

    new Promise(function(resolve,reject){
            if(this.beforeEachCallBack) {
               this.beforeEachCallBack(this.currentRouter.current,currentRecord,next(resolve,reject));
            }else {
              resolve(currentRecord);
            }
          }.bind(this)).then(record=>{
             this.currentRouter.current = record
          });
  }
  push({path,name,query}) {
    this.pathQueue.push(path);
    this.confirmTransitionTo({hash:path,name,query});
  }
  back() {
    let pathQueue = this.pathQueue;
    let path = null;
    if(pathQueue.length<=1) {
      console.error('pathQueue value is < 2:redirect to /');
      path = '/'
    } else {
      path = pathQueue[pathQueue.length-2];
      pathQueue.length = pathQueue.length-2;
    }
    this.pathQueue.push(path);
    this.confirmTransitionTo({hash:path});
  }
  replace({path}) {
    this.pathQueue.length = 0;
    this.confirmTransitionTo({hash:path});
  }
  //全局前置守卫
  beforeEach(fn) {
    this.beforeEachCallBack = fn;
  }
}
export default  Hashhistory;

Hashhistory实例构建时,会对routes进行解析(解析过程看createMatcher),然后就调用transitionTo,transitionTo获取hash,然后调用confirmTransitionTo进行组件切换.transitionTo做了一个逻辑判断,如果没有检测到#的话会自动追加hash。

Htmlhistory

import createMatcher from "../createMatcher";
class Htmlhistory {
  constructor(options) {
    this.$options = options;
    let routes = options.routes;
    const {addRoutes,getCurrentRecord} = createMatcher(routes);
    this.addRoutes = addRoutes;
    this.getCurrentRecord = getCurrentRecord;
    this.currentRouter = {current:{}};
    this.beforeEachCallBack = null;
    let path = this.getPath();
    this.transitionTo(path)
    this.setUpListener();

  }
  setUpListener() {
    window.onpopstate = function() {
      let path = this.getPath();
      this.confirmTransitionTo({path});
    }.bind(this);
  }
  getPath() {
    let path = location.href.split('/')[3];
    path = path?'/'+path:'/';
    return path;
  }
  transitionTo(path) {
    this.confirmTransitionTo({path});
  }
  confirmTransitionTo({path,name,query}) {
    let currentRecord;
    currentRecord = this.getCurrentRecord({path,name,query});
    let that = this;
    function next(resolve,reject) {
      return function(path) {
        if(Object.is(undefined,path)) {
          resolve(currentRecord);
        }else {
          that.confirmTransitionTo({path});
        }
      }
    }
    new Promise(function(resolve,reject){
      if(this.beforeEachCallBack) {
        this.beforeEachCallBack(this.currentRouter.current,currentRecord,next(resolve,reject));
      }else {
        resolve(currentRecord);
      }
    }.bind(this)).then(record=>{
      this.currentRouter.current = record
    });
  }
  push({path,name,query}) {
    history.pushState(null,null,path);
    this.confirmTransitionTo({path,name,query});
  }
  back() {
    history.go(-1);
  }
  replace({path}) {
    history.replaceState(null,null,path);
    this.confirmTransitionTo({path});
  }
  //全局前置守卫
  beforeEach(fn) {
    this.beforeEachCallBack = fn;
  }
}
export default  Htmlhistory;

HtmlHistory和HashHistory的实现其实差不多,不过HtmlHistory获取的是path,通过replaceState和pushState进行path切换,并监听popstate事件.大概的逻辑差不多,就不一一讲了。

confirmTransitionTo

confirmTransitionTo({hash,name,query}) {
    let currentRecord;
    currentRecord = this.getCurrentRecord({path:hash,name,query});
    let that = this;
    function next(resolve,reject) {
        return function(path) {
            if(Object.is(undefined,path)) {
               resolve(currentRecord);
            }else {
               location.hash = '#'+path;
               that.confirmTransitionTo({hash:path});
            }
        }
    }

    new Promise(function(resolve,reject){
            if(this.beforeEachCallBack) {
               this.beforeEachCallBack(this.currentRouter.current,currentRecord,next(resolve,reject));
            }else {
              resolve(currentRecord);
            }
          }.bind(this)).then(record=>{
             this.currentRouter.current = record
          });
  }

confirmTransitionTo是一个核心函数,主要就是根据相应hash获取对应的record,然后更新 currentRouter.current的指向,currentRouter其实是响应式的,相关请看LJ-View实现

createMatcher

class Record {
  constructor(path,url,query,params,name,component) {
    this.url = url;
    this.path = path;
    this.query = query;
    this.params = params;
    this.name = name;
    this.component = component;
  }
}
function addRecord(pathList,pathMap,nameMap,route) {
    let record  = new Record(route.path,route.url,route.query,route.params,route.name,route.component);
    if(route.path) {
      pathList.push(route.path);
    }
    if(route.name) {
      nameMap[route.name] = record;
    }
    if(route.path) {
      pathMap[route.path] = record;
    }
}
function addRouterRecord(pathList,pathMap,nameMap,routes) {
  routes.forEach(item=>{
    addRecord(pathList,pathMap,nameMap,item);
  });
}
function createMatcher(routes) {
  let pathList = [];
  let pathMap = {};
  let nameMap = {};
  addRouterRecord(pathList,pathMap,nameMap,routes);
  function getCurrentRecord({path,name,query}) {
    let record =  null;
    if(name) {
        record = nameMap[name];
    }
    if(path) {
      record = pathMap[path];
    }
    if(record) {
      record.query = query;
    }
    return record||{};
  }
  function addRoutes(appendRoutes) {
    addRouterRecord(pathList,pathMap,nameMap,appendRoutes);
  }
  return{getCurrentRecord,addRoutes};
}
export default  createMatcher;

createMatcher这个函数其实比较简单,其实就是根据routes的每一项创建对应的record,然后以path为key record为值添加到pathMap上,这里也有nameMap,参考了vue-router源码,返回addRoutes、getCurrentRecord,addRoutes主要作用时可以使用该函数动态添加路由规则,这里实现了query传值,但是没有实现params。你可以自己加上去。注意:(我这里没有实现子路由,如果你想的话可以加上去);

API实现

前提概要

//install,js
function definedRouter(vue) {
  Object.defineProperty(vue.prototype,'$router',{
    get() {
        return this._router.router;
    }
  });
  Object.defineProperty(vue.prototype,'$route',{
    get() {
      return this._router.route;
    }
  });
}

在install.js,就通过Object.defineProperty在Vue.Prototype上定义了$router和$route,实际上会被LJRouter上的get Router getRoute劫持到

//LJRouter
  get router() {
    return {
      push:this.push.bind(this),
      back:this.back.bind(this),
      replace:this.replace.bind(this)
    };
  }
  get route() {
    let query = this.history.currentRouter.current.query;
    query = deepCopy(query);
    return {query}
  }

实际上还是会调用LJRouter实例上history属性上的同名方法

push

  push({path,name,query}) {
    this.pathQueue.push(path);
    this.confirmTransitionTo({hash:path,name,query});
  }

Hash模式-获取传过去的path,然后在调用confirmTransitionTo函数,实现路由切换,这里多了个pathQueue,其实是用来存储记录的一个数组,为之后实现back做一个铺垫。很简单的逻辑。

push({path,name,query}) {
    history.pushState(null,null,path);
    this.confirmTransitionTo({path,name,query});
  }

history模式-主要是通过pushState进行path切换,然后再调用confirmTransitionTo

replace

  replace({path}) {
    this.pathQueue.length = 0;
    this.confirmTransitionTo({hash:path});
  }

Hash模式-会把记录栈清空,再调用confirmTransitionTo

  replace({path}) {
    history.replaceState(null,null,path);
    this.confirmTransitionTo({path});
  }

history模式-主要是通过replaceState进行path切换,然后再调用confirmTransitionTo

back

  back() {
    let pathQueue = this.pathQueue;
    let path = null;
    if(pathQueue.length<=1) {
      console.error('pathQueue value is < 2:redirect to /');
      path = '/'
    } else {
      path = pathQueue[pathQueue.length-2];
      pathQueue.length = pathQueue.length-2;
    }
    this.pathQueue.push(path);
    this.confirmTransitionTo({hash:path});
  }

Hash模式-这里做了一个判断,如果记录栈只有一个记录,如果调用back就会返回根目录,如果不止一个记录,就会返回上一个记录。

setUpListener() {
    window.onpopstate = function() {
      let path = this.getPath();
      this.confirmTransitionTo({path});
    }.bind(this);
  }
  back() {
    history.go(-1);
  }  

history模式-其实就是调用history,go然后操作游览器历史记录栈,这里会触发popstate事件,然后获取返回的path,再进行路由切换

beforeEach

  //LJRouter
  beforeEach(fn) {
    if(this.mode === 'hash'||this.mode === 'history') {
      this.history.beforeEach(fn);
    } else {
      throw new TypeError('beforeEach is undefined');
    }
  }
 //对应history 例如Hashhistory
  //全局前置守卫
  beforeEach(fn) {
    this.beforeEachCallBack = fn;
  }
  
 confirmTransitionTo({hash,name,query}) {
    let currentRecord;
    currentRecord = this.getCurrentRecord({path:hash,name,query});
    let that = this;
    function next(resolve,reject) {
        return function(path) {
            if(Object.is(undefined,path)) {
               resolve(currentRecord);
            }else {
               location.hash = '#'+path;
               that.confirmTransitionTo({hash:path});
            }
        }
    }

    new Promise(function(resolve,reject){
            if(this.beforeEachCallBack) {
               this.beforeEachCallBack(this.currentRouter.current,currentRecord,next(resolve,reject));
            }else {
              resolve(currentRecord);
            }
          }.bind(this)).then(record=>{
             this.currentRouter.current = record
          });
  }

这里我用了写koa2洋葱圈的写法,不太好说。express中间间机制也差不多.

组件实现

LJView

//LJView
let LJView = {
  functional:true,
  render(c,context) {
    let root = context.parent.$root;
    let component = root._route.current.component;
    if(!component) {
       return c(root.$notFoundComponent);
    }

    return c(root._route.current.component);
  }
}
export default LJView;

function routerInit() {
    if(this.$options.router) {
        this._router = this.$options.router;
        this.$notFoundComponent = notFound;
        install._vue.util.defineReactive(this,'_route',this._router.history.currentRouter);
    } else {
        this._router = this.$parent._router;
    }

}
 confirmTransitionTo({hash,name,query}) {
    let currentRecord;
    currentRecord = this.getCurrentRecord({path:hash,name,query});
    let that = this;
    function next(resolve,reject) {
        return function(path) {
            if(Object.is(undefined,path)) {
               resolve(currentRecord);
            }else {
               location.hash = '#'+path;
               that.confirmTransitionTo({hash:path});
            }
        }
    }

    new Promise(function(resolve,reject){
            if(this.beforeEachCallBack) {
               this.beforeEachCallBack(this.currentRouter.current,currentRecord,next(resolve,reject));
            }else {
              resolve(currentRecord);
            }
          }.bind(this)).then(record=>{
             this.currentRouter.current = record
          });
  }

LJView是一个函数式组件,这里其实很数据响应式有关,通过defineReactived定义一个响应式数据,然后在render函数中使用会产生依赖收集过程(普遍认为模板编译时候会产生依赖收集,但模板终究会编译成render函数,因此在render函数中会使用该变量,产生依赖收集(如果不对,请大佬指教)),当confirmTransitionTo调用时,会改变该数据,从而触发组件更新。

LJLink

let LJLink = {
    props:{
      to:{
        type:String,
        default:""
      },
      tag:{
        type:String,
        default:'a'
      }
    },
    render(c) {
      let tag = this.tag;
      return c(tag,{on:{click:()=>{this.$router.push({path:this.to})}}},this.$slots.default[0].text);
    }
}
export default LJLink;

LJLink的实现比较简单

结尾

写的比较废话,代码方面没有优化,而且有些功能也没有时间去实现了,又要上班了。我是小白,请大佬小喷