2

为什么Vue3.0 不再使用defineProperty实现数据监听

 2 years ago
source link: https://segmentfault.com/a/1190000040731258
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

为什么Vue3.0 不再使用defineProperty实现数据监听

其实这个问题很多文章都有写,也是面试的高频题目,这里仅仅是记录下自己的理解。

ProxyObject.defineproperty的区别

  1. Object.defineProperty只能劫持对象的属性,对于嵌套的对象还需要进行深度的遍历;而Proxy是直接代理整个对象
  2. Object.defineProperty对新增的属性需要手动的Observe(使用$set);Proxy可以拦截到对象新增的属性,数组的pushshiftsplice也能拦截到
  3. Proxy具有13种拦截操作,这是defineProperty不具有的
  4. Proxy 兼容性差 IE浏览器不支持很多种Proxy的方法 目前还没有完整的polyfill方案

defineProperty写法;

function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
     get: function defineGet() {
      console.log(`get key: ${key} value: ${value}`)
      return value
    },
     set: function defineSet(newVal) {
      console.log(`set key: ${key} value: ${newVal}`)
      value = newVal
    }
  })
}
function observe(data) {
  Object.keys(data).forEach(function(key) {
    // 递归的getter setter
    defineReactive(data, key, data[key])
  })
}

Proxy的写法:

let proxyObj = new Proxy(data, {
    get(key) {
        return data[key]
    },
    set(key, value) {
        data[key] = value
    }
})

当然还有其他的属性,这里写最简单的。

这两个方法的区别让我想到了事件代理

<ul id="ul">
    <li>111</li>
    <li>222</li>
    <li>333</li>
    <li>444</li>
</ul>

如果没有使用事件代理,那么它会给ul下的每个li绑定事件,这样写有个问题就是,新增的li是没有事件的,事件没有一起添加进去。
如果是使用事件代理,那么新添加的子节点也会有事件响应,因为它是通过触发代理节点(父节点 冒泡)来触发事件的
非常类似,这里想要说明的是:defineProperty是在本身自己的对象属性上做getter/setter, 而Proxy返回的是一个代理对象,只有修改代理对象才会发生响应式,如果修改原来的对象属性,并不会产生响应式更新.

Object.defineProperty对数组的处理

查阅vue官方文档 我们能看到:

Vue 不能检测以下数组的变动:

1、当你利用索引直接设置一个数组项时,例如:vm.items[indexOfItem] = newValue
2、当你修改数组的长度时,例如:vm.items.length = newLength

对于第一点:
有一些文章直接写

Object.defineProperty有一个缺陷是无法监听到数组的变化,导致直接通过数组的下标给数组设置值,不能实时响应

这种说法是错误的,事实上Object.defineProperty是可以监听到数组下标的变化,只是在Vue的实现中,从性能/体验的性价比考虑,放弃了这个特性.
对于数组下的索引是可以用getter/setter 的,

image.png

但是vue为什么没这么做?如果监听索引值,通过pushunshift添加进来的元素的索引还没被劫持,也不是响应式的,需要手动的进行observe,通过popshift删除元素,会删除并更新索引,也能触发响应式,但是数组经常会被遍历,会触发很多次索引的getter 性能不是很好。

对于第二点:
MDN:

数组的 length 属性重定义是可能的,但是会受到一般的重定义限制。(length 属性初始为 non-configurable,non-enumerable 以及 writable。对于一个内容不变的数组,改变其 length 属性的值或者使它变为 non-writable 是可能的。但是改变其可枚举性和可配置性或者当它是 non-writable 时尝试改变它的值或是可写性,这两者都是不允许的。)然而,并不是所有的浏览器都允许 Array.length 的重定义。

image.png

image.png

所以对于数组的length,无法对它的访问器属性进行getset,所以没法进行响应式的更新.

这里注意下有两个概念:索引 和 下标
数组有下标,但是对应的下标可能没有索引值!

arr = [1,2]
arr.length = 5
arr[4] // empty 下标为4,值为empty,索引值不存在。 for..in 不会遍历出索引值不存在的元素

