前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >浅谈 JavaScript 数据双向绑定[Proxy/defineProperty]

浅谈 JavaScript 数据双向绑定[Proxy/defineProperty]

作者头像
老猫-Leo
发布2023-12-11 20:46:53
2350
发布2023-12-11 20:46:53
举报
文章被收录于专栏:前端大全前端大全

从 JavaScript 的数据双向绑定(defineProperty、Proxy)开始,谈谈 Vue2 中的数组监听问题。

导读

  Vue3 中,响应式数据部分弃用了 Object.defineProperty,使用 Proxy 来代替它。本文将介绍这两种数据监听的方式区别,并通过以下方面来分析为什么 Vue3 选择弃用Object.defineProperty

  • Object.definePropertyProxy 基础使用。
  • Object.defineProperty 能否监测数组下标的变化。
  • 分析 Vue2 中对数组 Observe 部分源码。
  • 对比 Object.definePropertyProxy

基础使用

代码语言:javascript
复制
let person = { name: 'hxb', age: 21 };
Object.defineProperty(person, 'newName', {
  get: function () {
    console.log('get...');
    return this.name;
  },
  set: function (newValue) {
    this.name = newValue;
    console.log('set...', newValue);
  }
});
console.log(person.newName); // get...,hxb
person.newName = 'oqm'; // set...,oqm
console.log(person.newName); // get...,oqm
console.log(person.name); // oqm
// 或者
function defineReactive(obj, key) {
  var value = obj[key] || '没有初始值'; // 采用闭包保存数据,且保持独立。
  Object.defineProperty(obj, key, {
    get: function () {
      console.log('get...');
      return value;
    },
    set: function (newValue) {
      value = newValue;
      console.log('set...', newValue);
    }
  });
}
defineReactive(person, 'name');
console.log(person.name); // get...,'hxb'
person.name = 'oqm'; // set... 'oqm'
console.log(person.name); //  get...,'oqm'

/* ------------------------------ 分割线 ------------------------------ */

let obj = { name: 'hxb', age: 21 };
let proxyObj = new Proxy(obj, {
  get: function (target, key) {
    console.log('get...', target, key);
    return key in target ? target[key] : 'not found';
  },
  set: function (target, key, value) {
    console.log('set...', target, key, value);
    target[key] = value;
    return true;
  }
});
console.log(proxyObj.a); // get... {name: 'hxb', age: 21} a,not found
console.log(proxyObj.name); // get... {name: 'hxb', age: 21} name,hxb
proxyObj.name = 'oqm'; // set... {name: 'hxb', age: 21} name,oqm
console.log(proxyObj); // Proxy {name: 'oqm', age: 21}
console.log(obj); // {name: 'oqm', age: 21}
obj.name = 'test'; // 不会触发 set
console.log(proxyObj); // Proxy {name: 'test', age: 21}
console.log(obj); // {name: 'test', age: 21}

能否监测数组下标的变化

测试内容与代码

  在一些技术博客上看到过这样一种说法,认为 Object.defineProperty 有一个缺陷是无法监听数组变化。

无法监控到数组下标的变化,导致直接通过数组的下标给数组设置值,不能实时响应。所以 Vue 才设置了7个变异数组(pushpopshiftunshiftsplicesortreverse)的 hack 方法来解决问题。

Object.defineProperty 的第一个缺陷,无法监听数组变化。然而 Vue 的文档提到了 Vue 是可以检测到数组变化的,但是只有八种方法可以检测,vm.items[indexOfItem] = newValue 这种是无法检测的。

  这种说法是有问题的,事实上,Object.defineProperty 本身是可以监控到数组下标的变化的,只是在 Vue 的实现中,从性能/体验的性价比考虑,放弃了这个特性。

  • 下面我们通过一个例子来为 Object.defineProperty 正名。
代码语言:javascript
复制
function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    enumerable: true,
    configurable: true,
    get: function () {
      console.log(`get key: ${key} value: ${value}`);
      return value;
    },
    set: function (newValue) {
      console.log(`set key: ${key} value: ${newValue}`);
      value = newValue;
    }
  });
}

function observe(data) {
  Object.keys(data).forEach(function (key) {
    defineReactive(data, key, data[key]);
  });
}

let testArr = [1, 2, 3];
observe(testArr);

