vue-router原理剖析

17,972 阅读7分钟

vue-router的使用

页面中所有的内容都是组件化的,只需要把路径跟组件对应,在页面中把组件渲染出来。

  1. 页面实现:在vue-router中, 它定义了两个标签 和来对应点击和显示部分。 就是定义页面中点击的部分, 定义显示部分。

  2. js中配置路由:首先要定义route,一条路由的实现,他是一个对象,由path和component组成。

    这里的两条路由,组成routes:

const routes = [
	{// 首页
		path: '/',
		component: () => import('src/pages/home/index.vue'),
	},
	{// 首页更多功能
		path: '/functionManage',
		component: () => import('src/pages/home/functionManage.vue'),
	},
]
  1. 创建router对路由进行管理,它是由构造函数 new vueRouter() 创建,接受routes 参数。

    router.js文件中

const router = new VueRouter({
	routes,
})
  1. 配置完成后,把router 实例注入到 vue 根实例中。

    main.js文件中

window.vm = new Vue({
	router,
})

执行过程:当用户点击 router-link 标签时,会去寻找它的 to 属性, 它的 to 属性和 js 中配置的路径{ path: '/home', component: Home} path 一一对应,从而找到了匹配的组件, 最后把组件渲染到 标签所在的地方。

前端路由是通过改变URL,在不重新请求页面的情况下,更新页面视图。

目前在浏览器环境中这一功能的实现主要有2种:

  • 利用URL中的hash;
  • 利用H5中history;

vue-router 是 vue.js 框架的路由插件,它是通过 mode 这一参数控制路由的实现模式的。

const router = new VueRouter({
	// HTML5 history 模式
	mode: 'history',
	base: process.env.NODE_ENV === 'production' ? process.env.PROXY_PATH : '',
	routes,
})

在入口文件中需要实例化一个 VueRouter 的实例对象 ,然后将其传入 Vue 实例的 options 中。

var VueRouter = function VueRouter (options) {
    if ( options === void 0 ) options = {};

    this.app = null;
    this.apps = [];
    this.options = options;
    this.beforeHooks = [];
    this.resolveHooks = [];
    this.afterHooks = [];
    // 创建 matcher 匹配函数
    this.matcher = createMatcher(options.routes || [], this);
    // 根据 mode 实例化具体的 History,默认为'hash'模式
    var mode = options.mode || 'hash';
    // 通过 supportsPushState 判断浏览器是否支持'history'模式
    // 如果设置的是'history'但是如果浏览器不支持的话,'history'模式会退回到'hash'模式
    // fallback 是当浏览器不支持 history.pushState 控制路由是否应该回退到 hash 模式。默认值为 true。
    this.fallback = mode === 'history' && !supportsPushState &&   options.fallback !== false;
    if (this.fallback) {
        mode = 'hash';
    }
    if (!inBrowser) {
        // 不在浏览器环境下运行需强制为'abstract'模式
        mode = 'abstract';
    }
    this.mode = mode;

    // 根据不同模式选择实例化对应的 History 类
    switch (mode) {
        case 'history':
            this.history = new HTML5History(this, options.base);
            break
        case 'hash':
            this.history = new HashHistory(this, options.base, this.fallback);
            break
        case 'abstract':
            this.history = new AbstractHistory(this, options.base);
            break
        default:
        {
            assert(false, ("invalid mode: " + mode));
        }
    }
};
VueRouter.prototype.init = function init (app /* Vue component instance */) {
    
    ...
    
    var history = this.history;

    // 根据history的类别执行相应的初始化操作和监听
    if (history instanceof HTML5History) {
        history.transitionTo(history.getCurrentLocation());
    } else if (history instanceof HashHistory) {
        var setupHashListener = function () {
            history.setupListeners();
        };
        history.transitionTo(
          history.getCurrentLocation(),
          setupHashListener,
          setupHashListener
        );
    }

    history.listen(function (route) {
        this$1.apps.forEach(function (app) {
            app._route = route;
        });
    });
};

作为参数传入的字符串属性mode只是一个标记,用来指示实际起作用的对象属性history的实现类,两者对应关系:

modehistory:
    'history': HTML5History;
    'hash': HashHistory;
    'abstract': AbstractHistory;
  1. 在初始化对应的history之前,会对mode做一些校验:若浏览器不支持HTML5History方式(通过supportsPushState变量判断),则mode设为hash;若不是在浏览器环境下运行,则mode设为abstract;
  2. VueRouter类中的onReady(),push()等方法只是一个代理,实际是调用的具体history对象的对应方法,在init()方法中初始化时,也是根据history对象具体的类别执行不同操作

HashHistory

  • hash虽然出现在url中,但不会被包括在http请求中,它是用来指导浏览器动作的,对服务器端没影响,因此,改变hash不会重新加载页面。

  • 可以为hash的改变添加监听事件:

    window.addEventListener("hashchange",funcRef,false)

  • 每一次改变hash(window.location.hash),都会在浏览器访问历史中增加一个记录。

