你不知道的Vue变化侦测

双向数据绑定

从页面到数据都是通过事件监听的方式,即v-on事件监听。

  • 对于表单,就是v-bind:value+v-on:input
  • 对于单复选框,就是v-bind:value+v-on:change
  • 对于select,将value作为props传给options,用change监听

从数据到页面就是数据绑定,主要是响应式数据绑定,v-on事件监听只是去改变数据,而数据如何响应还是依赖的响应式数据。

整体

MVVM 数据双向绑定主要是指:数据变化更新视图,视图变化更新数据,如下图所示:
在这里插入图片描述

目前前端框架基本上都采用了MVVM模式实现双向数据绑定,Vue也不例外。但是各个框架实现双向绑定的方法各有不同,目前大概有三种实现方式:

  • Angular 脏检查机制
  • 发布订阅
  • 数据劫持

而Vue采用的是发布订阅和数据劫持结合的方式实现双向绑定,通过Object.defineProperty()来劫持各个属性的settergetter,在数据变动时发布消息给订阅者,触发相应的监听回调。
实现双向数据绑定的流程为:

  1. 通过Object.defineProperty对数据进行劫持,并结合观察者模式实现
  2. 创建一个Observer来劫持监听data中的所有属性,并把所有属性包括子属性都添加了getter/setter
      -get方法会将读取该属性的watcher(编译时根据模板和表达式创建的)添加到dep对象的依赖数组中
      -当值改变时, 会触发set方法, set方法中会调用dep对象的通知watcher的方法通知每个watcher对象调用更新方法进行更新

    在初始化MVue实例时,对data中每个属性劫持监听,同时进行模板编译,指令解析,最后挂载到相应的DOM中。

  3. Vue在组件渲染过程中(即模板编译)遇见使用data中数据的表达式, 会为其建立一个watcher, 并把所对应的Watcher通过getter收集到Dep中,作为依赖。
  4. 当数据发生变化时,通过触发属性的setter调用Dep通知所对应的Watcher
  5. Watcher调用更新的回调函数来更新节点
    在这里插入图片描述

数据绑定

一旦更新了data中的某个属性数据,所有界面上直接或间接使用此属性的节点都会更新。

数据劫持

所谓数据劫持,指的是在访问或者修改data中的某个属性数据时,通过代码拦截进行额外的操作或者修改返回结果。数据劫持是vue中用来实现数据绑定的一种技术,

基本思想:

通过defineProperty()来监视data中所有属性(任意层次)数据的变化,一旦变化就去更新界面

四个重要对象:
Observer 观察者
  • 通过隐式递归调用实现所有层次属性的监视/劫持
  • 给data中所有属性重新定义属性描述, 添加 setter / getter
  • 为data中的每个属性创建对应的dep对象
Dep(Deppend)订阅器
  • data中的每个属性(所有层次)都对应一个dep对象
  • 创建的时机:
    • 初始化data中各个属性时创建对应的dep对象
    • 在data中的某个属性值被设置为新的对象时
  • 对象的结构
Dep{
  id, //每个dep都有一个唯一的id
  subs //包含n个对应Watcher的数组(subscribe的简写)
}
  • sub属性说明:
    • 当一个Watcher被创建时,内部会将当前Watcher对象添加到对应dep对象的subs中
    • 当此data属性的值发生改变时,所有subs中的Watcher都会收到更新的通知,调用update 从而最终更新对应的界面
Compile解析器
  • 用来解析模板页面的对象(一个实例)
  • 利用compile解析模板页面
  • 插值语法和一般指令语法都会调用bind ,bind 中会创建 watcher
  • 每解析一个表达式(非事件指令)都会创建一个对应的Watcer对象,并建立Watcher与Dep的关系
  • compile与watcher关系:一对多关系
Watcher订阅者

初始化编译模板时遇见读取data中的属性时建立

  • 模板中每个非时间指令或表达式都对应一个Watcher对象

  • 监视当前表达式数据变化

  • 对象的组成:

