JS 属性描述符
场景引入
当前有一个对象,你需要对该对象进行封装。举例来说,对象可以是简单的商品对象数据:
1
2
3
4
5
6
7
8
const goods = {
pic: '/img/1.webp',
title: 'simple title',
desc: 'simple desc',
sellNumber: 1,
favorRate: 20,
price: 20,
};
自然的,你为了更方便的实现业务,会为它加上一些缺失的属性,比如选择的商品数量和总价,但是为了不更改原始对象数据 goods
,所以你使用 class
来进行封装:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 原始对象数据
const goods = {
pic: '/img/1.webp',
title: 'simple title',
desc: 'simple desc',
sellNumber: 1,
favorRate: 20,
price: 20,
};
class Goods {
constructor(goods) {
this.data = goods;
this.chooseNum = 0;
this.totalPrice = 0;
}
}
const g = new Goods(goods);
console.log(g);
然后,你意识到 totalPrice
可以由 data
和 chooseNum
算出来,所以你为了避免将来因为忘记有这回事,更新了选中商品数量却忘记更新总价数据,导致数据不一致的问题出现,选择去除这个会造成数据冗余的属性 totalPrice
,而是将它改为函数:
1
2
3
4
5
6
7
8
9
10
11
12
class Goods {
constructor(goods) {
this.data = goods;
this.chooseNum = 0;
// this.totalPrice = 0;
}
getTotalPrice() {
return this.chooseNum * this.data.price;
}
}
const g = new Goods(goods);
console.log(g.getTotalPrice());
终于,现在封装的像样子了…… 吗?
发散地想,随着业务进行,代码量越来越多,甚至要和其他人一起协作完成。此时,有人使用到了这个 g
,并且在不注意的情况下,直接更改了 g
的 data
属性呢?比如:
1
2
3
4
5
6
7
8
9
10
11
12
13
class Goods {
constructor(goods) {
this.data = goods;
this.chooseNum = 0;
// this.totalPrice = 0;
}
getTotalPrice() {
return this.chooseNum * this.data.price;
}
}
const g = new Goods(goods);
g.data = 'AAA建材招租';
console.log(g.getTotalPrice());
此时,调用的 g.getTotalPrice()
运行出错,再也得不到正确的结果,一个方法如此,如果有多个依靠 data
的方法,那情况更灾难了。我这里举的例子比较夸张,知道意思即可。
实际上,我们希望的效果肯定是原始数据不能改动,因为很多东西都是在这个数据的基础上建立起来的。
属性描述符
上述情况其实就是写库、写框架的人时刻在考虑的事:时刻考虑各种可能发生的 "神奇操作",并在代码上杜绝错误或不期望的操作生效,使得最终效果为期望效果。那么,属性描述符能一定程度上解决这种问题。
什么是属性描述符
假设有这样一个简单对象:
1
2
3
const obj = {
a: 1,
}
我们来想想,平时都是怎么描述一个对象的某个属性?
值为多少?可遍历?可重写?这些其实就是属性描述的一部分。那么,在代码表现上,怎么知道一个对象属性的描述?
JS
在这方面提供的函数 API
是 getOwnPropertyDescriptor
:
1
2
3
4
const desc = Object.getOwnPropertyDescriptor(obj, 'a')
console.log(desc);
// 这将输出:{ value: 1, writable: true, enumerable: true, configurable: true }
得到结果的是对象,它描述的就是对象 obj
的属性 a
。这个对象内的属性,描述的意思都很简单:
value
:属性值writable
:属性是否可重写enumerable
:属性是否可遍历configurable
:属性描述是否可再次配置
这就是说,对象的每一个属性都有一套属性描述符来描述。其中,第四个描述属性 configurable
决定了某个对象属性的属性描述符是否可以再次被配置。那么,如何配置一个属性的属性描述符呢?
JS
提供的函数 API
是 defineProperty
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const obj = {
a: 1,
}
Object.defineProperty(obj, 'a', {
value: 2,
writable: false,
enumerable: true,
configurable: false,
})
obj.a = 3;
Object.defineProperty(obj, 'a', {
value: 4,
})
console.log('obj.a:', obj.a);
// 这将报错:TypeError: Cannot redefine property: a
// 如果不进行Object.defineProperty则输出:obj.a: 3
当我们将 configurable
设为 false
之后,就无论如何都无法更改某个属性的属性描述符,更改则触发报错。
只有 configurable
会触发报错,其他属性描述符若存在冲突,不会触发任何显式提示
这时,我们回过头来看引入部分的场景,我们是不是可以对原始数据进行 "上锁" 了?
访问器
预期:原始数据无法被更改,并且在数据操作和我的预期有冲突时,"显式的提示" 给编程人员。
为了实现预期效果,上面的四个属性描述符是不够的,还需要认识两个属性描述符的配置:get()
和 set()
,示例如下:
1
2
3
4
Object.defineProperty(obj, 'b', {
get() {}, // 读取器 getter
set() {}, // 设置器 setter
})
get()
和 set()
整体称为访问器。
一旦设置了 get()
,那么将来读取这个被设置了属性描述符的属性时,就不再会去内存中寻找,而是直接运行 get()
函数。也就是说,console.log(obj.a)
等效于 console.log(get())
,打印的就是 get()
函数的运行结果。例如:
1
2
3
4
5
6
7
8
9
Object.defineProperty(obj, 'b', {
get() {
return 'aHa';
}, // 读取器 getter
set() {}, // 设置器 setter
})
console.log('obj.b:', obj.b);
// 这将输出:obj.b: aHa
对于 set()
也是如此,当赋值发生时,逻辑变成直接运行 set()
函数。例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
let tempVal = undefined;
Object.defineProperty(obj, 'b', {
get() {
return tempVal;
}, // 读取器 getter
set(val) {
tempVal = val;
}, // 设置器 setter
})
obj.b = 'vamos';
console.log('obj.b:', obj.b);
// 这将输出:obj.b: vamos
于是,借助 get()
和 set()
就可以实现我们的预期效果了:
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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// 原始对象数据
const goods = {
pic: '/img/1.webp',
title: 'simple title',
desc: 'simple desc',
sellNumber: 1,
favorRate: 20,
price: 20,
};
class Goods {
constructor(goods) {
goods = { ...goods }
Object.freeze(goods) // 冻结对象,防止修改
Object.defineProperty(this, 'data', {
configurable: false,
get() {
return goods;
},
set() {
throw new Error('data 属性只读,不可修改');
},
})
let internalData = 0;
Object.defineProperty(this, 'chooseNum', {
configurable: false,
get() {
return internalData;
},
set(val) {
if (typeof val !== 'number') {
throw new Error('chooseNum 属性必须是数字');
}
if (val < 0) {
throw new Error('chooseNum 属性必须是大于等于0的数字');
}
let tempNum = parseInt(val)
if (tempNum !== val) {
throw new Error('chooseNum 属性必须是整数');
}
internalData = val;
}
})
Object.defineProperty(this, 'totalPrice', {
configurable: false,
get() {
return this.data.price * this.chooseNum;
}
})
this.example = 'example';
Object.seal(this); // 密封对象,防止添加新属性,但可以修改已有属性
}
}
const g = new Goods(goods);
g.data.favorRate = 99; // 无法修改属性
g.chooseNum = 2; // 修改 chooseNum 属性
g.ak = 100; // 无法添加新属性
g.example = 'new example'; // 可以修改属性
console.log('g.data: ', g.data);
console.log('g.chooseNum: ', g.chooseNum);
console.log('g.totalPrice: ', g.totalPrice);
console.log('g.ak: ', g.ak);
console.log('g.example: ', g.example);
/**
* 输出结果:
* g.data: {
* pic: '/img/1.webp',
* title: 'simple title',
* desc: 'simple desc',
* sellNumber: 1,
* favorRate: 20,
* price: 20
* }
* g.chooseNum: 2
* g.totalPrice: 40
* g.ak: undefined
* g.example: new example
*/