function HashHistory (router, base, fallback) {
    History$$1.call(this, router, base);
    // 如果是从history模式降级来的,需要做降级检查
    if (fallback && checkFallback(this.base)) {
        // 如果降级且做了降级处理,则返回
        return
    }
    ensureSlash();
}

function checkFallback (base) {
    // 得到除去base的真正的 location 值
    var location = getLocation(base);
    if (!/^\/#/.test(location)) {
        // 如果此时地址不是以 /# 开头的
        // 需要做一次降级处理,降为 hash 模式下应有的 /# 开头
        window.location.replace(
            cleanPath(base + '/#' + location)
        );
    return true
    }
}

function ensureSlash () {
    // 得到 hash 值
    var path = getHash();
    // 如果是以 / 开头的,直接返回即可
    if (path.charAt(0) === '/') {
        return true
    }
    // 不是的话,需要手动保证一次 替换 hash 值
    replaceHash('/' + path);
    return false
}

function getHash () {
    // 因为兼容性的问题,这里没有直接使用 window.location.hash
    // 因为 Firefox decode hash 值
    var href = window.location.href;
    var index = href.indexOf('#');
    return index === -1 ? '' : href.slice(index + 1)
}

HashHistory.push()

HashHistory.prototype.push = function push (location, onComplete, onAbort) {
    var this$1 = this;

    var ref = this;
    var fromRoute = ref.current;
    this.transitionTo(location, function (route) {
        pushHash(route.fullPath);
        handleScroll(this$1.router, route, fromRoute, false);
        onComplete && onComplete(route);
    }, onAbort);
};

transitionTo()方法是用来处理路由变化中的基础逻辑的,push()方法最主要的是对window的hash进行了直接赋值:

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

hash的改变会自动添加到浏览器的访问历史记录中。 那么视图的更新是怎么实现的呢,看下 transitionTo()方法:

History.prototype.transitionTo = function transitionTo (location, onComplete, onAbort) {
    var this$1 = this;

    var route = this.router.match(location, this.current);
    this.confirmTransition(route, function () {
        this$1.updateRoute(route);
        ...
    });
};

History.prototype.updateRoute = function updateRoute (route) {
    var prev = this.current;
    this.current = route;
    this.cb && this.cb(route);
    this.router.afterHooks.forEach(function (hook) {
        hook && hook(route, prev);
    });
};

History.prototype.listen = function listen (cb) {
    this.cb = cb;
};

可以看到,当路由变化时,调用this.cb方法,而this.cb方法是通过History.listen(cb)进行设置的,在init()中对其进行了设置:

Vue作为渐进式的前端框架,本身的组件定义中应该是没有有关路由内置属性_route,如果组件中要有这个属性,应该是在插件加载的地方,即VueRouter的install()方法中混入Vue对象的,install.js的源码:

function install (Vue) {
  
  ...

  Vue.mixin({
    beforeCreate: function beforeCreate () {
      if (isDef(this.$options.router)) {
        this._routerRoot = this;
        this._router = this.$options.router;
        this._router.init(this);
        Vue.util.defineReactive(this, '_route', this._router.history.current);
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
      }
      registerInstance(this, this);
    },
    destroyed: function destroyed () {
      registerInstance(this);
    }
  });
}

通过Vue.mixin()方法,全局注册一个混合,影响注册之后所有创建的每个Vue实例,该混合在beforeCreate钩子中通过Vue.util.defineReactive()定义了响应式的_route属性。所谓响应式属性,即当_route值改变时,会自动调用Vue实例的render()方法,更新视图。

$router.push()-->HashHistory.push()-->History.transitionTo()-->History.updateRoute()-->{app._route=route}-->vm.render()

HashHistory.replace()

replace()方法与push()方法不同之处在于,它并不是将新路由添加到浏览器访问历史栈顶,而是替换掉当前的路由:

HashHistory.prototype.replace = function replace (location, onComplete, onAbort) {
    var this$1 = this;

    var ref = this;
    var fromRoute = ref.current;
    this.transitionTo(location, function (route) {
      replaceHash(route.fullPath);
      handleScroll(this$1.router, route, fromRoute, false);
      onComplete && onComplete(route);
    }, onAbort);
};
  
function replaceHash (path) {
    const i = window.location.href.indexOf('#')
    // 直接调用 replace 强制替换 以避免产生“多余”的历史记录
    // 主要是用户初次跳入 且hash值不是以 / 开头的时候直接替换
    // 其余时候和push没啥区别 浏览器总是记录hash记录
    window.location.replace(
        window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
    )
}

可以看出,它与push()的实现结构基本相似,不同点它不是直接对window.location.hash进行赋值,而是调用window.location.replace方法将路由进行替换。

监听地址栏

上面的VueRouter.push()和VueRouter.replace()是可以在vue组件的逻辑代码中直接调用的,除此之外在浏览器中,用户还可以直接在浏览器地址栏中输入改变路由,因此还需要监听浏览器地址栏中路由的变化 ,并具有与通过代码调用相同的响应行为,在HashHistory中这一功能通过setupListeners监听hashchange实现:

