前言

虽然我的主技术栈是 React 的,但是每当面试的时候,面试官几乎都会问你说一下 React 和 Vue 的区别,在说道双向数据绑定的时候,面试官会下意识的问一句,你说一下 Vue 的双向数据绑定的原理,这个时候 Object.defineProperty 就出场了,但是在 Vue3.0 中,Proxy 取代了 Object.defineProperty,成为双向绑定的底层原理,这个时候 Proxy 就显得尤为重要。

本篇文章先以 Object.defineProperty 作为引入,之后讲解 Proxy,最后比较两者之间的优劣。

1、Object.defineProperty 数据劫持

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

该方法接受三个参数,第一个参数是 obj:要定义属性的对象,第二个参数是 prop:要定义或修改的属性的名称或 Symbol,第三个参数是 descriptor:要定义或修改的属性描述符。

1
2
3
4
5
const obj = {};
Object.defineProperty(obj, "property", {
value: 18,
});
console.log(obj.property); // 18

虽然我们可以直接添加属性和值,但是使用这种方式,我们能进行更多的配置。

函数的第三个参数 descriptor 所表示的属性描述符有两种形式:数据描述符和存取描述符。数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。

这两种同时拥有下列两种键值:

  1. configurable:当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
  2. enumerable:当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
1
2
3
4
5
6
7
8
9
10
11
12
13
const obj = { property: 24 };
Object.defineProperty(obj, "property", {
configurable: true,
});
delete obj["property"]; // true
obj; // {}
// 改变状态
const obj = { property: 24 };
Object.defineProperty(obj, "property", {
configurable: false,
});
delete obj["property"]; // false
obj; // {'property': 24}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const obj = {
property1: 24,
property2: 34,
property3: 54,
};
Object.defineProperty(obj, "property1", {
enumerable: true,
});
for (i in obj) {
console.log(i);
}
// property1
// property2
// property3
// 改状态
Object.defineProperty(obj, "property1", {
enumerable: false,
});
for (i in obj) {
console.log(i);
}
// property2
// property3

数据描述符还具有以下可选键值:

  1. value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined。
  2. writable:当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。默认为 false。
1
2
3
4
5
const obj = {};
Object.defineProperty(obj, "property1", {
value: 18,
});
obj; // {'property1': 18}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {};
Object.defineProperty(obj, "property1", {
value: 18,
writable: false,
});
obj.property1 = 24;
obj; // {'property1': 18}
// 改变状态
const obj = {};
Object.defineProperty(obj, "property1", {
value: 18,
writable: true,
});
obj.property1 = 24;
obj; // {'property1': 24}

存取描述符还具有以下可选键值:

  1. get:属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的 this 并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。默认为 undefined。
  2. set:属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。默认为 undefined。
1
2
3
4
5
6
7
8
9
const obj = {};
Object.defineProperty(obj, "property1", {
get(value) {
return value;
},
set(newValue) {
value = newValue;
},
});

2、Proxy 数据拦截

Object.defineProperty 只能重定义获取和设置的行为,而 Proxy 相当于一个升级,它重定义了更多的行为,接下来我们对其进行深入讲解。

首先,Proxy 是一个构造函数,可以通过 new 来创建它的实例,其接受两个参数,一个是 target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。另外一个是 handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理实例的行为。

1
let p = new Proxy(target, handler);

handler 对象的方法

handler 中的所有方法都是可选的,如果没有定义哪个方法,那就保留原对象的默认行为。

1、get()

用于拦截对象的读取操作。该方法接收三个参数,target:目标对象,property:被获取的属性名,receiver:Proxy 或者继承 Proxy 的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let person = {
name: "Jack",
};
let p = new Proxy(person, {
get(target, property) {
if (property in target) {
return target[property];
} else {
throw Error("不存在该属性");
}
},
});
p.name; // Jack
p.age; // Uncaught Error: 不存在该属性

查看第三个属性

1
2
3
4
5
6
7
8
9
let person = {
name: "Jack",
};
let p = new Proxy(person, {
get(target, property, receiver) {
return receiver;
},
});
p.name === p; // true