Watcher{
  this.vm=vm, //vm对象
  this.exp=exp, //对应的指令表达式
  this.cb=cb, //当表达式所对应的数据发生改变的回调函数,即用于更新页面的回调
  this.value=this.get(), //表达式当前的值
  this.depIds={depId,dep} //表达式中各级属性所对应的dep对象的集合对象,即相关的n个dep对象
                         //属性名为dep的id,属性值为dep
}
总结:

dep与watcher的关系:多对多

  • dep通过data中属性的get()建立或初始化的解析模板中的表达式创建watcher对象时

  • 一个data中的属性对应一个dep,一个dep可能包含多个watcher(模板中n个表达式使用相同属性 {{name}} v-text=“name”)

Dep ==> n个watcher

  • 模板中一个非事件表达式(大括号表达式)对应一个watcher,一个watcher中可能包含多个dep(表达式中包含n个data属性 a.b.c)

watcher ==> n个dep

  • 数据绑定使用到2个核心技术

  • defineProperty()

  • 消息订阅与发布

Object.defineProperty的不足

虽然我们通过Object.defineProperty方法实现了对object数据的可观测,但是这个方法仅仅只能观测到object数据的取值及设置值,当我们向object数据里添加一对新的key/value或删除一对已有的key/value时,它是无法观测到的,导致当我们对object数据添加或删除值时,无法通知依赖,无法驱动视图进行响应式更新,需要手动进行 Observe,需要重新遍历对象,对其新增属性再使用 Object.defineProperty 进行劫持。

所以,在使用 Vue 给 data 中的数组或对象新增 / 删除属性时,需要使用 vm.$ set / vm.$delete才能保证新增 / 删除的属性也是响应式的。

Array型数据劫持

由于Object.defineproperty的不足,对于数组的添加和删除无法监测到,需要对Array型数据设计一套另外的变化侦测机制

Array型数据还是在getter中收集依赖。只是在Vue中创建了一个数组方法拦截器,它拦截在数组实例与Array.prototype之间,在拦截器内重写了操作数组的一些方法,并将这些方法赋值给了数据的 __ proto __ 上,因为原型链的机制,找到对应的方法就不会继续往上找原生的方法了,当数组实例使用操作数组方法时,其实使用的是拦截器中重写的方法,而不再使用Array.prototype上的原生方法。拦截器生效以后,当数组数据再发生变化时,我们就可以在拦截器中调用 dep 通知所对应的 watcher。编译方法中会对一些会增加索引的方法(push,unshift,splice)进行手动 observer。在 Array 中,用拦截器代替了setter

关于watcher和更新侦测粒度

  • Vue的更新是由于监视属性变化自动通知所有的Watcher,Watcher再调用自己的update进行更新

  • 现在的watcher是组件级别的, 一个组件实例只有一个watcher, 而不是一个节点对应一个watcher, 为了避免创建的watcher过多内存占用过多。

    Vue1.0的Watcher是节点级别的,这就导致粒度过细,每一个绑定都会有一个对应Watcher来观察状态的变化,这样就会使得开销过大。Vue2.0的Watcher是组件级别的,也就是说即便一个组件内有10个节点使用了某种状态,但其实也只有一个Watcher来观察这个状态的变化。当这个状态发生变化时,只是通知到组件,然后组件内部通过虚拟DOM去进行比对与渲染。

  • 更新时只是调用当前组件的update更新,只diff当前组件, 对于子组件,会更新其props和listener, 并不会深入去操作子组件更新, 由于监视的作用, 子组件会自动触发更新。

变化检测相关的API原理

所有以vm.$开头的方法都是在Vue.js的原型上设置的。

vm.$watch(P31)

基本

