JavaScript 中类的私有与保护成员

JavaScript 是基于对象的语言,但是自始至终,它的类与对象的概念都与以 C++ 为代表的编程语言有较大的区别。class 虽然从最初开始就是关键词,但是也是在最近的 ES6 中才被具体定义的,而关于类成员的访问控制在语法层面则是完全缺失的。从 JavaScript 开始应用于构建复杂交互起,对于如何模拟实现类成员的访问控制就一直是个老生常谈的问题,本文就尝试总结下目前想到的所有可能的模拟方法。

需要特别说明的是,不具备成员访问控制实际不会对应用有多大的影响,大家早已习惯,所有的这些模拟方法都只是代码层面的奇技淫巧而已,并不具备多大的实际使用价值。而真正意义上的 private fields 目前已处于草案阶段(Stage 2),离语法层面支持成员访问控制的日子不远了。

下划线

这个不是具体实现的方法,而是一种普遍的共识、约定俗成的规定:私有/保护成员以下划线开头进行命名。它比后面介绍的所有方法都要有效与简洁,并且借助于静态代码检测工具基本也能保证规则的强制性。

利用闭包

在 JavaScript 流行的初期,有太多针对 JavaScript 另类的类设计思路的抱怨,为此道格拉斯(Douglas Crockford)大神多次澄清,JavaScript 也是有私有成员的,只是和你们想要的不一样罢了,具体的实现就是使用闭包:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Human(age) {
// 私有属性 _age
var _age = age || 28;

// 私有属性的访问方法
// 对外提供访问接口
this.getAge = function () {
return _age;
};
}

var human = new Human(10);
// 10
console.log(human.getAge());

其中 _age 就是私有变量,只存在于构造函数 Human 中,除了可以使用 getAge 方法访问外别无他法,妥妥的私有属性。

这种方式不好的地方在于书写不好看(认真脸)… 所有的私有成员及其需要访问私有成员的方法都必须书写在构造函数中,必定导致构造函数过于臃肿(划重点:超过 50 行的函数都是耍流氓)。当然另外一个教科书式的理由是方法都直接写到对象上了,你让 prototype 情何以堪?如果存在继承的情况,子类想要覆盖这些方法都又要废一番功夫。

最后这个方法只能实现私有成员,无法实现保护成员 … >_<

利用 WeakMap

ES6 中的 WeakMap 可以使用对象作为 key 而且是弱引用,不会造成内存泄漏,可以帮助实现私有成员:

1
2
3
4
5
6
7
8
9
10
11
let privated = new WeakMap();

export default class Human {
constructor(age = 28) {
privated.set(this, age);
}

get age() {
return privated.get(this);
}
}

类的私有成员并不存储在类中,而是保存在一个独立的 WeakMap 中,使用对象自身作为 Key 来存储需要的数据,只要这个 WeakMap 不暴露就能保证数据的私有性。

书写上比闭包的方式好多了,是 constructor 的归 constructor ,是 prototype 的归 prototype,实现简单,唯一的不足就是存取私有成员比较繁琐。除了实现私有成员外,这种方式也能实现保护成员,只要将多个类写在同一个模块中使之能共享同一个 WeakMap 就好,不过这样一来也就破坏了一个模块一个类的单一原则,所以还是只考虑用来实现私有成员的好。

利用 Symbol

只要私有成员不能在类外部被访问到,具体把这些成员存储到哪里都不是问题,甚至就直接放到对象上也OK,只是对应的 Key 让人猜不到就好。利用 Symbol 实现私有成员就是这种方式:

1
2
3
4
5
6
7
8
9
10
11
const KEY_AGE = Symbol('age');

class Human {
constructor(age = 28) {
this[KEY_AGE] = age;
}

get age() {
return this[KEY_AGE];
}
}

这个属性对应的 Key 是一个只在类 Human 所在的模块内才能被访问的 Symbol 对象,外部无从知晓这个 Key,也就无从访问这个属性,自然也就是私有的了。实际上用不用 Symbol 都行,只要隐藏好 Key 就成,不过上面的实现并不完整,虽然外部用户无法访问到这个 Key,但是能通过 Object.getOwnPropertyNamesgetOwnPropertySymbols 之类的方法遍历得到这些 “隐藏” 的 Key,要实现真正意义上的 “隐藏” Key 还需要借助 Proxy 的帮助。

另外与利用 WeakMap 的形式相同,虽然这种方式也能用于实现保护成员,但同样也会违背模块的单一原则。

利用 Proxy