上面代码对数组 testArr 的每个属性通过 Object.defineProperty 进行劫持,下面我们对数组 testArr 进行操作,看看哪些行为会触发数组的 gettersetter 方法。

通过下标获取某个元素和修改某个元素的值

代码语言:javascript
复制
testArr[0]; // get key: 0 value: 1
testArr[0] = 100; // set key: 0 value: 100

可以看到,通过下标获取某个元素会触发 getter 方法,设置某个值会触发 setter 方法。

数组的 push 方法

代码语言:javascript
复制
testArr.push(4); // 4
testArr; // [(...), (...), (...), 4]

push 并未触发 settergetter 方法,数组的下标可以看做是对象中的 key ,这里 push 之后相当于增加了下索引为 3 的元素,但是并未对新的下标进行 observe ,所以不会触发。

数组的 unshift 方法

我擦,发生了什么?

unshift 操作会导致原来索引为 0,1,2,3 的值发生变化,这就需要将原来索引为 0,1,2,3 的值取出来,然后重新赋值,所以取值的过程触发了 getter ,赋值时触发了 setter

下面我们看一下原来的值

只有索引为 0,1,2 的属性才会触发 getter

这里我们可以对比对象来看,testArr 数组初始值为 [100, 2, 3, 4],即只对索引为 0,1,2 执行了 observe 方法,所以无论后来数组的长度发生怎样的变化,依然只有索引为 0,1,2 的元素发生变化才会触发,其他的新增索引,就相当于对象中新增的属性,需要再手动 observe 才可以。

数组的 pop 方法

当移除的元素为引用为 2 的元素时,会触发 getter

删除了索引为 2的元素后,再去修改或获取它的值时,不会再触发 settergetter

这和对象的处理是同样的,数组的索引被删除后,就相当于对象的属性被删除一样,不会再去触发 observe

到这里,我们可以简单的总结一下结论。

Object.defineProperty 在数组中的表现和在对象中的表现是一致的,数组的索引就可以看做是对象中的 key

  • 通过索引访问或设置对应元素的值时,可以触发 gettersetter 方法。
  • 通过 pushunshift 会增加索引,对于新增加的属性,需要再手动初始化才能被 observe
  • 通过 popshift 删除元素,会删除并更新索引,也会触发 settergetter 方法。

所以,Object.defineProperty 是有监控数组下标变化的能力的,只是 Vue2 因为性能问题放弃了这个特性。

代码语言:javascript
复制
性能问题:
Object.defineProperty 采用数据劫持的方式,中必须传入对应的 key 值,才能进行拦截数据,但是数组对象动态变化,则无法监听,必须每变化一次就再 observe 一次。
而 Vue3 中使用 Proxy 直接代理对象,传入 data 即可监听里面数据的变化,所以可以监听数组对象的动态变化。

Vue 对数组的 observe 做了哪些处理

  • Vue 的 Observer 类定义在 core/observer/index.js 中。
  • 可以看到,Vue 的 Observer 对数组做了单独的处理。
  • hasProto 是判断数组的实例是否有 __proto__ 属性,如果有 __proto__ 属性就会执行 protoAugment 方法,将 arrayMethods 重写到原型上。
  • arrayMethods 是对数组的方法进行重写,定义在 core/observer/array.js 中,下面是这部分源码的分析。
代码语言:javascript
复制
import { def } from '../util/index';

// 复制数组构造函数的原型,Array.prototype也是一个数组。
const arrayProto = Array.prototype;
// 创建对象,对象的__proto__指向arrayProto,所以arrayMethods的__proto__包含数组的所有方法。
export const arrayMethods = Object.create(arrayProto);

// 下面的数组是要进行重写的方法
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];

/**
 * Intercept mutating methods and emit events
 */