手动赋值length为一个更大的值,此时长度会更新,但是对应的索引不会被赋值,也就是对象的属性没有,defineProperty无法处理对未知属性的监听,举个例子:length = 5的数组,未必索引就有4,这个索引(属性)不存在,就没法setter了。

数组的索引跟对象的键表现其实是一致的.

vue对数组进行了单独处理, 对其进行劫持重写,
看一个数组劫持的demo:

const arrayProto = Array.prototype

// 以arrayProto为原型的空对象
const arrayMethods = Object.create(arrayProto)

const methodToPatch = ['push', 'splice']

methodToPatch.forEach(function (method) {
    const original = arrayProto[method]
    
    def(arrayMethods, method, function mutator(...args) {
        const result = original.apply(this, args)
        console.log('劫持hh')
        return result
    })
})

function def(obj, key, val, enumerable) {
    Object.defineProperty(obj, key, {
        value: val,
        enumerable: !!enumerable,
        configurable: true,
        writable: true
    })
}

let arr = [1,2,3]
arr.__proto__ = arrayMethods

arr.push(4)
// 输出
// 劫持hh
// 4

我们以数组为原型创建了一个空对象arrayMethods, 并在其上面定义了要劫持的数组,我们这个只是简单的打印了一句。改变arr的原型指向(给__proto__赋值),在arr操作push,splice时会走劫持的方法。 vue的数组劫持实际上是在劫持方法里面添加了响应式的逻辑.

function mutator(...args) {
    // cache original method
  const original = arrayProto[method]
  // obj key, val, enumerable
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        //eg: push(a) inserted = [a] // 为push的值添加Oberserve响应监听
        inserted = args
        break
      case 'splice':
        // eg: splice(start,deleteCount,...items)  inserted = [items] //  为新添加的值添加Oberserve响应监听
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    // notify change
    ob.dep.notify()
    return result
}
/**
 * Observe a list of Array items.
 */
observeArray (items: Array<any>) {
  for (let i = 0, l = items.length; i < l; i++) {
    observe(items[i])
  }
}

$set 手动添加响应式 原理

对于对象新增属性/数组新增元素,无法触发响应式,我们可以用vue $set进行处理

vm.$set(obj,key,value)

对于数组还能使用splice方法:

vm.items.splice(indexOfItem, 1, newValue)

但是它们本质是一样的!

set的实现核心就是:

  1. 如果是数组,会使用splice对元素进行手动observe
  2. 如果是对象
    如果是修改存在的key,直接赋值就会触发响应式更新
    如果是新增的key, 就对key进行手动observe
  3. 如果不是响应式的对象(响应式对象有__ob__ 属性) 就直接赋值

set的内部实现:

export function set (target: Array<any> | Object, key: any, val: any): any {
  // 如果 set 函数的第一个参数是 undefined 或 null 或者是原始类型值,那么在非生产环境下会打印警告信息
  // 这个api本来就是给对象与数组使用的
  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)) {
    // 类似$vm.set(vm.$data.arr, 0, 3)
    // 修改数组的长度, 避免索引>数组长度导致splcie()执行有误
    target.length = Math.max(target.length, key)
    // 利用数组的splice变异方法触发响应式, 这个前面讲过
    target.splice(key, 1, val)
    return val
  }
  // target为对象, key在target或者target.prototype上。
  // 同时必须不能在 Object.prototype 上
  // 直接修改即可, 有兴趣可以看issue: https://github.com/vuejs/vue/issues/6845
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  // 以上都不成立, 即开始给target创建一个全新的属性
  // 获取Observer实例
  const ob = (target: any).__ob__
  // Vue 实例对象拥有 _isVue 属性, 即不允许给Vue 实例对象添加属性
  // 也不允许Vue.set/$set 函数为根数据对象(vm.$data)添加属性
  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
  }
  // target本身就不是响应式数据, 直接赋值
  if (!ob) {
    target[key] = val
    return val
  }
  // ---->进行响应式处理
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

参考:
https://www.zhihu.com/questio...
https://www.javascriptc.com/3...
https://juejin.cn/post/684490...


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK