对比Object.defineProperty()和Proxy()

相信大家对于这两个名词并不陌生,最知名的变化就是vue从2到3底层数据响应使用的变化。今天得幸有时间,让我们重新温故一下,这方面的知识可能还需要大家对于JS的原型链有一定的认知。

Object.defineProperty()

首先我们来学习一下Object.defineProperty()方法。大家想要更加深入可以直接阅读MDN官网。

构造器上的方法

MDNapi截图

从图片可以看出,该方法是在Object直接调用,是直接在Object构造器上的,而不是在原型上。所以我们使用应当直接在构造器对象上调用此方法,而不是在任意一个Object类型的实例上调用。

语法

Object.defineProperty(obj, prop, descriptor)
// 参数
// obj 要定义属性的目标对象
// prop 要定义或修改的属性的名称或Symbol,就像对象的键值
// descriptor 要定义或修改的属性的描述符,用来描述该属性是否可被枚举,可被赋值等等,下面会讲解

返回值:obj对象,被传递给方法经过处理的目标对象。

在ES6中,由于 Symbol类型的特殊性,用Symbol类型的值来做对象的key与常规的定义或修改不同,而Object.defineProperty 是定义key为Symbol的属性的方法之一。

描述符

描述符主要分为三类:公共描述符(官网并没有这个称呼,这个是我自己取的,便于理解),数据描述符,存取描述符。

数据描述符键值:
  • value
    描述:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。
    默认值:undefined
  • writable
    描述:是否可以被赋值运算符改变
    默认值:false
存取运算符键值(这里不懂的朋友可以理解成属性存值取值的过程,类似钩子)
  • get
    描述:属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。
    默认值:undefined
  • set
    描述:属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。
    默认值:undefined
公共描述符键值(该名词并不官方)

数据描述符可以看出,我们拥有了直接给属性赋值的权利,还能控制属性是否可以改写。
存取描述符可以看出,属性取值和赋值的操作完全掌握在我们自己手中。
所以不免有朋友好奇,这两种描述符是否有些重叠,你的疑问是对的。数据描述符和存取描述符确实不能同时使用,它两水火不容,同时使用会产生异常。但是我们下面要讲的描述符确实可以和它们在一起配合使用,所以我给它们取名公共描述符。

  • configurable
    描述:该属性的描述符是否能被修改,属性是否能被删除。
    默认值:false
    如果属性已经存在,Object.defineProperty()将尝试根据描述符中的值以及对象当前的配置来修改这个属性。如果旧描述符将其configurable 属性设置为false,则该属性被认为是“不可配置的”,并且没有属性可以被改变(除了单向改变 writable 为 false)。当属性不可配置时,不能在数据和访问器属性类型之间切换。

当试图改变不可配置属性(除了 valuewritable 属性之外)的值时,会抛出TypeError,除非当前值和新值相同。

  • enumerable
    描述:enumerable 定义了对象的属性是否可以在 for...in 循环和 Object.keys() 中被枚举。
    默认值:false
var o = {};
Object.defineProperty(o, "a", { value : 1, enumerable: true });
Object.defineProperty(o, "b", { value : 2, enumerable: false });
Object.defineProperty(o, "c", { value : 3 }); // enumerable 默认为 false
o.d = 4; // 如果使用直接赋值的方式创建对象的属性,则 enumerable 为 true
Object.defineProperty(o, Symbol.for('e'), {
  value: 5,
  enumerable: true
});
Object.defineProperty(o, Symbol.for('f'), {
  value: 6,
  enumerable: false
});

for (var i in o) {
  console.log(i);
}
// logs 'a' and 'd' (in undefined order)

Object.keys(o); // ['a', 'd']

o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false
o.propertyIsEnumerable('d'); // true
o.propertyIsEnumerable(Symbol.for('e')); // true
o.propertyIsEnumerable(Symbol.for('f')); // false

var p = { ...o }
p.a // 1
p.b // undefined
p.c // undefined
p.d // 4
p[Symbol.for('e')] // 5
p[Symbol.for('f')] // undefined

记住,这些选项不一定是自身属性,也要考虑继承来的属性。为了确认保留这些默认值,在设置之前,可能要冻结 Object.prototype (en-US),明确指定所有的选项,或者通过 Object.create(null)__proto__ (en-US) 属性指向 null

// 使用 __proto__
var obj = {};
var descriptor = Object.create(null); // 没有继承的属性
// 默认没有 enumerable,没有 configurable,没有 writable
descriptor.value = 'static';
Object.defineProperty(obj, 'key', descriptor);

// 显式
Object.defineProperty(obj, "key", {
  enumerable: false,
  configurable: false,
  writable: false,
  value: "static"
});

继承

