手写 Vue Router、手写响应式实现、虚拟 DOM 和 Diff 算法

一、简答题

1、当我们点击按钮的时候动态给 data 增加的成员是否是响应式数据,如果不是的话,如何把新增成员设置成响应式数据,它的内部原理是什么。

let vm = new Vue({
 el: '#el'
 data: {
  o: 'object',
  dog: {}
 },
 method: {
  clickHandler () {
   // 该 name 属性是否是响应式的
   this.dog.name = 'Trump'
  }
 }
})

答:不是响应式数据。响应式对象和响应式数组是指在vue初始化时期,利用Object.defineProperty()方法对其进行监听,这样在修改数据时会及时体现在页面上。

  • 设置为响应式数据有两种方法:

    • 1、给 dog 的属性 name 设置一个初始值,可以为空字符串或者 undefined 之类的,代码和原因如下:

      let vm = new Vue({
      el: '#app',
      data: {
      msg: 'object',
      dog: {
          name: ''
      }
      },
      method: {
      clickHandler() {
          // 该 name 属性是否是响应式的
          this.dog.name = 'Trump'
      }
      }
      })
      • 原因:vm[key] setter 操作的时候会触发 data[key] 的 setter 操作,data[key] 的 setter 操作会 walk 这个新的值(walk方法是给data里的对象类型的值设置响应式),而题目中的 data 的 dog 是个空对象,没有任何属性,所以初始化 Vue 实例的时候,在给 dog 设置 proxy 的时候没有任何属性有 getter 和 setter 方法,所以在点击按钮动态的给 dog 添加 name 属性,并设置值的时候是不会触发 dog 对象下的属性 name 的 setter 方法,故不是响应式数据。而给 dog 对象添加了 name 的初始值后,dog 对象的 name 属性就有了 getter 和 setter 方法,故可以实现响应式。代码如下:

        • vue.js(监听 vm[key]的 getter 和 setter 操作 )

          class Vue {
          constructor(options) {
          // 1、通过属性保存选项的数据
          this.$options = options || {}
          this.$data = options.data || {}
          this.$el = typeof options.el === 'string' ? document.querySelector(options.el) : options.el
          // 2、把 data 中的成员转换成 getter 和 setter,并注入到 vue 实例中
          this._proxyData(this.$data)
          // 3、调用 observer 对象,监听数据的变化
          new Observer(this.$data)
          // 4、调用 compiler 对象,解析指令和插值表达式
          new Compiler(this)
          }
          _proxyData(data) {
          // 遍历 data 中的所有属性
          Object.keys(data).forEach(key => {
          // 把 data 的属性注入到 vue 实例中
          Object.defineProperty(this, key, {
          enumerable: true,
          configurable: true,
          get() {
              return data[key]
          },
          set(newValue) {
              if (newValue !== data[key]) {
                  data[key] = newValue
              }
          }
          })
          })
          }
          }
        • observe.js (data[key] 的 setter 操作会 walk 这个新的值,walk方法是给data里的对象类型的值设置响应式)

          
          class Observer {
          constructor(data) {
          this.walk(data)
          }
          walk(data) {
          // 1、判断 data 是否是对象
          if (!data || typeof data !== 'object') {
          return
          }
          // 2、遍历 data 对象的所有属性
          Object.keys(data).forEach(key => {
          this.defineReactive(data, key, data[key])
          })
          }
          defineReactive(obj, key, val) {
          let that = this
          // 负责收集依赖,并发送通知
          let dep = new Dep()
          // 如果 val 是对象,把 val 内部的属性转换成响应式数据
          that.walk(val)

      Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { // 收集依赖 Dep.target && dep.addSub(Dep.target) return val }, set(newValue) { console.log(newValue, '>>>>>', val) if (newValue === val) { return } val = newValue that.walk(newValue) // 发送通知 dep.notify() } }) } }

    • 2、使用 Vue.set(target, key, value) 时,target 为需要添加属性的对象,key 是要添加的属性名,value 为属性 key 对应的值, vue 中 set 的源码如下:

      export function set (target: Array<any> | Object, key: any, val: any): any {
      if (process.env.NODE_ENV !== 'production' &&
      (isUndef(target) || isPrimitive(target))
      ) {
      warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
      }
      if (Array.isArray(target) && isValidArrayIndex(key)) {
      target.length = Math.max(target.length, key)
      target.splice(key, 1, val)
      return val
      }
      if (key in target && !(key in Object.prototype)) {
      target[key] = val
      return val
      }
      const ob = (target: any).__ob__
      if (target._isVue || (ob && ob.vmCount)) {
      process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
      )
      return val
      }
      if (!ob) {
      target[key] = val
      return val
      }
      defineReactive(ob.value, key, val)
      ob.dep.notify()
      return val
      }
    • 上面源码的执行逻辑如下(参考链接:https://www.cnblogs.com/heavenYJJ/p/9559439.html):

      • 1、如果是在开发环境,且 target 未定义(为 null、undefined )或 target 为基础数据类型(string、boolean、number、symbol)时,抛出告警;
      • 2、如果 target 为数组且 key 为有效的数组 key 时,将数组的长度设置为 target.length 和 key 中的最大的那一个,然后调用数组的 splice 方法( vue 中重写的 splice 方法)添加元素;
      • 3、如果属性 key 存在于 target 对象中且 key 不是 Object.prototype 上的属性时,表明这是在修改 target 对象属性 key 的值(不管 target 对象是否是响应式的,只要 key 存在于 target 对象中,就执行这一步逻辑),此时就直接将 value 直接赋值给 target[key];
      • 4、判断 target,当 target 为 vue 实例或根数据 data 对象时,在开发环境下抛错;
      • 5、当一个数据为响应式时,vue 会给该数据添加一个 ob 属性,因此可以通过判断target对象是否存在 ob 属性来判断 target 是否是响应式数据,当 target 是非响应式数据时,我们就按照普通对象添加属性的方式来处理;当 target 对象是响应式数据时,我们将 target 的属性 key 也设置为响应式并手动触发通知其属性值的更新;
    • defineReactive(ob.value, key, val) ,将新增属性设置为响应式; ob.dep.notify() 手动触发通知该属性值的更新, 所以我们可以修改代码如下:

      let vm = new Vue({
      el: '#app',
      data: {
          msg: 'object',
          dog: {
              name: undefined
          }
      },
      method: {
          clickHandler() {
              // 该 name 属性是否是响应式的
              this.$set(this.data.dog, name, 'Trump')
          }
      }
      })

2、请简述 Diff 算法的执行过程

diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。

diff算法有两个比较显著的特点:

1、比较只会在同层级进行, 不会跨层级比较。

2、在diff比较的过程中,循环从两边向中间收拢。

diff流程:

1 、首先定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引。

2、接下来是一个 while 循环,在这过程中,oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

while 循环中会遇到四种情况:

情形一:当新老 VNode 节点的 start 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的开始索引都加 1。

情形二:当新老 VNode 节点的 end  是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的结束索引都减 1。

情形三:当老 VNode 节点的 start 和新 VNode 节点的 end  是同一节点时,这说明这次数据更新后 oldStartVnode 已经跑到了 oldEndVnode 后面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1。

情形四:当老 VNode 节点的 end 和新 VNode 节点的 start 是同一节点时,这说明这次数据更新后 oldEndVnode 跑到了 oldStartVnode 的前面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1。

3、while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

情形一:如果在循环中,oldStartIdx大于oldEndIdx了,那就表示oldChildren比newChildren先循环完毕,那么newChildren里面剩余的节点都是需要新增的节点,把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中

情形二:如果在循环中,newStartIdx大于newEndIdx了,那就表示newChildren比oldChildren先循环完毕,那么oldChildren里面剩余的节点都是需要删除的节点,把[oldStartIdx, oldEndIdx]之间的所有节点都删除

二、编程题

1、模拟 VueRouter 的 hash 模式的实现,实现思路和 History 模式类似,把 URL 中的 # 后面的内容作为路由的地址,可以通过 hashchange 事件监听路由地址的变化。

import Vue from 'vue'
console.dir(Vue)
let _Vue = null
export default class VueRouter {
 // 实现 vue 的插件机制
 static install(Vue) {
   //1 判断当前插件是否被安装
   if (VueRouter.install.installed) {
     return;
   }
   VueRouter.install.installed = true
   //2 把Vue的构造函数记录在全局
   _Vue = Vue
   //3 把创建Vue的实例传入的router对象注入到Vue实例
   // _Vue.prototype.$router = this.$options.router
   // 混入
   _Vue.mixin({
     beforeCreate() {
       if (this.$options.router) {
         _Vue.prototype.$router = this.$options.router

       }
     }
   })
 }

 // 初始化属性
 constructor(options) {
   this.options = options // options 记录构造函数传入的对象
   this.routeMap = {} // routeMap 路由地址和组件的对应关系
   // observable     data 是一个响应式对象
   this.data = _Vue.observable({
     current: "/" // 当前路由地址
   })
   this.init()

 }

 // 调用 createRouteMap, initComponent, initEvent 三个方法
 init() {
   this.createRouteMap()
   this.initComponent(_Vue)
   this.initEvent()
 }

 // 用来初始化 routeMap 属性,路由规则转换为键值对
 createRouteMap() {
   //遍历所有的路由规则 把路由规则解析成键值对的形式存储到routeMap中
   this.options.routes.forEach(route => {
     this.routeMap[route.path] = route.component
   });
 }

 // 用来创建 router-link 和 router-view 组件
 initComponent(Vue) {
   // router-link 组件
   Vue.component('router-link', {
     props: {
       to: String
     },
     // render --- 可在 vue 运行时版直接使用
     render(h) {
       // h(选择器(标签的名字), 属性,生成的某个标签的内容)
       return h('a', {
         attrs: {
           href: '#' + this.to,
         },
         // 注册事件
       //   on: {
       //     click: this.clickHandler // 点击事件
       //   },
       }, [this.$slots.default]) // this.$slot.default 默认插槽
     },
   });
   // router-view 组件
   const self = this; //这里的 this 指向 vueRouter 实例
   Vue.component('router-view', {
     render(h) {
       // 根据 routerMap 中的对应关系,拿到当前路由地址所对应的组件
       const component = self.routeMap[self.data.current]
       return h(component)
     }
   })

 }

 // 用来注册 hashchange 事件
 initEvent () {
    window.addEventListener('hashchange', () => {
     this.data.current = this.getHash();
   });
   window.addEventListener('load', () => {
     if (!window.location.hash) {
       window.location.hash = '#/';
     }
   });
 }

 getHash() {
   return window.location.hash.slice(1) || '/';
 }

}

2、在模拟 Vue.js 响应式源码的基础上实现 v-html 指令,以及 v-on 指令。

   // 不同指令所调用的函数
   update (node, key, attrName) {
       let updateFn = this[attrName + 'Updater'];

       // 处理 v-on 指令
       if (attrName.startsWith('on:')) {
           updateFn = this['on' + 'Updater']
           const eventType = attrName.split(':')[1]
           updateFn.call(this, node, this.vm[key], key, eventType)
           return
       }

       updateFn && updateFn.call(this, node, this.vm[key], key) // 此处的 this 就是 compiler 对象
   }

   // 处理 v-html 指令
   htmlUpdater (node, value, key) {
       node.innerHTML = value

       new Watcher(this.vm, key, (newValue) => {
           node.innerHTML = newValue
       })
   }

   // 处理 v-on 指令
   onUpdater (node, value, key, eventType) {
       // console.log(value, key, eventType, this.vm.$options.methods[key])
       node.addEventListener(eventType, this.vm.$options.methods[key])

   }

3、参考 Snabbdom 提供的电影列表的示例,利用Snabbdom 实现类似的效果,如图:

图片描述