作为元编程(Metaprogramming) 的一种手段,Proxy 可以动态的改变对象或者函数的数据结构和行为,通过它可以实现标准的保护成员。核心思路是控制对象的 GetSet 行为,在具体操作前进行检查,如果需要访问的成员属于保护成员就阻止访问,比如这样(以下示例都只以控制对象成员的获取为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function protectObject(obj) {
return new Proxy(obj, {
// 代理对象的成员访问
// 添加访问前的成员类型检查
get(target, key, receiver) {
// 假设以下划线开头的成员为保护成员
if (key.charAt(0) === '_') {
// 访问保护成员直接抛异常
throw new TypeError(`can't access protected property or method: ${key}`);
}
// 不是保护成员就返回具体的成员
return Reflect.get(target, key, receiver);
}
});
}

let human = protectObject({_age: 28});

// Error ...
console.log(human._age);

如此一来 human_age 就永远无法访问了,啊哈哈 …… 且慢,只是这样的话就真的是完全无法访问,我们想要的是保护成员,在类及其子类中可以访问,外部不能访问,而现在是谁都不能访问… 所以除了从名称上判断访问的成员类型外还要判断访问的路径,是从外部访问的还是从内部访问的?内部访问也就是从类及子类的方法中发起的访问,所以还需要更进一步的对类各个方法的调用进行记录,以便确定访问的路径:

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
function protectObject(obj) {
// 记录类方法的调用情况
// 在调用类的方法之前会对该数据进行 Push 操作
// 结束方法调用后对该数组进行 Pop 操作
// 通过对该数组元素个数据的检查就能确定后续成员的访问是不是来源于类的方法
let inMethods = [];

// 保存改造后的类方法
let methods = new Map();

return new Proxy(obj, {
get(target, key, receiver) {
// 添加类方法调用的检查
if (key.charAt(0) === '_' && !inMethod.length) {
throw new TypeError(`can't access protected property or method: ${key}`);
}

// 如果要访问的成员是已经被改造过的方法就不用再继续后续的操作了
// 直接返回就好
if (methods.has(key)) {
return methods.get(key);
}

// 获取非保护成员
// 由于存在 `Getter` 的情况
// 这里的操作也要视为是从类方法发起的
inMethod.push(true);
let property = Reflect.get(target, key, receiver);
inMethod.pop();

// 对类方法进行改造
if (typeof property === 'function' && !methods.has(key)) {
let newMethod = new Proxy(
property,
{
// 在调用前进行 Push 操作
// 在调用结束后进行 Pop 操作
apply(target, thisArg, args) {
inMethod.push(true);
let res = target.apply(thisArg, args);
inMethod.pop();
return res;
}
}
);

methods.set(key, newMethod);
return newMethod;
}

// 返回非保护成员的属性
return property;
}
});
}

呃,代码瞬间膨胀了… 好在使用上还算简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Human {
constructor(age = 28) {
this._age = age;
}

get age() {
return this._age;
}
}

let human = protectObject(new Human());

// 28
console.log(human.age);
// Error
console.log(human._age);

继承的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Man extends Human {
constructor() {
super();
}

intro() {
return `My age is ${this._age}`;
}
}

let man = protectObject(new Man());
// My age is 28
console.log(man.intro());
// Error
console.log(man._age);

再辅以装饰器(decorators)的配合,perfect ~:

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
function protect(target) {
// 这里换成这样可好?
// return function (...args) {return protectObject(new target(...args));}
return new Proxy(target, {
construct(target, args, newTarget) {
let obj = Reflect.construct(target, args, newTarget);
return protectObject(obj);
}
});
}

@protect
class Human {
constructor(age = 28) {
this._age = age;
}

get age() {
return this._age;
}
}

class Man extends Human {
constructor() {
super();
}

intro() {
return `My age is ${this._age}`;
}
}

let man = new Man();
// My age is 28
console.log(man.intro());
// Error
console.log(man._age);

这种方式在使用上最自然、简洁,感觉上是最好的方式(性能方面还未考察…),不过也只适用于模拟保护成员,对于私有成员就束手无策了。

One more thing … 那啥… 这里的实现有漏洞(我才不说是 Bug 呢),并不能完全保证保护成员不被外部访问,比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Crash extends Human {
constructor() {
super();
}

callback(fn) {
fn();
}
}

let crash = new Crash();
// (>_<)!!!
// output: 28
crash.callback(() => console.log(crash._age));

漏洞出在 protectObject 中判断是否是内部方法发起的访问,目前的判断方式只是近似求解,对于内部方法调用回调参数是无法正确判断的… 更优的方案暂时没想到,回头想到了再补充吧…

总结

还是那句话,目前这些方法都是徒劳,想要访问控制还是乖乖等规范吧 ~