Vue.js的变化侦测(一)

283 阅读6分钟

1、Object变化侦测

vue的变化侦测我们可以这么理解:每个数据绑定着多个依赖,而每个依赖都对应着一个具体的dom,当数据发生变化时,它的依赖会做出反应,同时让dom做出更新。

1.1、如何追踪变化

首先,我们需要了解js对象的defineProperty(data,key,obj)方法,这个方法用于设置对象里某个属性的属性值,data为对象名称,key为属性名,obj为设置该属性的方法,而Object追踪变化用到的则是defineProperty里的访问器属性(get,set),我们可以把Object追踪变化用如下函数来进行表示:

function defineObject(data,key,val) {
  Object.defineProperty(data,key,{
    enumerable:true,  // 该属性可枚举(for..in能循环到)
    configurable: true,  // 可修改该属性
    get: function() {   // 获取该属性执行函数
      return val
    },
    set: function(newVal): {    // 设置该属性时执行函数
      if(val === newVal) {
        return
      }
      console.log(val,newVal)
      val = newVal
    }
  })
}


let obj = new Object()
defineObject(obj,'name','oncename')
obj.name = 'twoname'    // log==> 'oncename' 'twoname'

上面函数是对defineProperty方法进行了封装,我们在定义数据时只需要传入所在对象,属性名和属性值就行了,当我们读取数据时,get函数会被触发,设置值时,set函数会被触发。

1.2、如何收集依赖

在vue中,一个数据可以对应多个依赖,我们要做的是当数据发生变化时,各个依赖都能得到及时的响应,在这里我们可以以数组的形式把各个依赖整合起来,在依赖获取数据的时候,将这个依赖保存在数组里面。简而言之,就是通过get获取到与该数据绑定的依赖并以数组的形式保存,通过set在数据变化时相应到该数组中的每一项。

// 封装依赖
// 假设依赖为保存在window上的defineValue方法
class dependArr {
  constructor() {
    this.deparr = []
  }
  addArr(val) {
    this.deparr.push(val)
  }
  removeArr(val) {
    remove(this.deparr,val)
  }
  depend() {
    if(window.defineValue) {  // 判断是否有defineValue,防止随意添加
      this.addArr(window.defineValue)
    }
  }
  notify() {
    const arr = this.deparr.slice()  // 复制deparr数组
    for(let i = 0;i<arr.length;i++) {
      arr[i].update()  // 调用更新方法
    }
  }

}

function defineObject(data,key,val) {
  let arr = new dependArr()
  Object.defineProperty(data,key,{
    enumerable:true,  
    configurable: true,  
    get: function() {
      arr.depend() // 访问该属性时将依赖添加到数组
      return val
    },
    set: function(newVal): { 
      if(val === newVal) {
        return
      }
      val = newVal
      arr.notify()  // 数据更新后执行依赖更新方法
    }
  })
}

1.3、依赖怎么定义

也许大家会比较好奇依赖是什么,是怎么来的,简单来说,依赖相当于一个中介,当数据发生变化时,需要通知依赖去处理,再由它去通知其他地方,我们称它为watch(监听器),我们在数据每次获取和修改时,只需要收集watch就可以了

在vue中watch的用法:

vm.$watch('attribval', function(newVal,oldVal) {
    // 当属性attribval发生变化时执行函数
})

我们设定一个类watcher,用来定义dep数组里面的每个依赖

首先,我们要知道watcher的作用是用来接收数据变化后同时去改变其他内容的一个角色,即使是对象里面属性的子属性,我们也要能够做到监听,所以首先我们要获取到属性的值

const bailRE = /[^\w.$]/
function parsePath(path) {
  if(bailRE.test(path)) {  // 若path字符串以.结尾
    return 
  }
  const segments = path.split('.')
  return function (obj) {  // obj为后面执行时传入的vm(data)对象
    for (let i = 0;i<segments.length;i++) {
      if(!obj) return    // 若obj不存在,该函数没有返回值
      obj = obj[segments[i]]   //逐层查找
    }
    return obj   // 返回内层的值
  }
}