上面代码中,p 对象的 name 属性是由 p 对象提供的,所以 receiver 指向 proxy 对象。

2、set()

用于设置属性值操作的捕获器。该方法接收四个参数,target:目标对象,property:将被设置的属性名或 Symbol,value:新属性值,receiver:最初被调用的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 proxy 本身)。

1
2
3
4
5
6
7
let person = {};
let p = new Proxy(person, {
set(target, property, value, receiver) {
target[property] = value;
},
});
p.name = 2;

当给 Proxy 的实例添加属性的时候,就会调用 set()方法。

下面我们来看一下 set()方法的第四个参数,一般情况下都是指向 proxy 本身。

1
2
3
4
5
6
7
8
let person = {};
let p = new Proxy(person, {
set(target, property, value, receiver) {
target[property] = receiver;
},
});
p.name = 2;
p.name === p; // true

但是也有其他情况,我们借用阮一峰:ECMAScript 6 入门-Proxy中谈到的例子:

1
2
3
4
5
6
7
8
9
10
11
const handler = {
set: function (obj, prop, value, receiver) {
obj[prop] = receiver;
},
};
const proxy = new Proxy({}, handler);
const myObj = {};
Object.setPrototypeOf(myObj, proxy);

myObj.foo = "bar";
myObj.foo === myObj; // true

上面代码中,设置 myObj.foo 属性的值时,myObj 并没有 foo 属性,因此引擎会到 myObj 的原型链去找 foo 属性。myObj 的原型对象 proxy 是一个 Proxy 实例,设置它的 foo 属性会触发 set 方法。这时,第四个参数 receiver 就指向原始赋值行为所在的对象 myObj。

值得注意一点的是,当该对象不可配置不可编写的时候,那么 set()方法将不起作用。

1
2
3
4
5
6
7
8
9
10
11
12
const obj = {};
Object.defineProperty(obj, "foo", {
value: 18,
writable: false,
});
let p = new Proxy(obj, {
set(target, property, value, receiver) {
target[property] = value;
},
});
p.foo = 28;
p.foo; // 18

3、apply()

用于拦截函数的调用。该方法接受三个参数,target:目标对象(函数)。thisArg:被调用时的上下文对象。argumentsList:被调用时的参数数组。

当 Prxoy 的实例当做函数使用时,就会执行该方法。

1
2
3
4
5
6
let p = new Proxy(function () {}, {
apply(target, thisArg, argumentsList) {
console.log("Hello Word");
},
});
p(); // Hello Word

4、has()

是针对 in 操作符的代理方法。该方法接受两个参数,target:目标对象。prop:需要检查是否存在的属性。

1
2
3
4
5
6
7
8
9
let p = new Proxy(
{},
{
has(target, prop) {
console.log(target, prop); // {} 'a'
},
}
);
console.log("a" in p); // false

has()方法只对 in 运算符有效,对 for…in…运算没有实际作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let p = new Proxy(
{ value: 18 },
{
has(target, prop) {
if (prop === "value" && target[prop] < 20) {
console.log("数值小于20");
return false;
}
return prop in target;
},
}
);
"value" in p; // 数值小于20 false
for (let a in p) {
console.log(p[a]); // 18
}

从上述例子可以看出,has 方法拦截只对 in 运算符有效,对 for…in…来说没有拦截效果。

5、construct

用于拦截 new 操作符. 为了使 new 操作符在生成的 Proxy 对象上生效,用于初始化代理的目标对象自身必须具有[[Construct]]内部方法(即 new target 必须是有效的)。该方法接收三个参数,target:目标对象。argumentsList:constructor 的参数列表。newTarget:最初被调用的构造函数。

1
2
3
4
5
let p = new Proxy(function () {}, {
construct(target, argumentsList, newTarget) {
return { value: argumentsList[0] };
},
})(new p(1)).value; // 1

construct()方法必须返回一个对象,不然不报错。

1
2
3
4
5
6
let p = new Proxy(function () {}, {
construct(target, argumentsList, newTarget) {
console.log("Hello Word");
},
});
new p(); // Uncaught TypeError: 'construct' on proxy: trap returned non-object ('undefined')

