场景引入

当前有一个对象,你需要对该对象进行封装。举例来说,对象可以是简单的商品对象数据:

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 可以由 datachooseNum 算出来,所以你为了避免将来因为忘记有这回事,更新了选中商品数量却忘记更新总价数据,导致数据不一致的问题出现,选择去除这个会造成数据冗余的属性 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,并且在不注意的情况下,直接更改了 gdata 属性呢?比如:

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 在这方面提供的函数 APIgetOwnPropertyDescriptor

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 提供的函数 APIdefineProperty

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
 */