vueRouter 源码讲解

81 阅读2分钟

本质就是借助响应式组件化来实行内容的替换

路由注册

  • 使用 Vue.use() 进行注册并获取当前 Vue 实例(为了使用 Vue 上的辅助函数),且会进行保存为后续去读响应式对象用

路由安装

  • 通过 Vue.mixin() 定义了 beforeCreate/destroyed 两个钩子函数(由于是 mixin 之后每个组件对象都会合并这两个函数并执行)
    • beforeCreate 内定义了 this._routerRoot/._router。并通过defineReactive(this, '_route', this._router.history.current)往根实例上设置响应式
      • this._router.init(this) 来初始化 router**(重要,只在 new Vue() 阶段执行)**
      • this._routerRoot 是通过 this.$parent 从父组件获取的,所以它是继承过来的
  • 并在 Vue.prototype 上设置了 router/router/route 属性。实际返回的是 this._routerRoot._router/._route 内容
  • 注册了<router-link>/<router-view>两个组件
  • 最后把整个 install() 函数挂到 VueRouter.install 上
Vue.mixin({
  beforeCreate() {
    /**
       * this.$options 是当前组件初始化时的选项
       * 读取的是 main.js 里的 new Vue({ router })
       * 其他 x.vue 页面是没有配置 router 选项的
       */
    if (this.$options.router) {
      // !只有根组件才触发
      /**
         * 这么操作后 this.__routerRoot._router 就可以访问了
         * 这样后面 $router 就可以获取了
         */
      this._routerRoot = this;
      this._router = this.$options.router;

      this._router.init(this);
      /**
         * 使用 Vue.util.defineReactive 创建一个 响应式对象
         * 对 app._route => this._route 进行劫持并添加响应式
         * 注意 _route、route/current、$route 三个的关系
         * - _route 是用于 响应式 绑定的对象
         * - current/route ?这个是不是可以不用
         * - $route 是组件内使用时的引用对象
         */
      Vue.util.defineReactive(this, '_route', this._router.history.current);
    } else {
      // !非根组件
      /**
         * 组件树是树状结构的,按照组件嵌套初始化的规则
         * 这里始终从它的父节点获取 _routerRoot 对象
         */
      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
    }
  }
});

为什么使用 mixin 方式,猜测是为了获取响应式对象。因为该对象只设置在了根this上,所以通过执行beforeCreate时从父组件继承

VueRouter 对象

  • new VueRouter()对象创建时,内部创建了this.matcher = createMatcher(options.routes) 路由匹配器。并按 mode 创建 this.history 对象(history/hash 方式)
  • 之后在new Vue()执行 beforeCreate 钩子时,会执行 router.init() 函数 -> 进而执行history.transitionTo()/history.listen() 函数
    • 这时的 history.transitionTo() 会触发第一次的页面匹配渲染,从而展示内容
    • history.listen() 用来添加路由匹配完成后的回调函数。实际是替换响应式的_route从而让<routerView>能进行渲染更新
class VueRouter {
  constructor(options) {
    this.matcher = createMatcher(options.routes || []);
    this.mode = options.mode || 'hash';
  }
  
  init() {
    const history = this.history;
    if (history instanceof HTML5History || history instanceof HashHistory) {
      /**
       * 初始化时执行一次,确保能渲染对应组件
       * hashchange 只有在 hash 改变时生效
       */
      history.transitionTo(history.getCurrentLocation(), () => {
        history.setupListeners();
      });
    }
    /**
     * 绑定回调事件,确保每次 transitionTo 后在当前实例下能拿到数据
     * app 指的是根节点。所以在根节点上新增 _route 属性
     * 把回调传进去,确保每次 current 更改都能顺便更改 _route 触发响应式
     */
    history.listen((route) => {
      // this._route 已经是响应式的
      app._route = route;
    });   
  }
}

matcher

  • createMatcher -> 通过 createRouteMap() 对传入的routes进行处理生成一个路由表对象
    • createRouteMap() 内维护了 pathList/pathMap/nameMap 3个和 RouterRecord 相关的映射关系变量
      • pathMap/nameMap 所有的记录都是平级的,父子关系通过record.parent字段维护
const pathList = ["/home", "/home/child1", "/home/child2", "/hello", "/hello/child1"]
// 这里的 component 就是后<router-view>组件用来替换的 VueComponent 实例
const pathMap = {
  "/hello": {path: xxx, component: xxx, parent: xxx },
  "/hello/child1": {path: xxx, component: xxx, parent: xxx },
}
  • addRoutes() 本质就是往 pathList/pathMap 添加新的数据 -> createRouteMap() 维护新的关系
  • match() 函数 -> 通过 path 匹配到的 record。然后再在 createRoute() 内通过该 record 去循环向上找(通过 child.parent 获取父)最终得到完整的路径matched = [ {}, {}, {} ] 父->子这样的顺序。它们也正好来对应 组件的层级,这样就确保了 view 渲染对应的 component
