对 Object.defineProperty 的理解

仅以此文记录自己对 Object.defineProperty()方法的理解,源起题目监听 data 变化的核心 API 是什么?

听君一席话

首先是方法的描述:

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

通俗易懂,来自MDN

参数部分,参数有三:

  1. target,要修改的目标对象;
  2. prop, 要修改的属性;
  3. descriptor, 要定义或修改的属性描述符;

前两个好理解,参数 3 如何理解呢?

  1. 对象中目前存在两种属性描述符,数据描述符和存取描述符;

  2. 这两种属性描述符共享以下三个参数:

    1. configurable,值为 true 或 false,为 true 时属性才能被修改或删除,从 config 这个词就可以理解,默认为 false;

    2. enumerable,值为 true 或 false,为 true 时属性才能被枚举,还可以直接写成属性对应的值,默认为 false;

    3. writable,值为 true 或 false,为 true 时属性才能被修改,也就是 enumerable 才能直接写属性对应的值,默认为 false;

      同时,writable 还有两个可选的键值 get 和 set

      • get,属性的 getter 函数,访问属性时调用此方法,函数的返回值就是属性的值,默认为 undefined;
      • set, 属性的 setter 函数,修改属性时调用此方法,接受一个参数,也就是新的属性值,默认为 undefined;
    4. 默认情况下,直接由Object.defineProperty()定义的属性是不可修改的,因为 configurable 属性默认是 false,如要修改,需要显示的定义 configurable 属性为 true 才行;

    5. 接上一条,注意:是直接Object.defineProperty()定义的属性默认不能修改,所以,对象上已存在的(不是由Object.defineProperty()定义的)属性默认是可以被修改的;

今天的主角是vue 是如何实现 data 响应式的?,所以,不要过分纠结此方法的细节,否则会深陷其中而顾此失彼,感兴趣的话可以抽空通读细读文档;

正题

先来一道开胃菜,不借助框架如何实现下面对象的响应式?

1
2
3
4
const data = {
name: "zhangsan",
age: 18,
};

预期调用:

1
2
3
observer(data);
data.name = "lisi";
data.age = 20;

预期输出:

预期输出

首先定义一个方法,提示视图更新:

1
2
3
function updataView(key, value, newValue) {
console.log(`视图更新,属性${key}${value}变更为${newValue}`);
}

然后定义主方法:

1
2
3
4
5
function observer(target) {
for (let key in target) {
defineReactive(target, key, target[key]);
}
}

然后实现核心方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function defineReactive(target, key, value) {
// 主角登场
// 这里用到了存取描述符 writable 的 get 和 set 方法,来获取和修改对象已存在的属性
Object.defineProperty(target, key, {
get() {
return value;
},
set(newValue) {
// 保存 value 给 updataView 用
const oldValue = value;
if (newValue !== value) {
value = newValue;

// 触发更新
updataView(key, oldValue, newValue);
}
},
});
}

加个菜

如果对象是这样的呢?

1
2
3
4
5
6
7
const data = {
name: "zhangsan",
age: 18,
info: {
city: "beijing",
},
};

是不是想到了,在遍历目标对象的属性时加个判断,然后递归调用,没错!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function observer(target) {
if (typeof target !== "object" || typeof target === null) {
return target;
}

for (let key in target) {
defineReactive(target, key, target[key]);
}
}

function defineReactive(target, key, value) {
// 递归调用
observer(value);
Object.defineProperty(target, key, {
get() {
return value;
},
set(newValue) {
const oldValue = value;
if (newValue !== value) {
value = newValue;

// 触发更新
updataView(key, oldValue, newValue);
}
},
});
}

输出:

加个菜

菜中菜

如果调用时是这样呢?

1
2
3
4
5
6
7
8
9
data.age = 20;
data.name = {
firstName: "john",
lastName: "Lee",
};
// 雷同
data.name.firstName = "smith";
data.name.last = "Bruce";
data.info.city = "shanghai";

很简单,在 set 函数中也使用深度监听(递归调用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function defineReactive(target, key, value) {
// 递归调用
observer(value);
Object.defineProperty(target, key, {
get() {
return value;
},
set(newValue) {
const oldValue = value;
if (newValue !== value) {
// 深度监听
observer(newValue);
value = newValue;

// 触发更新
updataView(key, oldValue, newValue);
}
},
});
}

输出:

菜中菜

基本类型和对象的响应式已经差不多了,那么数组的响应式是如何实现的呢,往下看;

Object.defineProperty()方法的不足

第 1 条确实是Object.defineProperty()方法的不足,但下面两条其实Object.defineProperty()是可以实现的,但基于性能或其他考虑,Vue 并没有使用

  • 深度监听时,如果对象嵌套层级很深,需要递归到底,一次性计算量很大;
  • 无法监听新增和删除属性:
    • 原因:由于 Vue 会在初始化实例时对 property 执行 getter/setter 转化,所以 property 必须在 data 对象上存在才能让 Vue 将它转换为响应式的。 来自官方文档;
    • 可以使用 Vue.set()Vue.delete()实现对象的增删;
  • 无法监听数组长度的变化,以及直接使用索引修改数组内容

数组的响应式

接着上面的代码,首先重写数组原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 重新定义数组原型
const newArrayPrototype = Array.prototype;
// 创建新对象,原型指向 newArrayPrototype,扩展的新方法不会污染全局的 Array.prototype
const arrProto = Object.create(newArrayPrototype);

// 重写数组的方法
["push", "pop"].forEach((methodName) => {
arrProto[methodName] = function () {
// 触发更新
updateView();
newArrayPrototype[methodName].call(this, ...arguments);
};
});

然后在主函数中处理数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function observer(target) {
if (typeof target !== "object" || typeof target === null) {
return target;
}

// 将数组的隐式原型指向我们定义的原型
// 这里调用的数组方法就是我们重写后的方法
if (Array.isArray(target)) {
target.__proto__ = arrProto;
}

for (let key in target) {
defineReactive(target, key, target[key]);
}
}

输入:

1
2
3
4
5
6
7
8
9
10
11
12
13
const data = {
name: "zhangsan",
age: 18,
info: {
city: "beijing",
},
arr: [10, 20, 30],
};

observer(data);

data.arr.push(40);
data.arr.pop();

输出:

数组

undefined 是因为数组在触发视图更新时没有传递参数;