6、deleteProperty

用于拦截对对象属性的 delete 操作。该方法接受两个参数。target:目标对象。property:待删除的属性名。

1
2
3
4
5
6
7
8
9
10
let p = new Proxy(
{},
{
deleteProperty(target, property) {
console.log("called: " + property);
return true;
},
}
);
delete p.a; // "called: a"

如果这个方法抛出错误或者返回 false,当前属性就无法被 delete 命令删除。注意,目标对象自身的不可配置(configurable)的属性,不能被 deleteProperty 方法删除,否则报错。

7、defineProperty()

用于拦截对对象的 Object.defineProperty() 操作。该方法接受三个参数,target:目标对象。property:待检索其描述的属性名。descriptor:待定义或修改的属性的描述符。

1
2
3
4
5
6
7
8
9
10
11
12
let p = new Proxy(
{},
{
defineProperty: function (target, prop, descriptor) {
console.log("called: " + prop);
return true;
},
}
);

let desc = { configurable: true, enumerable: true, value: 10 };
Object.defineProperty(p, "a", desc); // "called: a"

如果 defineProperty()方法内部没有任何操作,只返回 false,导致添加新属性总是无效。注意,这里的 false 只是用来提示操作失败,本身并不能阻止添加新属性。

注意,如果目标对象不可扩展(non-extensible),则 defineProperty()不能增加目标对象上不存在的属性,否则会报错。另外,如果目标对象的某个属性不可写(writable)或不可配置(configurable),则 defineProperty()方法不得改变这两个设置。

8、defineProperty()

用法是拦截 Object.getOwnPropertyDescriptor(),必须返回一个 object 或 undefined。该方法接受两个参数,target:目标对象。prop:返回属性名称的描述。

1
2
3
4
5
6
7
8
9
10
11
12
let p = new Proxy(
{ a: 20 },
{
getOwnPropertyDescriptor: function (target, prop) {
console.log("called: " + prop);
return { configurable: true, enumerable: true, value: 10 };
},
}
);

console.log(Object.getOwnPropertyDescriptor(p, "a").value); // "called: a"
// 10

9、getPrototypeOf()

是一个代理(Proxy)方法,当读取代理对象的原型时,该方法就会被调用。该方法只接受一个参数,target:被代理的目标对象。返回值必须是一个对象或者 null。
触发该方法的条件总共有 5 种:

  1. Object.getPrototypeOf()
  2. Reflect.getPrototypeOf()
  3. __proto__
  4. Object.prototype.isPrototypeOf()
  5. instanceof
1
2
3
4
5
6
7
8
9
10
let proto = {};
let p = new Proxy(
{},
{
getPrototypeOf(target) {
return proto;
},
}
);
Object.getPrototypeOf(p) === proto; // true

10、isExtensible()

用于拦截对对象的 Object.isExtensible()。该方法接受一个参数。target:目标对象。返回值必须返回一个 Boolean 值或可转换成 Boolean 的值。

1
2
3
4
5
6
7
8
9
10
11
12
let p = new Proxy(
{},
{
isExtensible: function (target) {
console.log("called");
return true;
},
}
);

console.log(Object.isExtensible(p)); // "called"
// true

注意,该方法有一个强制的约束,即 Object.isExtensible(proxy) 必须同 Object.isExtensible(target)返回相同值。也就是必须返回 true 或者为 true 的值,返回 false 和为 false 的值都会报错。

1
2
3
4
5
6
7
8
9
10
let p = new Proxy(
{},
{
isExtensible: function (target) {
return false;
},
}
);

Object.isExtensible(p); // Uncaught TypeError: 'isExtensible' on proxy: trap result does not reflect extensibility of proxy target (which is 'true')

11、ownKeys()

用来拦截对象自身属性的读取操作。该方法接受一个参数,target:目标对象。

具体拦截如下:

  1. Object.getOwnPropertyNames()
  2. Object.getOwnPropertySymbols()
  3. Object.keys()
  4. for…in 循环