// 例如
const data = {
  a: {
    b: {
      c: 1
    }
  },
  name: 'test'
}
const fun = parsePath('a.b.c')
fun(data)  // 返回1,在vue里面,我们传入fun的参数是vm

上面的函数给watcher获取到了数据的值,下面我们可以开始编辑watcher主体了

class watcher {
  // exporfn为属性key(可以为a.b.c形式),vm为属性所在对象,cb为获取到属性值后执行的方法
  constructor (vm,exporfn,cb) {
    this.vm = vm
    // 执行getter(),获取vm.exporfn的内容
    this.getter = parsePath(exporfn)
    this.cb = cb
    this.value = this.get()
  }
  get() {
    window.defineValue = this
    // 当读取数据时会触发defineObject的get把window.defineValue保存在deparr数组中
    // 我们只需要在这之前把watcher给window.defineValue就可以实现把依赖存储到deparr
    let value = this.getter.call(this.vm,this.vm)
    window.defineValue = undefined
    // 返回value为获取到的vm.exporfn属性值
    return value
  }
  update() {
    const oldValue = this.value
    this.value = this.get()
    // 把新旧参数传入cb函数
    this.cb.call(this.vm,this.value,oldValue)
  }
}

假设对data里面的obj进行监听,我们可以直接new watcher(data,'obj',cb)

1.4、递归侦测所有key

在数据变化时需要视图更新的话只需要在dependArr类的notify执行更新方法就可以了,变化侦测做到的是收集新旧变化值,为了能给页面中所有对象进行变化侦测,我们需要递归将页面所有属性转化为getter/setter形式

// 创建类递归object所有属性
class Observer {
  constructor(value) {
    this.value = value
    // 判断value是否是数组
    if(!Array.isArray(value)) {
      this.walk(value)
    }
  }
  walk(obj) {
    // 获取所有key
    const keys = Object.keys(obj)
    for(let i = 0; i < keys.length; i++){
      // 将属性转化为getter/setter形式
      defineObject(obj,keys[i],obj[keys[i]])
    }
  }
}
// 上面类实现了把某个属性值转化为getter/setter
// 下面实现对象里面所有属性转化为getter/setter(包括子属性对象的子属性)

function defineObject(data,key,val) {
  if(typeof val === 'object') {
    new Observer(val)
  }
  let arr = new dependArr()
  Object.defineProperty(data,key,{
    enumerable:true,  
    configurable: true,  
    get: function() {
      arr.depend() // 访问该属性时将依赖添加到数组
      return val
    },
    set: function(newVal): { 
      if(val === newVal) {
        return
      }
      val = newVal
      arr.notify()  // 数据更新后执行依赖更新方法
    }
  })
}

至此我们已经实现了数据的变化侦测,vue里的数据驱动试图方法可以在dependArr类的notify定义

封装了watcher类之后,我们可以实现数据监听执行方法

// watcher使用
// 假设就在vue组件中,我们需要监听date中的objone数据
// 封装watcher方法
watch: {
  objone(newval,oldval) {
    console.log(newval,oldval)
  }
}
// 上面代码是vue组件中的watch实现,内部封装可以看成:
(function packwatch() {
  const keys = Object.keys(watch)
  for(let i = 0;i<keys.length;i++) {
    // 判断data中是否定义了这个属性
    if(data.hasOwnProperty(keys[i])) {
    // 将watch对象里面与data数据键名一样的方法传入watcher,该方法接收两个回调参数(newvalue,oldvalue)
    // 当data内数据更新时,触发类watcher中的update方法,该方法会执行一遍watch.keys[i]函数,并传入两个参数
      watch.keys[i] && new watcher(data,keys[i],watch.keys[i])
    }
  }
})()