// 遍历methodsToPatch数组,对其中的方法进行重写。
methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method];
  // def 方法定义在 lang.js 文件中,是通过 Object.defineProperty 对属性进行重新定义。
  // 即在 arrayMethods 中找到我们要重写的方法,对其进行重新定义。
  def(arrayMethods, method, function mutator(...args) {
    const result = original.apply(this, args);
    const ob = this.__ob__;
    let inserted;
    switch (method) {
      // 上面已经分析过,对于push,unshift会新增索引,所以需要手动observe
      case 'push':
      case 'unshift':
        inserted = args;
        break;
      // splice方法,如果传入了第三个参数,也会有新增索引,所以也需要手动observe
      case 'splice':
        inserted = args.slice(2);
        break;
    }
    // push,unshift,splice 三个方法触发后,在这里手动 observe,其他方法的变更会在当前的索引上进行更新,所以不需要再执行 ob.observeArray。
    if (inserted) ob.observeArray(inserted);
    // notify change
    ob.dep.notify();
    return result;
  });
});

Object.defineProperty Vs Proxy

上面已经知道 Object.defineProperty 对数组和对象的表现是一致的,那么它和 Proxy 对比存在哪些优缺点呢?

Object.defineProperty 只能劫持对象的属性,而Proxy是直接代理对象。

  • 由于 Object.defineProperty 只能对属性进行劫持,需要遍历对象的每个属性。而 Proxy 可以直接代理对象。

Object.defineProperty 对新增属性需要手动进行 Observe。

  由于 Object.defineProperty 劫持的是对象的属性,所以新增属性时,需要重新遍历对象,对其新增属性再使用 Object.defineProperty 进行劫持。

  也正是因为这个原因,使用 Vue 给 data 中的数组或对象新增属性时,需要使用 vm.$set 才能保证新增的属性也是响应式的。

  • 下面看一下 Vue 的 set 方法是如何实现的,set 方法定义在 core/observer/index.js ,下面是核心代码。
代码语言:javascript
复制
/**
 * Set a property on an object. Adds the new property and
 * triggers change notification if the property doesn't
 * already exist.
 */
export function set(target: Array<any> | Object, key: any, val: any): any {
  // 如果 target 是数组,且 key 是有效的数组索引,会调用数组的 splice 方法。
  // 我们上面说过,数组的 splice 方法会被重写,重写的方法中会手动 Observe。
  // 所以 Vue 的 set 方法,对于数组,就是直接调用重写 splice 方法。
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key);
    target.splice(key, 1, val);
    return val;
  }
  // 对于对象,如果 key 本来就是对象中的属性,直接修改值就可以触发更新。
  if (key in target && !(key in Object.prototype)) {
    target[key] = val;
    return val;
  }
  // Vue 的响应式对象中都会添加了 __ob__ 属性,所以可以根据是否有 __ob__ 属性判断是否为响应式对象。
  const ob = (target: any).__ob__;
  // 如果不是响应式对象,直接赋值。
  if (!ob) {
    target[key] = val;
    return val;
  }
  // 调用 defineReactive 给数据添加了 getter 和 setter,
  // 所以 Vue 的 set 方法,对于响应式的对象,就会调用 defineReactive 重新定义响应式对象,defineReactive 函数。
  defineReactive(ob.value, key, val);
  ob.dep.notify();
  return val;
}

  在 set 方法中,对 target 是数组和对象做了分别的处理,target 是数组时,会调用重写过的 splice 方法进行手动 Observe

  对于对象,如果 key 本来就是对象的属性,则直接修改值触发更新,否则调用 defineReactive 方法重新定义响应式对象。

  如果采用 Proxy 实现,Proxy 通过 set(target, propKey, value, receiver) 拦截对象属性的设置,是可以拦截到对象的新增属性的。

  不止如此,Proxy 对数组的方法也可以监测到,不需要像上面 Vue2 源码中那样进行 hack

代码语言:javascript
复制
let obj = { prop1: 1 };
let proxyObj = new Proxy(obj, {
  get: function (target, key) {
    console.log('get...', target, key);
    return key in target ? target[key] : 'not found prop';
  },
  set: function (target, key, value) {
    console.log('set...', target, key, value);
    target[key] = value;
  }
});
console.log(proxyObj.prop1); // get... {prop1: 1} prop1,1
// 动态添加属性依旧可以监听
proxyObj.prop2 = 2; // set... {prop1: 1} prop2 2
console.log(proxyObj.prop2); // get... {prop1: 1, prop2: 2} prop2,2

/* ------------------------------ 分割线 ------------------------------ */

