浅谈前端路由的概念与vue-router的实现原理

1,215 阅读9分钟

1.Web路由

1.1 后端路由

      Web路由的概念简单来说就是根据不同URL渲染不同的页面。在前后端不分离的时代,路由往往指的是后端路由(服务端路由),即当服务端接收到客户端发来的 HTTP 请求,就会根据所请求的相应 URL,进行文件读取,数据库读取等操作,使用模板引擎将相应结果与模板结合后进行渲染,将渲染完毕的页面发送给客户端。

优缺点

  • 优点:seo友好,爬虫爬取到的页面就是最终的渲染页面。
  • 缺点:每次发起请求都要刷新页面,用户体验不好,服务器压力大。

1.2 前端路由

      说到前端路由,必须先提一下Ajax与SPA。Ajax技术的兴起促使了 SPA—单页面应用的出现,由于Ajax可以做到页面的局部更新,因此单页应用页面的交互和页面的跳转都是无刷新的,无刷新就意味着无需处理html文件的请求,因此用户体验很好。但相应的,由于页面数据需要通过Ajax获取,因此爬虫获取到的html只是模板而不是最终的渲染页面,因此会不利于seo。为了实现单页应用,所以就有了前端路由。

      前端路由的概念简单来讲就是,当路由发生变化,不请求服务端,而是通过js的方式修改dom(组件替换),并发送Ajax获取数据来达到页面跳转的效果。因此实现前端路由有两个关键点:

  • 如何改变url不让浏览器向服务器发送请求。
  • 如何监听到url的变化,并执行对应的操作

这里就要引出实现前端路由的两种路由模式:hash模式和history模式

2.前端路由的实现模式

2.1 hash模式

概念

      hash 就是指 url 后的 # 号以及后面的内容

特点

hash模式有以下几个特点

  • hash值的变化不会导致浏览器向服务器发送请求,不会引起页面刷新
  • hash值变化会触发hashchange事件
  • hash值改变会在浏览器的历史中留下记录,使用浏览器的后退按钮,就可以回到上一个hash值
  • hash永远不会提交到服务端,即使刷新页面也不会

由此可见hash模式的特点完全可以满足前端路由的实现需求,所以在 H5 的 history 模式出现之前,基本都是使用 hash 模式来实现前端路由。

优缺点

优点:

  • 1、兼容性好,支持低版本和IE浏览器。
  • 2、实现前端路由无需服务端的支持。

缺点:

  • URL带#,路径丑

2.2 history模式

概念

      在 HTML5 之前,浏览器就已经有了 history 对象来控制页面历史记录跳转,主要有以下方法。

history.forward():前进
history.back():后退
history.go(n):加载历史列表中的某个具体的页面

      在 HTML5 的规范中,history 新增了以下几个 API:pushState(追加) 和 replaceState(替换),通过这两个 API 可以改变 url 地址且不会发送请求,同时还新增popstate 事件。通过这些API就能用另一种方式来实现前端路由,其实现原理跟与hash模式 实现类似,只是用了 HTML5 的实现,单页面应用的 url 不会多出一个#,会更加美观。

关于History模式有两点需要说明:

  • history模式如何监听路由变化 history模式下,浏览器的前进后退(history.back(), history.forward()等)会触发popstate 事件,但pushState,replaceState 并不会触发popstate事件。因此要实现路由变化的侦听,我们需要重写这两个方法,可以通过事件中心(EventBus)添加事件通知,这里不具体展开,感兴趣的小伙伴可以参考这里
  • history模式需要后端支持 由于history模式没有 # 号,所以当用户手动刷新或直接通过url进入应用时,浏览器还是会给服务器发送请求。但服务端无法识别这个 url ,因此为了避免出现这种情况,history模式需要服务端的支持,即服务端需要把匹配不到的所有路由都重定向到根页面。
优缺点

优点:

  • 路径好看

缺点:

  • 1、兼容性差,不能兼容IE9。
  • 2、需要服务端支持。

3.实现vue-router

      介绍完前端路由的概念及其实现模式,接下来我们尝试实现vue-router插件,具体包括vue-router类,两个全局组件:router-link,router-view以及install方法。

3.1 实现router类

      我们使用Hash模式来实现,因此vue-router具体要做的核心点就是要添加hashchange和load事件的事件侦听,在回调中根据当前url从路由表中取出对应的路由组件,提供给router-view渲染。因此一个首要的问题就是:如何根据url从路由表中取出组件?

      一个基础的思路是,我们只需在侦听到url变化时,拿到当前的hash值,然后遍历路由表找到路径为当前hash值的选项的component即可。不过这样做的问题也很明显,就是无法处理嵌套路由,如果我们在路由表中配置了嵌套路由,则单靠hash值是无法匹配到子代路由的。要解决这个问题,我们可以用一个matched数组来存储从父代到子代匹配过程中的各级组件,这样各级router-view组件只需按需渲染即可。

      说到这里,又会引出另一个问题,如何能做到在url变化时router-view也能响应式的更新。这里可以利用vue响应式数据的特点,我们知道单文件组件中data中的数据都是响应式的,当数据更新时,所有用到该数据的地方都会响应式的更新。而这里router-view组件显然会用到matched数组,因此我们只需将matched变为响应式数据即可。具体来说就是使Vue.util.defineReactive这个api,它可以定义一个对象的响应属性,用法如下:

Vue.util.defineReactive(obj,key,value,fn)    
  obj: 目标对象,
  key: 目标对象属性;
  value: 属性值

      我们用它将matched定义为router实例的一个响应式属性,这样即可实现matched变化时,router-view也会响应式的渲染。这里还要注意,使用该方法要用到vue实例,如何拿到vue实例?我们可以在vue-router的install方法中拿到并保存,关于这一点后面会解释。接下来我们按照以上思路,首先实现router类。