用于监测传入的data, 变化后会调用传入的回调函数,** **回调函数调用时,会从参数得到新值和旧值。表达式只接受以点分隔的路径。对于更复杂的表达式,用一个函数取代。

  • vm.$watch(expOrFn, callback, {deep, immediate})
    • {string | Function} expOrFn,表示被监视的数据,可以是表达式/函数
    • {Function | Object} callback,监视的数据变化后执行的,参数为(newval, oldval) => {}
    • {Object} [options]
      • {boolean} deep,为了发现对象内部值的变化,可以指定deep:true
      • {boolean} immediate,立即以表达式当前值触发回调,可以指定immediate:true
  • 返回值:{Function} unwatch,取消观察函数

注意: 在变更 (不是替换) 对象或数组时,旧值将与新值相同,因为它们的引用指向同一个对象/数组。Vue 不会保留变更之前值的副本。

原理

vm.$watch其实是对Watcher的一种封装,但vm. $watch中的参数deep和 immediate 是 watcher中所没有的。

具体实现如下:

  • 首先new watcher(vm,exporFn,cb,options)实现基本的监视功能, 其中expOrFn可以为表达式或者函数, 当为函数时, 函数里涉及到的属性都会被监视, 即watcher对应了多个dep, 任意属性值改变都会触发回调函数
  • 接着判断是否存在immediate参数,如果且值为true,会立即执行cb
  • 使用depIds来判断如果当前watcher已经订阅了该Dep,则不会重复订阅
  • 判断是否存在deep, 如果值为true的话, 会对传入的值进行递归的监视
    强调的一点是,一定要在window.target = undefined之前去触发子值的收集依赖逻辑 如果在window.target = undefined之后去触发收集依赖的逻辑,那么其实当前的watcher并不会被收集到子值的依赖列表中,也就无法实现deep的功能。
    traverse(value)
    • 判断是否是array, object以及是否冻结, 如果满足则直接返回, 并不会继续递归监视
    • 将对象对应的dep的dep.id添加到一个Set中避免重复添加
    • 递归对象或数组的每一个属性, 触发了getter 就将该watcher添加到对象的dep中
  • 返回一个用于取消监视的函数, 本质上是调用了watcher的teardown()
Vue.prototype.$watch = function (exporFn,cb,options) {
  const vm = this
  options = options || {}
  const watcher = new watcher(vm,exporFn,cb,options)
  //判断是否存在immediate参数
  if (options.immediate) {
    cb.call(vm,watcher.value)
  }
  //返回一个用于取消监视的函数
  return function unwatchFn () {
    watcher.teardown( )
  }
}

export default class watcher {
  constructor (vm,expOrFn,cb) {
    this.vm = vm
	//判断是否存在deep
    if (options) {
      this.deep = !!options.deep
    }else {
      this.deep = false
    }
	//dep与Watcher是多对多的关系,dep增加依赖知道依赖了哪些watcher,同样watcher也要知道自己都订阅过哪些Dep
    this.deps =[]
    this.depIds = new Set() 
    //判断exporFn是函数还是表达式
    /*
    	如果 exporFn是函数,则直接将它赋值给getter;如果不是函数,再使用parsePath函数来读取keypath中的数据。
    */
    if (typeof exporFn === "function " ) {
      this.getter = exporFn
    } else {
        this.getter = parsePath(expOrFn)
    }
    this.cb = cb
    this.value = this.get()
  }
  //将对象对应的dep的dep.id添加到一个Set中避免重复添加
  addDep (dep) {
    const id = dep.id
    if (!this.depIds.has(id)) {
      this.depIds.add(id)
      this.deps.push(dep)
      dep.addSub(this)
    }
  }
  //一定要在window.target = undefined之前去触发子值的收集依赖逻辑
  get () {
    window.target = this
    let value = this.getter.call(vm,vm)
    if (this.deep) {
      traverse(value)
    }
    window.target = undefined
    return value
  }
}