let arr = [1, 2, 3];
let proxyArr = new Proxy(arr, {
  get: function (data, index) {
    console.log('get...', data, index);
    return data[index] || 'not found index';
  },
  set: function (data, index, value) {
    console.log('set...', data, index, value);
    data[index] = value;
    return true;
  }
});
console.log(proxyArr[0]); // get... [1, 2, 3] 0,1
proxyArr.push(4); // set... [1, 2, 3] 3,4
// 动态添加依旧可以监听
console.log(proxyArr[3]); // get... [1, 2, 3, 4] 3,4

Perfect!!!

Proxy 支持 13 种拦截操作,这是 defineProperty 没有的。

操作

介绍

get(target, propKey, receiver)

拦截对象属性的读取,比如 proxy.foo 和 proxy['foo'] 。

set(target, propKey, value, receiver)

拦截对象属性的设置,比如 proxy.foo = v 或 proxy['foo'] = v ,返回一个布尔值。

has(target, propKey)

拦截 propKey in proxy 的操作,返回一个布尔值。

deleteProperty(target, propKey)

拦截 delete proxy[propKey] 的操作,返回一个布尔值。

ownKeys(target)

拦截 Object.getOwnPropertyNames(proxy)、 Object.getOwnPropertySymbols(proxy)、Object.keys(proxy)、for...in 循环,返回一个数组。该方法返回目标对象所有自身的属性的属性名,而 Object.keys() 的返回结果仅包括目标对象自身的可遍历属性。

getOwnPropertyDescriptor(target, propKey)

拦截 Object.getOwnPropertyDescriptor(proxy, propKey) ,返回属性的描述对象。

defineProperty(target, propKey, propDesc)

拦截 Object.defineProperty(proxy, propKey, propDesc)、Object.defineProperties(proxy, propDescs),返回一个布尔值。

preventExtensions(target)

拦截 Object.preventExtensions(proxy),返回一个布尔值。

getPrototypeOf(target)

拦截 Object.getPrototypeOf(proxy),返回一个对象。

isExtensible(target)

拦截 Object.isExtensible(proxy),返回一个布尔值。

setPrototypeOf(target, proto)

拦截 Object.setPrototypeOf(proxy, proto) ,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。

apply(target, object, args)

拦截 Proxy 实例作为函数调用的操作,比如 proxy(...args)、proxy.call(object, ...args)、proxy.apply(...) 。

construct(target, args)

拦截 Proxy 实例作为构造函数调用的操作,比如 new proxy(...args) 。

新标准性能红利

  • Proxy 作为新标准,长远来看,JS 引擎会继续优化 Proxy,但 gettersetter 基本不会再有针对性优化。

Proxy 兼容性差

可以看到,Proxy 对于 IE 浏览器来说简直是灾难。

并且目前并没有一个完整支持 Proxy 所有拦截方法的 Polyfill 方案,有一个 Google 编写的 proxy-polyfill 也只支持了 get,set,apply,construct 四种拦截,可以支持到 IE9+ 和 Safari 6+。

总结

  • Object.defineProperty 对数组和对象的表现一致,并非不能监控数组下标的变化,Vue2 中无法通过数组索引来实现响应式数据的自动更新是 Vue 本身的设计导致的,不是 defineProperty 的问题。
  • Object.definePropertyProxy 本质差别是,defineProperty 只能对属性进行劫持,新增属性需要手动 Observe 的问题。
  • Proxy 作为新标准,浏览器厂商势必会对其进行持续优化,但它的兼容性也是块硬伤,并且目前还没有完整的 polyfill 方案。

参考来源

  • 转改自掘金
  • 参考 MDN defineProperty
  • 参考 MDN Proxy
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2020-10-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 导读
  • 基础使用
  • 能否监测数组下标的变化
    • 测试内容与代码
      • 通过下标获取某个元素和修改某个元素的值
        • 数组的 push 方法
          • 数组的 unshift 方法
            • 数组的 pop 方法
              • 到这里,我们可以简单的总结一下结论。
              • Vue 对数组的 observe 做了哪些处理
              • Object.defineProperty Vs Proxy
                • Object.defineProperty 只能劫持对象的属性,而Proxy是直接代理对象。
                  • Object.defineProperty 对新增属性需要手动进行 Observe。
                    • Proxy 支持 13 种拦截操作,这是 defineProperty 没有的。
                      • 新标准性能红利
                        • Proxy 兼容性差
                        • 总结
                        • 参考来源
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档