美文网首页零基础转行前端前端开发那些事儿让前端飞
浅谈前端路由的概念与vue-router的实现原理

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

作者: 竹叶寨少主 | 来源:发表于2021-05-26 18:57 被阅读0次

    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的已经完成。
    水平有限,欢迎指正😁。
    参考:
    https://juejin.cn/post/6844903695365177352#heading-15
    https://juejin.cn/post/6854573222231605256#heading-14

    相关文章

      网友评论

        本文标题:浅谈前端路由的概念与vue-router的实现原理

        本文链接:https://www.haomeiwen.com/subject/cnwksltx.html