该方法有几个约束条件:

  1. ownKeys 的结果必须是一个数组
  2. 数组的元素类型要么是一个 String ,要么是一个 Symbol
  3. 结果列表必须包含目标对象的所有不可配置(non-configurable )、自有(own)属性的 key
  4. 如果目标对象不可扩展,那么结果列表必须包含目标对象的所有自有(own)属性的 key,不能有其它值
1
2
3
4
5
6
7
8
9
10
11
let target = {
a: 1,
b: 2,
c: 3,
};
let p = new Proxy(target, {
ownKeys(target) {
return ["a"];
},
});
Object.keys(p); // ['a']

12、preventExtensions()

用于设置对 Object.preventExtensions()的拦截。该方法接受一个参数,target:所要拦截的目标对象。该方法返回一个布尔值。该方法有个限制,即如果目标对象是可扩展的,那么只能返回 false。

1
2
3
4
5
6
7
8
9
10
let p = new Proxy(
{},
{
preventExtensions: function (target) {
return true;
},
}
);

Object.preventExtensions(p); // Uncaught TypeError: 'preventExtensions' on proxy: trap returned truish but the proxy target is extensible

上面代码中,proxy.preventExtensions()方法返回 true,但这时 Object.isExtensible(proxy)会返回 true,因此报错。

为了防止出现这个问题,通常要在 proxy.preventExtensions()方法里面,调用一次 Object.preventExtensions()。

1
2
3
4
5
6
7
8
9
10
11
12
13
let p = new Proxy(
{},
{
preventExtensions: function (target) {
console.log("called");
Object.preventExtensions(target);
return true;
},
}
);

console.log(Object.preventExtensions(p)); // "called"
// false

13、setPrototypeOf()

用来拦截 Object.setPrototypeOf()。该方法接受两个参数,target:被拦截目标对象。prototype:对象新原型或为 null。如果成功修改了[[Prototype]], setPrototypeOf 方法返回 true,否则返回 false。

1
2
3
4
5
6
7
8
9
10
var handler = {
setPrototypeOf(target, proto) {
throw new Error("Changing the prototype is forbidden");
},
};
var proto = {};
var target = function () {};
var proxy = new Proxy(target, handler);
Object.setPrototypeOf(proxy, proto);
// Error: Changing the prototype is forbidden

上面代码中,只要修改 target 的原型对象,就会报错。

注意,如果目标对象不可扩展(non-extensible),setPrototypeOf()方法不得改变目标对象的原型。

3、Object.defineProperty 和 Proxy 的区别

最主要的区别其实是应用到 Vue 的双向数据绑定中,其实也有由于 Vue 的双向数据绑定,才让这两个方法越来越多的进入到人们的视野中。

在此我们不深入讲解双向数据绑定,所以分别用这两个方法实现个简单版的双向数据绑定,来看看两者之间的区别。

3.1、Object.defineProperty 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const obj = {};
Object.defineProperty(obj, "text", {
get: function () {
console.log("get val");
},
set: function (newVal) {
console.log("set val:" + newVal);
document.getElementById("input").value = newVal;
document.getElementById("span").innerHTML = newVal;
},
});

const input = document.getElementById("input");
input.addEventListener("keyup", function (e) {
obj.text = e.target.value;
});

可以看出来这个简单版的透露出 Object.defineProperty 一个很明显的缺点,就是只能对对象的一个属性进行监听,如果想要对对象的所有属性监听的话,就要去遍历,并且还有一个问题就是无法去监听数组,但是还是有优点的,优点就是兼容性好,这也是为什么 Vue2.0 优先选择了 Object.defineProperty 的原因。

3.2、Proxy 版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const input = document.getElementById("input");
const p = document.getElementById("p");
const obj = {};

const newObj = new Proxy(obj, {
get: function (target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function (target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key === "text") {
input.value = value;
p.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});

input.addEventListener("keyup", function (e) {
newObj.text = e.target.value;
});

从上述可以看出 Proxy 可以对整个对象进行拦截,并且其能返回一个新的对象,除此之外其能对数组进行拦截。而且最直观的就是其有 13 个拦截方式。但是其最致命的问题就是兼容性不好,而且无法用 polyfill 磨平,因此尤大大才声明需要等到下个大版本(3.0)才能用 Proxy 重写。