const seen0bjects = new Set()
export function traverse (val) {
  _traverse(val,seenObjects)
  seenobjects.clear()
}
function _traverse (val,seen) {
  let i, keys
  const isA = Array .isArray(val)
  //判断是否是array, object以及是否冻结, 如果满足则直接返回, 并不会继续递归监视
  if (( !isA && !isobject(val)) || object.isFrozen(val)) {
    return
  }
  //将对象对应的dep的dep.id添加到一个Set中避免重复添加
  if (val.__ob_) {
    const depId = val._ob_.dep.id
    if ( seen.has( depId)) {
      return
    }
    seen.add( depId)
  }
  //递归对象或数组的每一个属性, 触发了getter 就将该watcher添加到对象的dep中
  if (isA) {
    i = val.length
    while (i--) _traverse(val[i], seen)
  }else {
    keys = object.keys( val)i = keys.length
    while (i--) _traverse(val[ keys[i]], seen)
  }
}


vm.$set(P38)

基本

在 object 上设置一个属性,如果 object是响应式的,Vue.js 会保证属性被创建后也是响应式的,并且触发视图更新。

vm.$set在原型上设置的,但具体实现其实是在observer中抛出的set方法。

vm.$set( target,key,value )

  • 参数:
    {object | Array} target
    {string | number} propertyName/index
    {any} value

  • 返回值:设置的值

原理

Array:

  • 判断 key是否合法,先原数组的length变化target. length = Math.max(target.length,key)
  • 通过 splice将原数组对应的值修改。
    使用splice时数组拦截器会侦测到target 的变化,并自动把val转换成响应式的
  • 最后返回val即可
export function set (target,key , val) {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target. length = Math.max(target.length,key)
    target.splice( key, 1, val)
    return val
  }
}

Object:

  • 非响应式数据:直接修改对象的值target[key] = val
  • 响应式数据:
    • key存在target中
      由于key已经存在于target 中,说明这个key已经被侦测变化,那么直接修改key属性。修改数据的动作会被Vue.js侦测到,所以数据发生变化后,会自动向依赖发送通知。
		if (key in target && !(key in Object.prototype)) {
		  target[ key] = val
		  return val
		}	
  • 处理新增属性
    - 使用_isVue/ob.vmCount来判断target不属于Vue.js实例或Vue.js实例的跟数据对象
    什么是根数据?this.$data就是根数据
    - 使用defineReactive重新设置对象的getter和setter,同时调用该对象的dep的notify方法通知watcher更新
完整代码:
	export function set (target, key, val) {
	  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._ob_
	  //判断target不属于Vue.js实例或Vue.js实例的跟数据对象
	  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
	}

vm.$delete

删除数据中的某个属性。如果对象是响应式的,需要确保删除能触发更新视图。

基本

vm.$delete( target, propertyName/index )

  • 参数
    • {Object | Array} target
    • {string | number} propertyName/index
原理

Array:只需使用splice将参数key所指定的索引位置的元素删除即可

Object:

  • 与vm.$set一样,vm. $delete也不可以在Vue.js实例或Vue.js实例的根数据对象上使用
  • 如果删除的这个key不是target自身的属性,就什么都不做,直接退出程序执行
  • 如果删除的这个key在target 中根本不存在,那么其实并不需要进行删除操作,也不需要向依赖发送通知
  • 判断target是否为响应式数据,只有响应式数据才需要发送通知,非响应式数据只需要执行删除操作即可
export function del (target,key) {
  if (Array.isArray(target) && isValidArrayIndex ( key)) {
    target.splice(key,1)
    return
  }
  const ob = target._ob_//新增
  if (target._isVue || (ob && ob. vmCount)) {
    process.env. NODE_ENV !== ' production' && warn(
    'Avoid deleting properties on a Vue instance or its root $data ' +
    ' just set it to nul1."
    return
  }
  //如果key不是target自身的属性,则终止程序继续执行
  if(!hasOwn(target,key)) {
    return
  }
  //如果ob不存在,则直接终止程序
  if(!ob) {
    return
  }
  delete target[key]
  ob.dep.notify()
}

如果大家有什么意见或建议希望大家在评论区多多交流,谢谢😊
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值