简易的vue-router实现(一)

267 阅读4分钟

前言

本文只是一个demo,有很多功能没有实现,可以之后会出几篇文章将这个vue-router的核心功能补全。

已经实现的核心功能

  • router-linkrouter-component
  • $router$route
  • hash modehistory mode
  • 监听路由变化切换组件

之后考虑实现的功能

  • 嵌套路由
  • 动态路由匹配

思考

当你通过vue-cli安装vue全家桶的时候,你翻开src/router/index.js

import Vue from "vue";
import VueRouter from "vue-router";
import Home from "../views/Home.vue";

// 思考1: 为什么使用Vue.use
Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/about",
    name: "About",
    // route level code-splitting
    // this generates a separate chunk (about.[hash].js) for this route
    // which is lazy-loaded when the route is visited.
    component: () =>
      import(/* webpackChunkName: "about" */ "../views/About.vue"),
  },
];

// 思考2: 为什么要new VueRouter
const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

export default router;

再点开src/main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

Vue.config.productionTip = false

//  思考3: 为什么要router传进去的是根实例
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

点开src/App.vue

<template>
  <div id="app">
    <div id="nav">
      <!-- 思考4: 为什么我没有import router-link和router-view但是可以使用 -->
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </div>
    <router-view/>
  </div>
</template>
<script>
export default {
    methods: {
      xx() {
        //  思考5: 为什么我可以在任意组件访问到$router,而且这个$router是同一个东西
        this.$router.push("/")
      }
  },
}
<script>

思考6: 为什么我点击router-link切换视图页面却不会跳转和刷新

我自己的思考

  1. 使用Vue.use很明显VueRouter是一个插件,我们需要实现一个install方法从而可以安装这个插件
  2. new VueRouter({}) 说明了VueRouter是一个类,或者说构造函数
  3. 这个在先卖个关子,在后面进行解答。
  4. 没有import但是可以直接使用,说明这两个组件是全局组件,那么在那里注册的呢,答案很明显,在Vue.use(VueRouter)的时候执行了全局注册
  5. 很明显,任意组件都能访问且是同一个东西,说明这个$router是挂载到Vue.prototype上的。
  6. router-link本质上是一个a标签,但是默认的跳转事件被拦截了,取而代之的是切换router-view所渲染的组件

正文

src/router文件夹下创建mRouter.js文件夹编写我们自己的VueRouter类。

1.最初的样子

1)首先定义VueRouter这个类

2)接着实现install方法,接着全局注册两个router-linkrouter-view这个两个组件

3)router-linkrouter-view 现在这两个东西是我随便写的,下面会进行完善

src/router/mRouter.js

class VueRouter {
  constructor() {}
}

VueRouter.install = function(vue) {
  vue.component("router-link", {
    render(h) {
      return h("a", {}, this.$slots.default);
    },
  });

  vue.component("router-view", {
    render(h) {
      return h("div", {}, "renderrender");
    },
  });
};

export default VueRouter;

效果

image.png

2.挂载$router

Q: 上面提到,我们想要在Vue.prototype上挂载$router,这时候就产生了一个问题: router是在new Vue({router})中传入的,而如果Vue.use(VueRouter)是在new Vue({})之前执行的,这时候我们在install中无法访问到Vue实例,因此也就挂载不上。

遇到这个问题,我们想一下我们的期待什么。我们希望延迟到将来的某个时刻执行,具体点说就是在new Vue({})这个根实例创建时执行

实现的方法我们可以看一下源码中如何做到的

image.png

A:可以看到,源码的解决方案是全局混入一个beforeCreate的钩子函数,然后在每次beforeCreate的时候判断一下是否是根实例(根实例的options中才有router属性),然后挂载上去。

自己实现

VueRouter.install = function(vue) {
  vue.mixin({
    beforeCreate() {
      if (this.$options.router !== undefined) {
        vue.prototype.$router = this.$options.router;
      }
    },
  });

  vue.component("router-link", {
    props: ["to"],
    render(h) {
      return h("a", { attrs: { href: this.to } }, this.$slots.default);
    },
  });

  vue.component("router-view", {
    render(h) {
      // 打印一下
      console.log("test-router--", this.$router);
      return h("div", {}, "renderrender");
    },
  });
};

效果

image.png

3.url与视图建立响应式

在这里我们需要用一个变量current来记录当前的url,当url发生变化的时候,我们更新current,随着视图也发生变化。

我们的mode有history和hash这两种,我们分别监听popstate或者hashchange这两个事件,当触发的时候更新current

接下来就出现了一个问题,我们直接更改currentrender函数中会知道current更新了吗?我们来试一下。

首先我们把mode切换到hash模式,因为history模式下我们还没做拦截。

class VueRouter {
  constructor(options) {
    this.$options = options;

    this.current = "/";

    const strategy = {
      hash: () => {
        window.addEventListener("hashchange", () => {
          //  '#/about/id=1123' => 不需要#
          this.current = window.location.hash.slice(1);
        });
      },
      history: () => {
        window.addEventListener("popstate", () => {
          //  /abc 就是我们想要的
          this.current = window.location.pathname;
        });
      },
    };

    strategy[options.mode] && strategy[options.mode]();
  }
}

