路由原理与 vue-router 源码解析

134 阅读6分钟

路由原理与 vue-router 源码解析

在 Web 前端单页应用 SPA(Single Page Application)中,路由描述的是 URL 与 UI 之间的映射关系,这种映射是单向的,即 URL 变化引起 UI 更新(无需刷新页面)。

源码地址

源码

路由核心

  • 不引起页面刷新
  • 检测 URL 变化

客户端的两种模式

明面上的区别:

hash 模式中路径中带有 #

hash模式原理

# 号的含义

# 代表网页中的一个位置,其右边的字符,就是该位置的标识符

比如如下链接:

http://www.example.com/index.html#this

就是代表 index.html 中的 this 位置。浏览器会自动把 this 位置滚动到页面可视区域内。

案例:example/hash标识符.html

核心代码:

<a href="#this">点击、展示这里</a>
<p id="this">这里可见</p>

hashchange

当 URL 的片段标识符更改时,将触发 hashchange 事件 (跟在#符号后面的 URL 部分,包括#符号)

获取与操作 hash 值相关的方法:

  • location.hash
    • 它是一个可读可写的属性 。返回一个字符串,包含 URL 标识中的 # 和 后面 URL 片段标识符。
  • location.assign()
  • 用给定的 URL 替换掉当前的资源
  • location.replace()
  • 用给定的 URL 替换掉当前的资源。与 assign() 方法不同的是用 replace() 替换的新页面不会被保存在会话的历史 History 中,这意味着用户将不能用后退按钮转到该页面。

监听以下三种的 hash 变化,分别获取 hash 值:

  • # 值为:空字符串 ''
  • #/ 值为:空字符串 #/
  • #/a 值为:空字符串 #/a

案例:example/hash-获取hash值.html

但是在实际获取 hash 值时,使用的是另外的方法。

是对地址栏的地址做了切割得到了 hash 值。这是因为在火狐浏览器中会存在问题。具体原因查看如下链接:

浏览器兼容 | 火狐中的具体的问题

获取 hash 值 ,不包含 # 号

  function getHash() {
    let href = window.location.href;
    const index = href.indexOf("#");
    // empty path
    if (index < 0) return "";
    href = href.slice(index + 1);
    return href;
  }

当 hash 为 # 与 #/ 时,获取的 hash 值时不同的。

在 vue-router 中,当我们使用 hash 模式时,我们的默认的页面地址永远都是 music.163.com/#/ ,不是 music.163.com/ 或者 music.163.com/# 的链接。页面打开之后的 hash 值永远为 #/

这是因为在源码中为了统一, 对 hash 进行了处理。主要方法是 ensureSlash。同时使用 window.location.replace 进行了页面的重定向

  function ensureSlash() {
    // 获取 hash 值
    const path = getHash();
    if (path.charAt(0) === "#") {
      return true;
    }
    replaceHash("/" + path);
    return false;
  }
  function getUrl(path) {
    const href = window.location.href;
    const i = href.indexOf("#");
    const base = i >= 0 ? href.slice(0, i) : href;
    return `${base}#${path}`;
  }
  function replaceHash(path) {
    window.location.replace(getUrl(path));
  }

案例:example/hash-生产使用.html

对 hash 值进行更改

  function pushHash(path) {
    window.location.hash = path;
  }

location.replace

replace()替换的新页面不会被保存在会话的历史 History 中,这意味着用户将不能用后退按钮转到该页面。

比如我们存在 a->b->c 三个页面:

当我们使用 replace 从 b 跳转到 c 时,这相当于直接把 b 页面替换成了 c 页面,而不是重新打开了一个页面 c,此时回退,从效果上看就是从 c 直接返回到 a 页面。

案例:example/hash-replace.html

请求

无论 hash 如何变化 页面中发出的请求地址永远都是一样的。

案例:example/hash-req.html

改变 hash 之后随便刷新。

查看 Request URL 都是一样的值:/**/vue-router-learn/example/hash-req.html

HTTP请求中不包含 #

popstate

在 Html5 中新增了 popstate 事件,当 hash 变化时,使用 popstate 也可以监听到 hash 变化。

案例:example/hash-popstate.html

在 Html5 中新增的不仅仅是 popstate

  • h5之前,通过 history 对象能够访问浏览器历史记录 前进/后退

    • length 属性可以访问历史记录对象的长度
    • forward 和 back 方法如同浏览器的前进和后退功能
    • go 方法可以通过指定一个相对于当前页面的索引值来跳转到相应的记录。(正进负退)
  • h5之后,通过 history 对象能够操纵浏览器历史记录 到达指定url

    • pushState 方法 添加
      • 能在不刷新页面(不发请求)的前提下,将当前页面地址保存为最近一条历史记录,同时修改当前页面地址
    • replaceState 方法 替换
      • 与 pushState 很相似,区别在于是修改当前历史记录(只修改当前页面地址),而 pushState 是创建新的历史记录
window.history.pushState(state, title, targetURL);
state 状态对象:传给目标路由的信息,可为空
title 页面标题:目前所有浏览器都不支持,填空字符串即可
targetURL 可选url:目标url,不会检查url是否存在,且不能跨域。如不传该项,即给当前url添加 data
window.history.replaceState(state, title, targetURL);
类似于pushState,但是会直接替换掉当前url,而不会在history中留下记录

注意:用history.pushState()或者history.replaceState()不会触发popstate事件。

popstate 事件会在点击后退、前进按钮(或调用history.back()history.forward()history.go()方法)时触发

history模式原理

history模式原理就是利用了 h5 新增的 window.history.pushStatewindow.history.replaceState 方法

同时 history 模式需要后台配置支持,因为我们的应用是个单页客户端应用,如果后台没有正确的配置,当用户在浏览器直接访问 http://oursite.com/user/id 就会返回 404,这就不好看了

pushState 的优点

developer.mozilla.org/zh-CN/docs/…

两种模式的对比

如果不想要很丑的 hash,我们可以用路由的 history 模式,不过这种模式要玩好,还需要后台配置支持。

源码分析

目录结构

|-- vue-router
    |-- create-matcher.js
    |-- create-route-map.js
    |-- index.js
    |-- install.js
    |-- components
    |   |-- link.js
    |   |-- view.js
    |-- history
    |   |-- abstract.js
    |   |-- base.js
    |   |-- hash.js
    |   |-- html5.js
    |-- util
        |-- async.js
        |-- dom.js
        |-- errors.js
        |-- location.js
        |-- misc.js
        |-- params.js
        |-- path.js
        |-- push-state.js
        |-- query.js
        |-- resolve-components.js
        |-- route.js
        |-- scroll.js
        |-- state-key.js
        |-- warn.js

从使用开始
const routes = [
  {
    path: "/",
    name: "Home",
    component: Home,
  },
  {
    path: "/about",
    name: "About",
    component: function () {
      return import("../views/About.vue");
    },
    children: [
      {
        path: "child",
        name: "child",
        component: function () {
          return import("../views/AboutChild.vue");
        },
      },
    ],
  },
];

const router = new VueRouter({
  routes,
});
create-route-map.js

所有的脏活累活都是我干的,它主要是对用户传入的路由列表做了扁平化处理,并且得到了一个路径与组件的映射表,对于存在 children 的也会标明其父组件。如下所示:

{
    "/": {
      component: "Home",
      parent: undefined,
      path: "/",
    },
    "/about": {
      component: "About",
      parent: undefined,
      path: "/about",
    },
    "/about/child": {
      component: "AboutChild",
      parent: {
        component: "About",
        parent: undefined,
        path: "/about",
      },
      path: "/about/child",
    },
};
create-matcher.js

它的主要作用是,根据上一步得到的路由映射表,根据传入的参数得到一个匹配的列表。

比如:当前路径为 / , 会得到一个相匹配的数据:

{
  matched: [
    {
      component: "Home",
      parent: undefined,
      path: "/",
    },
  ],
  path: "/",
};

当前路径为 /about/child , 会得到一个相匹配的数据:

{
  matched: [
    {
      component: "About",
      parent: undefined,
      path: "/about",
    },
    {
      component: "AboutChild",
      parent: {
        component: "About",
        parent: undefined,
        path: "/about",
      },
      path: "/about/child",
    },
  ],
  path: "/about/child",
};

存在嵌套路由时,在 matched 列表中会按照 父-子 的顺序返回。

index.js

VueRouter类,也是整个插件的入口

install.js

提供插件安装方法

components

这里面是两个组件 router-view 和 router-link

history

这个是路由模式(mode),有三种方式

util

这里是路由的功能函数和类

源码分析

index.js 入口开始
import { install } from "./install";
class VueRouter {
}

VueRouter.install = install;
export default VueRouter;

首先看插件的安装的方法。

import View from "./components/view";
import Link from "./components/link";

// 导出 Vue 这样就不用再引入 Vue
export let _Vue;

export function install(Vue) {
  // 防止多次调用
  // 对象比较的是内存地址
  if (install.installed && _Vue === Vue) return;
  install.installed = true;

  _Vue = Vue;

  const isDef = (v) => v !== undefined;
  Vue.mixin({
    beforeCreate() {
      // 所有组件都可通过 this._routerRoot._router 访问 router 实例
      // 但是这样太长而且用户可以修改此属性,所以下面会代理
      if (isDef(this.$options.router)) {
        this._routerRoot = this;
        this._router = this.$options.router;
        // 路由初始化
        this._router.init(this);
        // route 与页面关联
        Vue.util.defineReactive(this, "_route", this._router.history.current);
      } else {
        // 防止根组件没有传递 router 时,调用 this.$router 的属性或者方法报错
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
      }
    },
  });

  // 代理 this.$router 与  this.$route
  Object.defineProperty(Vue.prototype, "$router", {
    get() {
      return this._routerRoot._router;
    },
  });

  Object.defineProperty(Vue.prototype, "$route", {
    get() {
      return this._routerRoot._route;
    },
  });

  Vue.component("RouterView", View);
  Vue.component("RouterLink", Link);
}

做的事情有:

  • 全局注册 RouterViewRouterLink 组件
  • 通过混入的方式为每个组件都绑定了 router 实例
  • 将路由的当前对象 current 绑定到了组件的 _route 属性上,这样当 current 属性变化时,页面机会变化,这是因为在 RouterView 组件中引入了 _route
  • 代理了混入的属性,方便使用且不允许用户修改
this._router.init(this);

路由初始化

import { install } from "./install";
import { createMatcher } from "./create-matcher";
import { HashHistory } from "./history/hash";
import { HTML5History } from "./history/html5";
class VueRouter {
  constructor(options) {
    this.options = options;
    // 暂存匹配的对象
    this.matcher = createMatcher(options.routes || [], this);
    let mode = options.mode || "hash";
    this.mode = mode;
    // 根据不同的模式调用不同的路由
    switch (mode) {
      case "history":
        this.history = new HTML5History(this);
        break;
      case "hash":
        this.history = new HashHistory(this);
        break;
    }
  }
  init(app) {
    this.app = app;
    const history = this.history;
    let setupListeners = () => {
      history.setupListeners();
    };
    // 导航到当前路径并监听路由的变化
    history.transitionTo(history.getCurrentLocation(), setupListeners);

    // 初始化 _route 的值
    history.listen((route) => {
      this.app._route = route;
    });
  }
  match(location) {
    return this.matcher.match(location);
  }
}

VueRouter.install = install;
export default VueRouter;

做的事情有:

  • 调用 createMatcher 方法之后,createMatcher 会调用 createRouteMap ,这样会得到上面说的路由映射表。调用 this.matcher.match(location) 之后就会到得匹配到的路由数据
  • 然后根据不同的 mode 模式调用不同的路由类。
  • init() 方法要先看 HashHistory
  • 调用公共的导航到具体路径的方法,然后分别调用各自子类实现的 获取当前路径与 监听路由路径变化
HashHistory

hash 模式的路由类

因为 hash 与 history 类 是存在一些相同的方法的,所以他们会有一个父类 History

/* eslint-disable no-unused-vars */

import { createRoute } from "./../create-route-map";
export class History {
  // implemented by sub-classes
  go(n) {}
  push(loc) {}
  replace(loc) {}
  ensureURL(push) {}
  getCurrentLocation() {}
  setupListeners() {}
  constructor(router) {
    this.router = router;
    this.current = createRoute(null, {
      path: "/",
    });
  }
  // 导航到具体的路径
  transitionTo(location, onComplete) {
    let route = this.router.match(location);
    this.updateRoute(route);
    onComplete && onComplete();
  }
  // 更新路径
  updateRoute(route) {
    this.current = route;
    this.cb && this.cb(route);
  }
  listen(cb) {
    this.cb = cb;
  }
}

做的事情有:

  • 定义了 current 属性,就是在插件中使用的 。他的值就是上面 create-matcher.js 说到的数据结构
  • 定义了 transitionTo 方法,导航到具体的路径然后更新 current
  • 除了上面的其他的方法都是各自的子类实现的。

HashHistory

import { History } from "./base";
import { replaceState, supportsPushState } from "../util";
export class HashHistory extends History {
  constructor(router) {
    super(router);
    ensureSlash();
  }
  getCurrentLocation() {
    return getHash();
  }
  setupListeners() {
    const eventType = supportsPushState ? "popstate" : "hashchange";
    window.addEventListener(eventType, () => {
      this.transitionTo(getHash());
    });
  }
}
function ensureSlash() {
  const path = getHash();
  if (path.charAt(0) === "/") {
    return true;
  }
  replaceHash("/" + path);
  return false;
}

export function getHash() {
  // We can't use window.location.hash here because it's not
  // consistent across browsers - Firefox will pre-decode it!
  let href = window.location.href;
  const index = href.indexOf("#");
  // empty path
  if (index < 0) return "";

  href = href.slice(index + 1);

  return href;
}

function getUrl(path) {
  const href = window.location.href;
  const i = href.indexOf("#");
  const base = i >= 0 ? href.slice(0, i) : href;
  return `${base}#${path}`;
}
function replaceHash(path) {
  if (supportsPushState) {
    replaceState(getUrl(path));
  } else {
    window.location.replace(getUrl(path));
  }
}

做的事情有:

  • ensureSlash 确保hash 模式下的 hash 值为 #/
  • getCurrentLocation 获取当前的 hash值
  • setupListeners 定义了如何监听路由的变化
  • 他们两个要子类实现的原因是 hash 模式与 history 对路径的处理不一致,而且 history 模式 只监听 popstate, hash 模式 则是 hashchange 与 popstate 都监听
RouterView
// 函数式组件

export default {
  name: "RouterView",
  functional: true,
  props: {
    name: {
      type: String,
      default: "default",
    },
  },
  render(h, { parent, data }) {
    // used by devtools to display a router-view badge
    data.routerView = true;
    const route = parent.$route;
    // determine current view depth, also check to see if the tree
    // has been toggled inactive but kept-alive.
    let depth = 0;

    while (
      parent &&
      parent.$vnode &&
      parent.$vnode.data &&
      parent.$vnode.data.routerView
    ) {
      depth++;
      parent = parent.$parent;
    }

    const matched = route.matched[depth];
    const component = matched && matched.component;
    // render empty node if no matched route or no config component
    if (!matched || !component) {
      return h();
    }
    return h(component, data);
  },
};

做的事情有:

  • 对 RouterView 组件声明了一个属性 routerView,用于标识
  • 定义了深度 depth ,这是对标 current 中的 matched 列表,还记得组件嵌套的数据格式嘛
  • 匹配到之后渲染组件即可,渲染的时候肯定是选渲染子组件,再渲染父组件了