手写Vue-router核心原理

739 阅读6分钟

一、如何实现前端路由

1、什么是前端路由?

URL 变化引起 UI 更新(无需刷新页面)

所以要思考两个问题:

  1. 改变URL,却不会引起页面的刷新
  2. 如何检测URL变化

2、实现方案

hash实现

hash是URL中#后面那部分,同时修改hash值不会引起页面的刷新,也不会向服务器重新发送请求。通过hashchange事件可以监听它的变化。改变hash值有以下三种方式:

  • 浏览器前进后退改变URL
  • 通过a标签改变URL
  • 通过window.location.hash改变URL

Ps:以上三种方式均可以触发hashchang事件

history实现

history是HTML5新增的,提供了两个方法用来修改浏览器的历史记录,且都不会引起页面的刷新

  • pushState,向浏览器中新增一条历史记录,同时修改地址栏
  • replaceState,替换浏览器中当前的历史记录,同时修改地址栏

history提供了popstate监听事件,但是只有以下两种情况会触发该事件

  • 点击浏览器前进后退的按钮
  • 显示调用history的back、go、forward方法

Ps:pushState与replaceState均不会触发popstate事件

二、原生方式实现

hash实现方式

<!DOCTYPE html>
<html lang="en">
<body>
<ul>
    <ul>
        <!-- 定义路由 -->
        <li><a href="#/home">home</a></li>
        <li><a href="#/about">about</a></li>

        <!-- 渲染路由对应的 UI -->
        <div id="routeView"></div>
    </ul>
</ul>
<script>
    let routerView = document.querySelector('#routeView')
    window.addEventListener('DOMContentLoaded', ()=>{
      if(!location.hash){//如果不存在hash值,那么重定向到#/
        location.hash="/"
      }else{//如果存在hash值,那就渲染对应UI
        let hash = location.hash;
        routerView.innerHTML = hash
      }
    })
    window.addEventListener('hashchange', ()=>{
      let hash = location.hash;
      routerView.innerHTML = hash
      console.log('hashChange')
    })
</script>
</body>

</html>

代码解析:

  1. 页面第一次加如果不存在hash值,那么需要重定向到#/
  2. 页面第一次加载完毕,并不会触发hashchange事件,所以需要手动的给routeView(UI)赋值
  3. 当hashchange事件触发后(如:点击A链接、手动修改hash值等)需要做两件事儿
    • 修改location.hash的值
    • 给routeView(UI)赋值

history实现

<!DOCTYPE html>
<html lang="en">
<body>
<div>
    <ul>
        <!-- 定义路由 -->
        <li><a href="/home">home</a></li>
        <li><a href="/about">about</a></li>

        <!-- 渲染路由对应的 UI -->
        <div id="routeView"></div>
    </ul>
</div>
<script>
    let routerView = document.querySelector('#routeView')
    window.addEventListener('popstate', ()=>{
      let pathName = location.pathname;
      routerView.innerHTML = pathName
      console.log('popstate');
      
    })
    window.addEventListener('DOMContentLoaded', load, false)
    function load (e) {
      !location.pathname && (location.pathname="/") //如果不存在pathname值,那么重定向到/
      let ul = document.querySelector('ul')
      ul.addEventListener('click', function (e) {
        e.preventDefault()
        if (e.target.nodeName === 'A') {
          let src = e.target.getAttribute('href')
          history.pushState(src, null, src)     // 修改URL中的地址
          routerView.innerHTML = src            // 更新UI
        }
      }, false)
    }
  
</script>
</body>

</html>

代码解析:

  1. 页面加载完毕后,如果URL中不存在pathname,需要重定向到根路径/
  2. 给A链接绑定click事件,同时要阻止A标签的默认事件,当事件执行时,修改URL中的地址并更新UI
  3. 定义popstate事件,事件触发后,更新routeView(UI)

总结:

  1. 页面第一次加载,需要以下两件事儿:
    • 需要判断URL中hash|pathname是否有值,为空的话,需要为他们赋值(/)
    • 由于第一次加载并不会触发hashchange|popstate事件,所以需要手动更新UI
  2. 事件触发后,需要修改URL中的值(为location.hash、history.pushState赋值)并更新UI

