手拉手实现mini-vue-router

482 阅读4分钟

本文是手拉手系列中的 “实现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,分别是addRoutesbeforeEach

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需要几步?

  1. 对用户传入options进行格式化,提供matched和addRoutes方法

  2. 给每个Vue实例绑定$router

  3. 实现transitionTo ( 这个方法是Router内置方法,用于路由跳转 ) ,并绑定$route

  4. 实现 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来调用Router的原型方法,这是因为vuerouter通过代理模式为每个Vue实例都添加了一个router来调用Router的原型方法,这是因为vue-router通过代理模式为每个Vue实例都添加了一个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的思路!