// 用于在Install方法中保存vue实例
let Vue
class myRouter{
    constructor (options){
        this.$options = options
        // 保存当前hash值,即匹配路径
        this.current = window.location.hash.slice(1) || '/' // 给初值
        // 保存匹配过程中的各级路由信息
        Vue.util.defineReactive(this, 'matched', [])
        // match方法可以递归遍历路由表,获得匹配关系 
        this.match()
        // 添加侦听事件,事件回调中用到this,因此要绑定上下文
        window.addEventListener('hashchange', this.onHashChange.bind(this))
        window.addEventListener('load', this.onHashChange.bind(this))
    }
    onHashChange () {
        // 更新匹配路径
        this.current = window.location.hash.slice(1)
        this.matched = []
        this.match()
      }
    /**
     * @description 遍历路由表,保存匹配关系
     */
    match(routes) {
         // 默认遍历总路由表
         routes = routes || this.$options.routes;
         for (let i = 0; i < routes.length; i++) {
               const route = routes[i];
               // 严格匹配根路径
               if (route.path === "/" && this.current === "/") {
                       this.matched.push(route);
                       break;
              // 当前路由包含于url 则推入matched数组并递归遍历其子路由
             } else if (route.path !== "/" && this.current.includes(route.path)) {
                       this.matched.push(route);
                       if (route.children) {
                            this.match(route.children);
                        }
                       break;
                 }
            }
      } 
}

3.2 实现两个全局组件

vue-router有两个全局组件分别是:

  • router-link 路由跳转
  • router-view 路由占位符

我们分别来实现

router-link

      router-link用来进行路由跳转,他的实现比较简单,因为其本质其实就是a标签,因此实现router-link只需渲染一个a标签即可。但要注意的是,由于此时是运行时环境,无法进行模板编译,所以不能使用模板语法,我们可以使用render函数。

      具体实现思路是,使用render渲染一个a标签,herf属性对应router-link的to属性,标签内容就是用户写在router-view中的内容,我们可以通过插槽(this.$slots)来获取,并将其添加在实际的a标签中。

export default {
  props: {
    to: {
      type: String,
      required: true
    }
  },
  render (h) {
    return h('a', { attrs: { href: '#' + this.to } }, this.$slots.default)
  }
}

router-view

      router-view用来渲染路由组件,我们前面实现的router中已经添加了对路由匹配关系的处理,他会根据当前url将各级匹配关系存入matched数组中,router-view如何根据matched数组按需渲染呢?

      其实,对于一个嵌套路由来说,每一级路由都有一个router-view与之对应,即router-view也一定是嵌套的,因此router-view只需知道自身所处的层级,具体来说就是matched数组中的第几项即可。实现这一点我们可以给每一个router-view添加一个标记变量和一个深度计数变量,router-view判断自己的父节点有没有这个标记,有则说明自己是子代路由,则深度加一同时继续向上判断直到不存在父节点。这样最终每个router-view都会得到自己所处的层级,只需根据这个层级从matched数组获取对应的路由组件并渲染即可。下面根据以上思路来实现,注意同样不能使用模板语法,要使用render函数。

export default {
  render(h) {
    // 标记自己是父级router-view
    this.$vnode.data.routerView = true; 
    // 统计深度 
    let depth = 0;
    let parent = this.$parent;
    // 获取自己的深度
    while (parent) {
      const vnodeData = parent.$vnode && parent.$vnode.data;
         if (vnodeData && vnodeData.routerView) {
           depth++;
         }
      }
      // 不断向上查找
      parent = parent.$parent;
    }
    let component = null;
    // 获取当前层级对应的路由
    const route = this.$router.matched[depth];
    // 获取path对应的component
    if (route) {
      component = route.component;
    }
    return h(component);
  }
};

3.3 实现install方法

      vue-router是个vue插件,我们前面提到过vue插件的实现原理。它要暴露一个install方法,用全局混入(Vue.mixin)的方式混入beforeCreate生命周期,这会使得所有的组件的beforeCreate钩子都会触发该行为。我们在beforeCreate中将router实例挂载到vue原型上,便于在任何地方通过vue原型直接调用router。如何做到这一点呢?

      我们在使用vue-router时会在main.js中创建Vue根实例,引入并挂载router选项,也就是说只有Vue根实例才有router这个选项。因此我们只需在beforeCreate钩子中判断当前组件有没有router选项即可,有则说明这是vue-router根实例,将router其挂载到vue原型即可。

      前面实现router类时说过,我们要在install方法中保存vue实例,为什么可以这样做呢?vue插件之所以要暴露一个install方法,是因为我们使用vue.use()方法注册组件时会调用install方法,并将vue作为参数传入,因此可以在install方法中保存vue实例。

      此外,install方法还要注册前面实现的两个全局组件。 接下来根据以上思路具体实现:

myRouter.install = function (_Vue){
   // 保存vue实例
    Vue = _Vue
    Vue.mixin({
        beforeCreate () {
            // 确保根实例的时候才执行,因为只有根实例才有router这个选项。
            if (this.$options.router) {
              Vue.prototype.$router = this.$options.router
            }
          }
    })
  //注册组件
  Vue.component('router-link', Link)
  Vue.component('router-view', View)
}

至此,基于hash模式的丐版vue-router的已经完成。 水平有限,欢迎指正😁。

参考: juejin.cn/post/684490… juejin.cn/post/685457…