三、Vue-router实现

这里只实现了hash模式部分,history部分也大相径庭,可以自行补充。实现Vue-router之前先看看Vue中的插件机制是什么样的,它是如何注册的?

1、Vue.use() 全局注册插件

先贴上Vue.use代码

Vue.use = function(plugin) {
    // 全局维护一个插件列表,防止多次注册相同的插件
    const installedPlugins = this._installedPlugins || (this._intalledPlugins = [])
    if (installedPlugin.indexOf(plugin) > -1) return this
    const args = toArray(argumens, 1) // 将类数组转换成数组,并从1开始截取
    args.unshift(this)                // 将vue的构造函数放置args的第一位
    if (typeof plugin.install === 'function') {
      plugin.install.applay(plugin, args)
    } else if(typeof plugin === 'function') {
      plugin.apply(null, args)
    }
  }

  /**
  *  将类数组转化成数组
  */
  function toArray (list, start) {
    start = start || 0
    let i = list.length - start
    const ret = new Array(i)
    while (i--) {
      console.log('i=', i)
      ret[i] = list[i + start]
    }
    return ret
  }

代码解析:

  1. 首先接收一个插件构造函数plugin
  2. 判断传入的插件在Vue的全局插件列表(_intalledPlugins)中是否存在,不存就加入,存在则返回
  3. 拼接参数列表args,截取use函数的参数,从下标第1位开始截取(注册插件时传入的参数),然后将Vue构造函数unshit到数组中的第一位
  4. 执行install或可执行函数,并传入args参数列表

小结

  1. Vue会全局维护一个installedPlugins插件列表,防止插件被多次注册
  2. Vue的插件必须是一个带有install方法的对象,或者可执行函数,它会被当作install方法来执行。同时install方法或者可执行函数,可接收两个参数,分别是:
    • 第一个参数用来接受Vue的构造函数
    • 第二个参数是可选的对象(注册插件时传入)

2、VueRouter.install方法