function createRoute(record, path) {
  const route = {
    path,
    matched: []
  };

  if (record) {
    // 递归遍历
    // 从 子.parent 获取 父,最终得到 父->子 这样的顺序
    while (record) {
      route.matched.unshift(record);
      record = record.parent;
    }
  }

  return route;
}

路径切换

  • history.transitionTo(path, onComplete):是切换路由时都会触发的函数。$router.push/replace()API也都是通过它的实现路由切换的
    • 通过拿到 this.matcher.match() 匹配的record路由对象 -> 触发 this.confirmTransition() -> 触发 this.updateRoute() 最终会更新app._route = route(history.listen() 定义的)这样就会触发响应式配合<routeView>来更新界面
    • 之后的 onComplete 分两种情况
      • 在初始init()时执行this.setupListeners()。是添加 hashchange/popstate 事件监听,这样我们修改URL时也能触发视图的更新
      • 后续执行$router.push/replace()等。是先去修改响应式对象触发视图更新,后再去修改实际的URL地址信息。
    • 相同判断:由于 API 会触发transitionTo()和改变URL,但改变URL又出发了监听的事件从而会再次执行transitionTo(),所以这里有个相同route判断来禁止循环触发
  • URL 地址:正常我们通过<routerLink> api去更改路由实际执行的是 history.transitionTo() 而它只是按 path 匹配 record 并不修改 url 地址,所有在 transitionTo 执行完后还需要触发 pushHash/replaceHash/pushState()去更改实际的URL地址/添加记录栈信息
    • 它们分别是封装了location.hash/.replacehistory.pushState/.replaceState
  • 组件:<routerView>通过 parent.$router = this._routerRoot._route 来获取匹配到的 matched 内容
    • 由于 matched 是安装父->子顺序组合,实际就对应着 组件的嵌套层级。但是view组件本身不知道自己是第几个,所以需要有一个按照 parent.$vnode 的层级去获取当前的层级数depth过程**。**最后通过 matched[depth] 获取对应的组件进行渲染展示
// base.js/html5.js 合并
class History {
  /**
   * path 是要匹配的路径
   */
  transitionTo(path, onComplete) {
    // this.router = new VueRouter 实例 -> vueRouter.match -> this.matcher.match
    let route = this.router.match(path);
    const current = this.current;
    /**
     * 不管是 hash/history 添加相同的记录,都是会触发的
     * 并且 router-link 也是调用 api 触发的
     * 所以这里要对重复的内容进行拦截,防止重复渲染
     */
    if (isSameRoute(route, current)) {
      return;
    }
    this.updateRoute(route);
    onComplete && onComplete(route);
  }
  /**
   * 更新内部记录
   * 更新外部的 app._route 用于触发试图刷新
   */
  updateRoute(route) {
    this.current = route;
    this.cb && this.cb(route);
  }
  
  setupListeners() {
    if (this.listeners.length > 0) return;
    const handleRoutingEvent = () => {
      const location = getLocation();
      this.transitionTo(location);
    };
    // 注意 popstate 的触发条件
    window.addEventListener('popstate', handleRoutingEvent);
    this.listeners.push(() => {
      window.removeEventListener('popstate', handleRoutingEvent);
    });
  }

  push(path) {
    this.transitionTo(path, (route) => {
      pushState(route.path);
    });
  }

  replace(path) {
    this.transitionTo(path, (route) => {
      pushState(route.path, true);
    });
  }
}

// <routerView> 组件
const RouterView = {
  function: true,
  render(h, {parent, children, data}) {
    const { matched } = parent.$route;
    // 标识此组件为 router-view
    data.routerView = true;
    /**
     * 因为 matched 该路径下全量数据
     * 所有按照 路径 层级找到对应要展示的 component
     * 是由外往内分层展示。一个 view 对应一层,按层级对应
     */
    let depth = 0;
    while (parent) {
      // 如果有 父组件 且 父组件为 <router-view> 说明索引需要加 1
      if (parent.$vnode && parent.$vnode.data.routerView) {
        depth += 1;
      }
      parent = parent.$parent;
    }
    // 这里的 depth 是对应层级要展示的路径
    const record = matched[depth];
    /**
     * 把 routerView 替换为对应要展示的 component
     */
    const component = record.component;
    return h(component, data, children);
  }
}