如果访问者的属性是被继承的,它的 get 和 set 方法会在子对象的属性被访问或者修改时被调用。如果这些方法用一个变量存值,该值会被所有对象共享。

function myclass() {
}

var value;
Object.defineProperty(myclass.prototype, "x", {
  get() {
    return value;
  },
  set(x) {
    value = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // 1

这可以通过将值存储在另一个属性中解决。在 get 和 set 方法中,this 指向某个被访问和修改属性的对象。

function myclass() {
}

Object.defineProperty(myclass.prototype, "x", {
  get() {
    return this.stored_x;
  },
  set(x) {
    this.stored_x = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // undefined

不像访问者属性,值属性始终在对象自身上设置,而不是一个原型。然而,如果一个不可写的属性被继承,它仍然可以防止修改对象的属性。

function myclass() {
}

myclass.prototype.x = 1;
Object.defineProperty(myclass.prototype, "y", {
  writable: false,
  value: 1
});

var a = new myclass();
a.x = 2;
console.log(a.x); // 2
console.log(myclass.prototype.x); // 1
a.y = 2; // Ignored, throws in strict mode
console.log(a.y); // 1
console.log(myclass.prototype.y); // 1

Proxy

接下来我们来学习一下Proxy。同样,大家想要更加深入可以直接阅读MDN官网。

defineProperty方法

Proxy对象

从图片我们可以看出Proxy是JS的标准内置对象,和Object平级。
Proxy对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。我理解这边就像是给对象包了一层外衣,让我们可以按照规定自己定义在不同捕捉的时机执行的hooks。

术语

1.handler(en-US)
包含捕捉器(trap)的占位符对象,可以称为处理器对象。
2.traps
提供属性访问的方法。这里指的其实就是根据时机我们自己定义要执行的方法群。
3.target
被Proxy代理虚拟化的对象。这里其实就是目标对象。

语法

const p = new Proxy(target, handler)
// target 要使用Proxy的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另外一个代理)。
// handler 一个通常以函数作为属性的对象,按照捕获时机定义的方法。

捕获时机都有哪些?

Proxy对象的捕捉时机

handler对象是一个容纳一批特定属性的占位符对象。它包含有 Proxy 的各个捕获器(trap)。
所有的捕捉器是可选的。如果没有定义某个捕捉器,那么就会保留源对象的默认行为。我们可以根据自己的需要去定义这个目标对象的操作方法,如果同学还想深入了解不同捕获器请查看官网。

重点来啦

我们从一道面试题来对比两者:实现双向绑定Proxy与defineProperty的优劣

双向绑定体系

双向绑定其实已经是一个老掉牙的问题,只要涉及到MVVM框架就不得不涉及的知识点,大家熟知的vue三要素之一。

Vue三要素

  • 响应式:监听数据变化,实现方法就是响应式
  • 模板引擎:如何解析模板
  • 渲染:Vue如何将监听到的数据变化和解析后的HTML进行渲染。

可以实现双向绑定的方法有很多:
1.KnockoutJs基于观察者模式的双向绑定
2.Ember基于数据模型的双向绑定
3.Angular基于脏检查的双向绑定
4.面试中最常见的基于数据劫持的双向绑定

最常见的基于数据劫持的双向绑定有两种实现:

  • Vue2 Object.defineProperty 实现
  • Vue3 Proxy 实现(严格来说Proxy被称为代理,并非劫持,不过作用有很多相似之处)
双向绑定体系

基于数据劫持的Object.observe方法,已被废弃。

基于数据劫持实现的双向绑定的特点

什么是数据劫持?

通常我们利用Object.defineProperty()劫持对象的访问器,在属性变化时我们可以获取变化,从而进行下一步操作。使用过Vue2的小伙伴们都知道,Vue初始化实例时会把data中声明的对象属性进行getter/setter转化,也就是使用了Object.defineProperty。

数据劫持的优势

目前主流框架可以分为两个流派:一个是以React为首的单向数据绑定,另一个是以Angular、Vue为主的双向数据绑定。

其实三大框架都是既可以双向绑定也可以单项绑定,比如React可以手动绑定onChange和value实现双向绑定,也可以调用可以双向绑定库。Vue也加入了props这种单向流的api,不过都并非主流卖点。

这里我们不讨论单向或者双向,我们来对比其他双向绑定的实现方法,数据劫持的优势所在:
1.无需显式调用:例如Vue运用数据劫持+发布订阅,直接可以通知变化并驱动视图。比如Angular则需要显式调用markForCheck(这里可以使用zone.js库避免显示调用),react则需要显式调用setState。
2.可精准得知变化数据:我们劫持了属性的setter,当属性变化,我们可以精确获知变化的内容,因此这部分不需要额外的diff操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这个时候就需要大量的diff来找出变化值,这是额外的性能损耗。

基于数据劫持双向绑定的实现思路

数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是Vue。
基于数据劫持的双向绑定离不开Proxy与Object.defineProperty等方法对对象/对象属性的“劫持”,我们要实现一个完整的双向绑定需要以下几个要点。
1.利用Proxy或Object.defineProperty生成的Observer针对对象/对象的属性进行“劫持”,在属性发生变化后通知订阅者。(①)
2.解析器Compile解析模板中的Directive(指令),收集指令所依赖的方法和数据,等待数据变化进行渲染。(②)
3.Watcher属于Observer和Compile桥梁,它将接收到的Observer产生的数据变化,并根据Compile提供的指令进行试图渲染,是的数据变化促使视图图变化。(③)

基于数据劫持的双向绑死实现思路

这里有人就好奇了,为什么我们不一个模块直接在Dep数据变化的时候直接去更新视图,还要用发布订阅模式。我这里总结了两个原因:

  • 大家如果了解开放封闭原则,就会知道这样操作明显违反了开放封闭原则。
  • 代码耦合严重,我们的数据,方法和DOM都是耦合在一起的,这就是传说的面条代码。

到这里,相信大家对于如何实现双向绑定都有了相应的认知。接下来我们通过分析Vue2和Vue3响应式数据原理来对比Object.defineProperty到Proxy都有哪些的不同。

Vue2响应式数据原理

Vue2的数据响应使用了ES5中的Object.defineProperty。
Object.defineProperty只能劫持对象的属性,从而需要对每个对象,每个属性进行遍历。如果,属性值是对象,还需要深度遍历。

  • 优点:
    兼容性好,IE9 。
  • 缺点:
    1.只能劫持对象的属性,因此需要对每个对象的每个属性进行遍历,性能消耗大。
    2.Vue的文档提到了Vue是可以检测到数组变化的,但是只有以下七种方法。其实Vue2里面是通过重写数据的操作方法(通过原型链进行拦截)来对数组进行监听的。但是对于数组长度变化下标值修改内容是无法监听的,Vue提供了Vue.set()进行响应式。
    3.不能监听对象属性的新增和删除。
    4..不能对es6新产生的Map,Set这些数据结构做出监听。
    因为Object.defineProperty会一开始就会遍历data、methods、props、computed、watch、mixins… 里的一系列变量全都绑定在this上,当嵌套层次比较深时会影响性能和占内存比较大。
push()
pop()
shift()
unshift()
splice()
sort()
reverse()

模拟Vue2中用Object.defineProperty实现数据响应

let obj = {
  key:'cue'
  flags: {
    name: ['AAA', 'VVV', 'FFF']
  }
}
function observer(obj) {
  if (typeof obj == 'object') {
    for (let key in obj) {
      defineReactive(obj, key, obj[key])
    }
  }
}
function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      console.log('获取:' + key)
      return value
    },
    set(val) {
      observer(val)
      console.log(key + "-数据改变了")
      value = val
    }
  })
}
observer(obj)

Vue3响应式数据原理

先来看下MND描述:

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

Proxy可以劫持整个对象,并返回一个新的对象。

  • 优点:
    1.可以直接监听对象而非属性
    2.可以直接监听数组的变化
    3.有多种拦截方式,不限于apply、ownKeys、has等是defineProperty不具备的
    4.返回的是一个新对象,我们可以只操作新的对象达到目的,而defineProperty只能遍历对象属性直接修改
    模拟Vue3种用Proxy实现数据响应
let obj = {
  key:'cue'
  flags: {
    name: ['AAA', 'VVV', 'FFF']
  }
}
function observerProxy(obj) {
  const handler = {
    get(target, key, receiver) {
      console.log("获取:" + key);
      if (typeof target[key] === "object" && target[key] !== null) {
       // 如果是对象,就添加 proxy 拦截
        return new Proxy(target[key], handler);
      }
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      console.log("设置:" + key);
      return Reflect.set(target, key, value, receiver);
    },
  };
  return new Proxy(obj, handler);
}
let newObj = observerProxy(obj);

vue3在开发中的一些具体使用


总结

首先,我们先认识到了Object.defineProperty和Proxy的概念和使用。再围绕着实现双向绑定Proxy与defineProperty的优劣面试题,了解双向绑定体系,什么是数据劫持,数据劫持实现双向绑定的思路。最后通过分析Vue2和Vue3响应式数据原理实现(Observer)的区别,更加深入了解了从Object.defineProperty到Proxy的一大步进步。有兴趣的同学,可以按照基于数据劫持的双向绑死实现思路图实现完整双向绑定。

你可能感兴趣的:(对比Object.defineProperty()和Proxy())