// eslint-disable-next-line no-unused-vars
let _Vue
export function install (Vue) {
  _Vue = Vue
  Vue.mixin({
    beforeCreate () {
      // 1、将router挂在根组件上
      // 2、使每个vue实例上都有一个_routerRoot指向根组件实例
      if (this.$options && this.$options.router) { // 只有根组件才有router
        this._routerRoot = this
        this._router = this.$options.router
        this._router.init(this) // 调用router实例的init方法
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else { // 子组件(这里是一级一级传入的)
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
    }
  })

  // 3、this.$router -> this._routerRoot.router  this.$route -> this._routerRoot.router._route
  Object.defineProperty(Vue.prototype, '$router', {
    get () {
      return this._routerRoot._router
    }
  })
  Object.defineProperty(Vue.prototype, '$route', {
    get () {
      return this._routerRoot._route
    }
  })

  // 4、组册router-link 与 router-view两个组建
  Vue.component('router-link', {
    props: {
      to: String
    },
    render (h) {
      var mode = this._routerRoot._router.mode
      let to = mode === 'hash' ? '#' + this.to : this.to
      return h('a', { attrs: { href: to } }, this.$slots.default)
    }
  })
  Vue.component('router-view', {
    render (h) {
      var component = this._routerRoot._route.component
      return h(component)
    }
  })
}

代码解析:

  1. 将Vue作为参数传入在插件内部使用(你总不想打包的时候将Vue打包进去吧!)
  2. 使用Vue.mixin主要做以下几件事儿:
    • 将router挂在根组件上
    • 使每个vue实例上都有一个_routerRoot指向根组件实例
    • 通过Vue.util.defineReactive()定义了响应式的_route属性,通过修改Vue实例上的_route,会自动调用Vue实例的render()方法,RouteView组件内容会更新
    • 调用router实例的init方法,并将vue实例作为参数传入
  3. 将Vue实例的$router、$route分别代理到router、router._route上 (也就是我们平时调用this.router(路由实例)this.router(路由实例)、this.route(当前路由对象))
  4. 组册router-link 与 router-view两个组件

3、VueRouter类实现

上文中,我们已经实现了VueRouter.install方法,且方法里面调用了VueRouter实例上的init方法,现在我们一起实现一个VueRouter类

constructor构造函数

构造函数主要做以下几件事儿:

  • 接收options参数,保存mode、路由列表(options.routes)在实例上
  • 将路由列表映射成key-value形式,方便后面使用
  • 根据mode类别( HashHistory | HTML5History | AbstractHistory )来实例化一个history(我这里就写一个hash模式的)

init方法实现

实现思路:

  1. 保存Vue实例app在router实例上
  2. 获取当前路径location(#后面那边分)
  3. 通过location获取当前route
  4. 保存当前路由(history.current = route)
  5. 执行history中的cb回调,并传入参数route(这里就是修改Vue实例上的_route),由于Vue实例上的_route被修改,Vue根组件上的render被执行,RouteView内容被更新
  6. 设置hashchange路由监听事件,当路由变化时,重新执行第2步--第5步

Ps:查看VueRouter源码,会发现它的push方法、replace方法也都会调用history中的transitionTo方法,实现以上的第2步--第5步,只不过前者是直接重定向,后者是直接替换

执行流程

  • 保存Vue实例
  • 调用history.transitionTo(以上的第2步--第6步)
  • 执行history.listen(cb)(Ps:cb是一个回调函数,用来修改Vue实例的_route值)
// init方法中
history.listen(route => {
    this.app._route = route
})
.....

// History类中的listen
listen (cb) {
    this.cb = cb
}

具体实现如下:

import { install } from './install.js'
import History from './history'

class VueRouter {
  constructor (options) {
    this.app = null
    this.mode = options.mode || 'hash'
    this.routes = options.routes
    this.history = new History(options.routes)
  }
  init (app) {
    this.app = app // 保存vue的实例
    var history = this.history
    // history 暂时不考虑 -- 没法测试
    // !location.pathname && (location.pathname = '/')
    // window.addEventListener('popstate', e => {
    //   let path = location.pathname
    //   this.history.transitionTo(
    //     this.history.getCurrentRoute(path),
    //     route => this.history.updateRoute(route)
    //   )
    // })
    history.transitionTo(// 这里主要做两件事儿 1)初始化的时候更新路由,待用vue实例render 2)给hash做事件监听
      history.getCurrentLocation(),
      (route) => {
        history.setupListener(route)
      }
    )
    history.listen(route => {
      this.app._route = route
    })
  }
  push (location) {
    this.history.push(location)
  }
  replace (location) {
    this.history.replace(location)
  }
}

VueRouter.install = install

export default VueRouter

4、History类实现

class History {
  constructor (routes) {
    this.current = null
    this.cb = null
    this.routerMap = this.createRouterMap(routes)
    this.ensureHash() // 判断URL中是否带有#/,没有的话就给URL重置一下
  }
  ensureHash () {
    !location.hash && (location.hash = '/')
  }
  transitionTo (location, onComplete) {
    let route = this.routerMap[location]
    this.updateRoute(route)
    onComplete && onComplete(route)
  }
  updateRoute (route) {
    this.current = route
    this.cb && this.cb(route)
  }
  getCurrentRoute (location) {
    return this.routerMap[location]
  }
  createRouterMap (routes = []) {
    return routes.reduce((module, route) => {
      module[route.path] = route
      return module
    }, {})
  }
  setupListener (route) {
    window.addEventListener('hashchange', e => {
      this.transitionTo(
        this.getCurrentLocation()
      )
    })
  }
  getCurrentLocation () {
    var href = window.location.href
    var index = href.indexOf('#')
    return index > -1 ? href.slice(index + 1) : '/'
  }
  push (location) {
    this.transitionTo(
      location,
      () => {
        // 修改window中的hash
        pushHash(location)
      }
    )
  }

  replace (location) {
    this.transitionTo(
      location,
      () => {
        replaceHash(location)
      }
    )
  }

  listen (cb) {
    this.cb = cb
  }
}

export default History

// 直接替换hash(可以理解为重定向)
function pushHash (hash) {
  location.hash = hash
}

// 替换url后面那部分的hash
function replaceHash (hash) {
  var href = window.location.href
  var index = href.indexOf('#')
  var base = index > -1 ? href.slice(0, index) : href
  window.location.replace(base + '#' + hash)
}

四、参考