setupListeners () {
    window.addEventListener('hashchange', () => {
        if (!ensureSlash()) {
            return
        }
        this.transitionTo(getHash(), route => {
            replaceHash(route.fullPath)
        })
    })
}

该方法设置监听了浏览器事件hashchange,调用的函数为replaceHash,即在浏览器地址栏中直接输入路由相当于代码调用了replace()方法。

HTML5History

History interface是浏览器历史记录栈提供的接口,通过back(),forward(),go()等方法,我们可以读取浏览器历史记录栈的信息,进行各种跳转操作。

HTML5引入了history.pushState()和history.replaceState()方法,他们分别可以添加和修改历史记录条目。这些方法通常与window.onpopstate配合使用。

window.history.pushState(stateObject,title,url)
window.history,replaceState(stateObject,title,url)
  • stateObject:当浏览器跳转到新的状态时,将触发popState事件,该事件将携带这个stateObject参数的副本
  • title:所添加记录的标题
  • url:所添加记录的url(可选的)

pushState和replaceState两种方法的共同特点:当调用他们修改浏览器历史栈后,虽然当前url改变了,但浏览器不会立即发送请求该url,这就为单页应用前端路由,更新视图但不重新请求页面提供了基础。

export function pushState (url?: string, replace?: boolean) {
    saveScrollPosition()
    // 加了 try...catch 是因为 Safari 有调用 pushState 100 次限制
    // 一旦达到就会抛出 DOM Exception 18 错误
    const history = window.history
    try {
        if (replace) {
            // replace 的话 key 还是当前的 key 没必要生成新的
            history.replaceState({ key: _key }, '', url)
        } else {
            // 重新生成 key
            _key = genKey()
            // 带入新的 key 值
            history.pushState({ key: _key }, '', url)
        }
    } catch (e) {
        // 达到限制了 则重新指定新的地址
        window.location[replace ? 'replace' : 'assign'](url)
    }
}

// 直接调用 pushState 传入 replace 为 true
export function replaceState (url?: string) {
    pushState(url, true)
}

代码结构以及更新视图的逻辑与hash模式基本类似,只不过将对window.location.hash()直接进行赋值window.location.replace()改为了调用history.pushState()和history.replaceState()方法。

在HTML5History中添加对修改浏览器地址栏URL的监听popstate是直接在构造函数中执行的:

constructor (router: Router, base: ?string) {
  
  window.addEventListener('popstate', e => {
    const current = this.current
    this.transitionTo(getLocation(this.base), route => {
      if (expectScroll) {
        handleScroll(router, route, current, true)
      }
    })
  })
}

以上就是'hash'和'history'两种模式,都是通过浏览器接口实现的。

两种模式比较

一般的需求场景中,hash模式与history模式是差不多的,根据MDN的介绍,调用history.pushState()相比于直接修改hash主要有以下优势:

  • pushState设置的新url可以是与当前url同源的任意url,而hash只可修改#后面的部分,故只可设置与当前同文档的url
  • pushState设置的新url可以与当前url一模一样,这样也会把记录添加到栈中,而hash设置的新值必须与原来不一样才会触发记录添加到栈中
  • pushState通过stateObject可以添加任意类型的数据记录中,而hash只可添加短字符串
  • pushState可额外设置title属性供后续使用

AbstractHistory

'abstract'模式,不涉及和浏览器地址的相关记录,流程跟'HashHistory'是一样的,其原理是通过数组模拟浏览器历史记录栈的功能

// 对于 go 的模拟
    go (n: number) {
        // 新的历史记录位置
        const targetIndex = this.index + n
        // 超出返回了
        if (targetIndex < 0 || targetIndex >= this.stack.length) {
            return
        }
        // 取得新的 route 对象
        // 因为是和浏览器无关的 这里得到的一定是已经访问过的
        const route = this.stack[targetIndex]
        // 所以这里直接调用 confirmTransition 了
        // 而不是调用 transitionTo 还要走一遍 match 逻辑
        this.confirmTransition(route, () => {
            // 更新
            this.index = targetIndex
            this.updateRoute(route)
        })
    }

history模式的问题

hash模式仅改变hash部分的内容,而hash部分是不会包含在http请求中的(hash带#):

http://oursite.com/#/user/id //如请求,只会发送http://oursite.com/

所以hash模式下遇到根据url请求页面不会有问题

而history模式则将url修改的就和正常请求后端的url一样(history不带#)

http://oursite.com/user/id

如果这种向后端发送请求的话,后端没有配置对应/user/id的get路由处理,会返回404错误。

官方推荐的解决办法是在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。同时这么做以后,服务器就不再返回 404 错误页面,因为对于所有路径都会返回 index.html 文件。为了避免这种情况,在 Vue 应用里面覆盖所有的路由情况,然后在给出一个 404 页面。或者,如果是用 Node.js 作后台,可以使用服务端的路由来匹配 URL,当没有匹配到路由的时候返回 404,从而实现 fallback。