1.5、关于Object的问题

虽然我们已经实现了object简单的变化侦测,但并不是所有状态都能监测得到,比如当数据新增属性或者删除属性我们是监测不到的,对于这两种状态,vue官方也有相应解决的api(vm.set,vm.set,vm.delete)。

2、Array的变化侦测

通常情况下我们可以通过Array原型上面的方法去改变数组,上面也说到增加和删除属性是无法触发getter和setter的。所以在Array上的变化侦测我们不能通过Object的方式去实现。

2.1、如何追踪变化

首先,我们要在数组执行改变其本生的原型方法时进行侦测,但我们不能去改变数组的原生方法,对于这种情况,vue是在实例和Array.prototype之间设置一个拦截器,它拥有Array的原型所有方法,我们把该拦截器放在实例的_proto_上,这样在数组实例调用数组的原型方法时我们就可以用拦截器去替代Array的原型方法,从而对数组进行侦测。

2.2、拦截器的实现

下面我们写出代码:

// 获取到Array的原型对象
const arrayProto = Array.prototype
// 复制arrayProto对象给拦截器
const arrayMethods = Object.create(arrayProto)
// 把所有改变数组自身的方法进行拦截
['push','pop','shift','unshift','splice','sort','reverse'].forEach(function(method) {
  // 获取到Array的原型方法
  const original = arrayProto[method]
  // 对拦截器里的该方法进行重新定义
  Object.defineProperty(arrayMethods,method,{
    value: function mutator(...args) {
      // 可执行的侦测方法
      ...
      // 返回Array的原型方法
      return original.apply(this,args)
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

上面代码实现了对拦截器方法的实现,下面我们把方法作用到Array实例上去覆盖其Array原型方法

// 覆盖原型的过程在Observer类上,在我们把Object转换成getter/setter同时对Array进行处理
// 判断浏览器是否支持_proto_
const hasProto = '_proto_' in {}
// 获取arrayMethods 对象的key集合
const arrayKeys = Object.getOwnPropertyNames(arrayMethods)

class Observer {
  constructor (value) {
    this.value = value
    if(Array.isArray(value)) {
      const augment = hasProto? protoAugment: copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
  ...
}
// 直接将拦截器赋值到遍历为数组实例的_proto_
function protoAugment(target,src,keys) {
  target._proto_ = src
}
// 若浏览器不支持_proto_,则将拦截器上的属性直接赋值给数组实例
function copyAugment(target,src,keys) {
  for(let i = 0;i<keys.length;i++) {
    const key = keys[i]
    // 将该方法赋值给该实例
    target[key] = src[key]
  }
}

为了避免全局污染,我们只能在需要用到变化侦测的数据上进行拦截器的覆盖,所以我们将其部署在Observer类上面

2.3、依赖的收集和存放

  • 收集依赖

首先,我们在把数据转换成响应式数据时需要进行判断,对数组和对象进行单独处理

function observe(value,asRootData) {
  // 数组和对象返回ob对象,其他非引用类型数据则不返回ob
  if(!isObject(value)) {
    return
  }
  let ob
  // value是否包含__ob__属性,且该属性由Observer构造,是的话返回该属性,否则再进行一次递归侦测(数组才有这属性,对象则进行递归)
  if(hasOwn(value,'__ob__')&&value.__ob__ instanceof Observer) {
    ob = value.__ob__
  } else {
    ob = new Observer(value)
  }
  return ob
} 

上面的__ob__属性是存储的Observer实例,也就是每一次递归,我们都会把递归的该属性存储到__ob__里面。

我们先封装__ob__并把该数据放在Observer实例中

function def(obj,key,val,enumerable) {
  Object.defineProperty(obj,key,{
    value: val,
    enumerable: !!enumerable,
    writable:true,
    configurable:true
  })
}
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new dependArr()
    def(value,'__ob__',this)
    // 通过def函数,value的值标记了'__ob__'属性,且该属性值包含value和dep依赖实例
    if(Array.isArray(value)) {
      const augment = hasProto? protoAugment: copyAugment
      augment(value, arrayMethods, arrayKeys)
    } else {
      this.walk(value)
    }
  }
}

在Observer中保存了__ob__之后,我们在其原型方法上也可以获取到该属性,所以在我们提供的拦截器中也可以通过该属性去处理该值

['push','pop','shift','unshift','splice','sort','reverse'].forEach(function(method) {
  const original = arrayProto[method]
  Object.defineProperty(arrayMethods,method,{
    value: function mutator(...args) {
      // 重写的数组方法可以在调用该方法时操作该值的依赖
      const result = original.apply(this,args)
      const ob = this.__ob__
      // 向存储在dep里的依赖(watcher)发送消息提示已调用方法
      ob.dep.notify()
      return result
    },
    enumerable: false,
    writable: true,
    configurable: true
  })
})

在转换数据为响应式的时候,我们就可以通过判断该数组或对象是否存在__ob__属性去进行依赖存储

function defineReactive(data,key,val) {
  let childOb = observe(val) // 获取该属性的ob
  let dep = new dependArr()
  Object.defineProperty(data,key,{
    enumerable:true,
    configurable: true,
    get: function() {
      dep.depend()
      if(childOb) {
        childOb.dep.depend() // key(数组)的父元素(data)存储key的依赖在标记属性childOb的dep里
      }
      return val
    },
    set: function(newVal) {
      if(val === newVal) {
        return
      }
      dep.notify()
      val = newVal
    }
  })
}

2.4、数组内部元素变化

我们要理清上面定义的obsrve函数、Oberver类、dependArr类,observe是用来判断是否为对象或数组,若是则通过判断其是否存在__ob__去执行Observer增加标记__ob__,不是则直接对其存储dep依赖,Observer则是对对象和数组增加标记__ob__dependArr则是以来的存储类,需要配合watcher改变window.defineValue去存储

  1. 侦测数组中元素的变化
class Observer {
  constructor(value) {
    this.value = value
    this.dep = new dependArr()
    def(value,'__ob__',this)
    if(Array.isArray(value)) {
      const augment = hasProto? protoAugment: copyAugment
      augment(value, arrayMethods, arrayKeys)
      this.observeArray(value)
    } else {
      this.walk(value)
    }
  }
  walk(obj) {
    ...
  }
  observeArray(val) {
    for(let i=0;i<val.length;i++) {
      // 执行侦测函数,对每一项再进行一次判断然后递归侦测(Observer)
      observe(val[i])
    }
  }
}
  1. 侦测新增元素的变化

在Array新增元素时,我们虽然可以拦截到数组变化但前面写到的方法是无法给数组新增元素设置变化侦测的,所以我们要在数组新增元素的拦截器上去设置新增元素的变化侦测

首先,我们要判断执行的方法,如果是push、unshift、splice这些新增方法,我们需要拿到新增的值,然后在去执行变化侦测的方法

['push','pop','shift','unshift','splice','sort','reverse'].forEach(function(method) {
  const original = arrayProto[method]
  def(arrayMethods,method,function mutator(...args) {
    const result = original.apply(this,args)
      const ob = this.__ob__
      let inserted
      switch (method) {
        case 'push':
        case 'unshift':
          inserted = args
          break
        case 'splice':
          inserted = args.slice(2)
          break
      }
      if(inserted) ob.observeArray(inserted) // 对新增元素执行Observer内的遍历变化侦测
      ob.dep.notify()
      return result
  })
})

2.5、Array存在的问题

  • 使用this.list[0] = 1是无法侦测到数据的变化的
  • 直接设置length也无法侦测到数据变化this.list.length = 0 在vue3会利用es6提供的Proxy去解决这个问题

本篇文章参考书籍<<深入浅出vue.js>>