VueRouter.install = function(vue) {
  vue.mixin({
    beforeCreate() {
      if (this.$options.router !== undefined) {
        vue.prototype.$router = this.$options.router;
      }
    },
  });

  vue.component("router-link", {
    props: ["to"],
    render(h) {
      let prefix = this.$router.$options.mode === "hash" ? "#" : "";
      return h("a", { attrs: { href: prefix + this.to } }, this.$slots.default);
    },
  });

  vue.component("router-view", {
    render(h) {
      // 我们点击不同的url看一下console是否会触发
      console.log("render---", this.$router.current);
      return h("div", {}, "renderrender");
    },
  });
};

export default VueRouter;

test.gif

明显render函数中根本就不知道current发生了变化,所以它只执行了一次。

使用过Vue大概都知道如何处理这种情况,我们只需要让current变成响应式,Vue收集到这个依赖后,就会在current更新时去触发对应的render函数。

在这里又衍生出了一个问题,我们怎么将其变成响应式。直接使用Vue.$setObject.definePropertynew Vue({data():{return current:"/"}})或者其他?

在这里直接使用Vue.$set是不行的,至于为什么大家可以想一下$set的应用场景:把一个非响应式的属性添加到一个响应式对象中,使其变成响应式属性。在这里,我们甚至都没有响应式对象,那么如何使用$set呢。

第二种方式可以自己尝试去做,第三种是可行的,但是在这里我们不去用。

大家回头去看 2.挂载$router 里面我截源码那部分的内容,那里出现了一个Vue.util.defineReactive这个函数,这个是Vue里面的工具函数,帮助我们去创建一个响应式属性。

/**
 * Define a reactive property on an Object.
 */
function defineReactive$$1 (
  obj,
  key,
  val,
  customSetter,
  shallow
) {
  var dep = new Dep();

  var property = Object.getOwnPropertyDescriptor(obj, key);
  if (property && property.configurable === false) {
    return
  }

  // cater for pre-defined getter/setters
  var getter = property && property.get;
  var setter = property && property.set;
  if ((!getter || setter) && arguments.length === 2) {
    val = obj[key];
  }

  var childOb = !shallow && observe(val);
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter () {
      var value = getter ? getter.call(obj) : val;
      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
          if (Array.isArray(value)) {
            dependArray(value);
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (process.env.NODE_ENV !== 'production' && customSetter) {
        customSetter();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) { return }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
  });
}

我们来使用这个defineReactive看下是否可行

let Vue = null;

class VueRouter {
  constructor(options) {
    this.$options = options;
    
    // 这里的Vue是下面的install中弄到的
    Vue.util.defineReactive(this, "current", "/");

    const strategy = {
      hash: () => {
        window.addEventListener("hashchange", () => {
          //  '#/about/id=1123' => 不需要#
          this.current = window.location.hash.slice(1);
        });
      },
      history: () => {
        window.addEventListener("popstate", () => {
          //  '#/about/id=1123' => 不需要#
          this.current = window.location.pathname;
        });
      },
    };

    strategy[options.mode] && strategy[options.mode]();
  }
}

VueRouter.install = function(vue) {
  //  因为是先执行Vue.use() 再执行 new VueRouter(),所以我们可以在constructor中访问到Vue
  Vue = vue;

  vue.mixin({
    beforeCreate() {
      if (this.$options.router !== undefined) {
        vue.prototype.$router = this.$options.router;
      }
    },
  });

  vue.component("router-link", {
    props: ["to"],
    render(h) {
      let prefix = this.$router.$options.mode === "hash" ? "#" : "";
      return h("a", { attrs: { href: prefix + this.to } }, this.$slots.default);
    },
  });

  vue.component("router-view", {
    render(h) {
      console.log("render---", this.$router.current);
      return h("div", {}, "renderrender");
    },
  });
};

export default VueRouter;

test.gif

很好,我们就差一步就搞定了,就是根据current去匹配路由表中的path然后渲染对应的component,这里我没考虑嵌套路由和动态路由匹配的情况。

let Vue = null;

class VueRouter {
  constructor(options) {
    this.$options = options;

    Vue.util.defineReactive(this, "current", "/");

    // this.current = "/";

    const strategy = {
      hash: () => {
        window.addEventListener("hashchange", () => {
          //  '#/about/id=1123' => 不需要#
          this.current = window.location.hash.slice(1);
        });
      },
      history: () => {
        window.addEventListener("popstate", () => {
          //  '#/about/id=1123' => 不需要#
          this.current = window.location.pathname;
        });
      },
    };

    strategy[options.mode] && strategy[options.mode]();
  }
}

VueRouter.install = function(vue) {
  Vue = vue;

  vue.mixin({
    beforeCreate() {
      if (this.$options.router !== undefined) {
        vue.prototype.$router = this.$options.router;
      }
    },
  });

  vue.component("router-link", {
    props: ["to"],
    render(h) {
      let prefix = this.$router.$options.mode === "hash" ? "#" : "";
      return h("a", { attrs: { href: prefix + this.to } }, this.$slots.default);
    },
  });

  vue.component("router-view", {
    render(h) {
      console.log("render---", this.$router.current);

      const component = this.$router.$options.routes.find((item) => {
        return item.path === this.$router.current;
      })?.component ?? {
        render(h) {
          return h("div", {}, "404");
        },
      };

      return h(component);
    },
  });
};
export default VueRouter;

test.gif

结语

其实这部分的内容很多大佬估计都知道,本菜鸡也是最近才开始看源码,跟着大佬的思考走,然后按照提出来的问题来思考如何一步步解决问题。实在想不出还是得啃源码,毕竟标准答案就在那

参考

跟着来,你也可以手写VueRouter

从路由到 vue-router 源码,带你吃透前端路由

Vue Router