如何设计ES6中class实现私有属性呢?
为什么会出现 class
其实,学过 java 的小伙伴一定对class
熟悉不过了,那为什么 JS 里面还要引入 class 呢?
在 es6 之前,虽然 JS 和 Java 同样都是 OOP(面向对象)语言,但是在 JS 中,只有对象而没有类的概念。
es6 中class
的出现拉近了 JS 和传统 OOP 语言的距离。但是,它仅仅是一个语法糖罢了,不能实现传统 OOP 语言一样的功能。在其中,比较大的一个痛点就是私有属性问题。
何为私有属性?
私有属性是面向对象编程(OOP)中非常常见的一个特性,一般满足以下的特点:
- 能被
class
内部的不同方法访问,但不能在类外部被访问; - 子类不能继承父类的私有属性。
在 Java 中,可以使用 private
实现私有变量,但是可惜的是, JS 中并没有该功能。
私有属性提案
2015 年 6 月,ES6 发布成为标准,为了纪念这个历史性时刻,这个标准又被称为 ES2015,至此,JavaScript 中的 class 从备胎中转正。但是没有解决私有属性这个问题,产生了一个提案——在属性名之前加上 #
,用于表示私有属性。
class Foo { #a; // 定义私有属性 constructor(a, b) { this.#a = a; this.b = b } }
上述代码私有属性的声明,需要先经过 Babel 等编译器编译后才能正常使用。
至于为什么不用 private
关键字呢?参考大佬说的就是有一大原因是向 Python 靠拢,毕竟从 es6 以来, JS 一直向着 Python 发展。
如何设计实现私有属性呢?
上文我们介绍了 class 出现原因,以及它没有解决私有属性这个问题,那么我们作为 JSer 们,如何自己设计一下呢?带着好奇心来探讨一下吧:
约定命名
目前使用最广的方式:约定命名,既然还没有解决,我们不是可以自己定义一下嘛,对于特殊命名的就把它当做私有属性使用不就可以了吗?大家都遵循这个规范,不就解决这个问题了吗?
/* 约定命名 */ class ClassA { constructor(x) { this._x = x; } getX() { return this._x; } } let classa = new ClassA(1); /* 此时可以访问我们自定义私有属性命名的 _x */ console.log(classa._x); // 1 console.log(classa.getX()); // 1
显然,上述方法简单方便,大家按照规范来就可以了,也比较好阅读他人代码。
闭包
闭包的一个好处就是可以保护内部属性,也是我开头想要实现的一种方式,做法就是将属性定义在 constructor
作用域内,如下代码:
/* 闭包 */ class ClassB { constructor(x) { let _x = x; this.getX = function(){ return _x; } } } let classb = new ClassB(1); /* 此时不可以访问我们自定义私有属性命名的 _x */ console.log(classb._x); // undefined console.log(classb.getX()); // 1
显然,如果私有属性越来越多,那么看起来就很臃肿,对后续维护造成了一定的麻烦,对于他人阅读也是不太友好。同时呢,引用私有变量的方法又不能定义在原型链上。
进阶版闭包
可以通过 IIFE(立即执行函数表达式)建立一个闭包,在其中建立一个变量以及 class,通过 class 引用变量实现私有变量。
/* 进阶版闭包 */ const classC = (function () { let _x; class ClassC { constructor(x) { _x = x; } getX() { return _x; } } return ClassC; })(); let classc = new classC(3); /* 此时不可以访问我们自定义私有属性命名的 _x */ console.log(classc._x); // undefined console.log(classc.getX()); // 3
这种方式就有点 模块化 的思想了
闭包的做法产生的问题?
上述,我们用了闭包和进阶版闭包来解决私有属性这个问题,但是这是有问题的,我们以进阶版闭包为例:
/* 进阶版闭包带来的问题 */ const classC = (function () { let _x; class ClassC { constructor(x) { _x = x; } getX() { return _x; } } return ClassC; })(); let classc1 = new classC(3); /* 此时不可以访问我们自定义私有属性命名的 _x */ console.log(classc1._x); // undefined console.log(classc1.getX()); // 3 /* 问题引出:此时新创建一个实例 */ let classc2 = new classC(4); /* 出现了问题:实例之间会共享变量 */ console.log(classc1.getX()); // 4
从上述代码可以发现,用闭包创建私有变量是不行的,实例之间会共享变量,就好像几个人都实例化了,但是操作地还是同一个属性,这显然是不可取的。
Symbol
利用 Symbol
变量可以作为对象 key
的特点,我们可以模拟实现更真实的私有属性。
/* Symbol */ const classD = (function () { const _x = Symbol('x'); class ClassD { constructor(x) { this[_x] = x; } getX() { return this[_x]; } } return ClassD; })(); let classd = new classD(4); /* 此时不可以访问我们自定义私有属性命名的 _x */ console.log(classd._x); // undefined console.log(classd.getX()); // 4 classd[_x] = 1; console.log(classd[_x]); // ReferenceError: _x is not defined
关于上述代码,
Sysmol 要配合 import/export 模板语法。比如 A.js 里面你定义了 class A 和 Symbol(就用你的写法),对外只暴露 class A。然后在别的 js 文件引入 class A 实例化,拿不到 Symbol 的值,而且无法通过’.’去访问变量名(Symbol 唯一,不暴露外界拿不到)。这样才是私有。
通过模板化的角度,我们对外暴露 ClassD
,Symbol
唯一,不会暴露,外界拿不到,但是这个也不是毫无破绽,看如下代码:
console.log(classd[Object.getOwnPropertySymbols(classd)[0]]); // 4
原来,ES6 的 Object.getOwnPropertySymbols
可以获取 symbol 属性,今天又学到了新东西。
为了解决上述问题,我们又要引出一个新的东西:WeakMap
WeakMap
/* WeakMap */ const classE = (function () { const _x = new WeakMap(); class ClassE { constructor(x) { _x.set(this, x); } getX() { return _x.get(this);; } } return ClassE; })(); let classe = new classE(5); /* 此时不可以访问我们自定义私有属性命名的 _x */ console.log(classe._x); // undefined console.log(classe.getX()); // 5
这种方式就很好解决了私有属性的问题,至于 WeakMap 和 Map 相关知识,我打算在下一篇文章继续探讨,这个知识目前也不算是特别了解,大概了解不能遍历、弱引用这些,可以关注后续的文章。
这里有个问题,如果是要支持多个私有变量的话,这儿用 Map 有没有啥问题呢?
于是我就尝试了一下多个私有变量,先看如下代码:
/* WeakMap */ const classE = (function () { const _x = new WeakMap(); class ClassE { constructor(x, y) { _x.set(this, x); _x.set(this, y); } getX() { return _x.get(this);; } } return ClassE; })(); let classe = new classE(5, 6); /* 此时不可以访问我们自定义私有属性命名的 _x */ console.log(classe.getX()); // 6
诶,发现问题了没有,我们最后输出的只有 _y 这个私有属性,原来出现了覆盖问题,那么该如何解决这个问题呢?
既然私有属性要和实例进行关联,那么是不是可以创建一个包含所有私有属性对应的对象来维护呢?这样所有私有属性就都存储在其中了,也就解决多个私有变量问题啦,同时,这种技术也有好处,就是在遍历属性时或者在执行JSON.stringify
时不会展示出实例的私有属性。
但它依赖于一个放在类外面的可以访问和操作的WeakMap
变量。
const map = new WeakMap(); // 创建一个在每个实例中存储私有变量的对象 const internal = (obj) => { if (!map.has(obj)) { map.set(obj, {}); } return map.get(obj); } class ClassE { constructor(name, age) { internal(this).name = name; internal(this).age = age; } get userInfo() { return '姓名:' + internal(this).name + ',年龄:' + internal(this).age; } } const classe1 = new ClassE('mybj', 18); const classe2 = new ClassE('mybbj123.com', 19); console.log(classe1.userInfo); // 姓名:mybj,年龄:18 console.log(classe2.userInfo); // 姓名:mybbj123.com,年龄:19 /* 无法访问私有属性 */ console.log(classe1.name); // undefined console.log(classe2.age); // undefined
Proxy 代理设置拦截
Proxy 是 JavaScript 中一项美妙的新功能,它将允许你有效地将对象包装在名为 Proxy 的对象中,并拦截与该对象的所有交互。我们将使用 Proxy 并遵照上面的命名约定来创建私有变量,但可以让这些私有变量在类外部访问受限。
Proxy 可以拦截许多不同类型的交互,但我们要关注的是get
和set
,Proxy
允许我们分别拦截对一个属性的读取和写入操作。创建Proxy
时,你将提供两个参数,第一个是打算包裹的实例,第二个是您定义的希望拦截不同方法的“处理器”对象。
我们的处理器将会看起来像是这样:
const handler = { get: function(target, key) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } return target[key]; }, set: function(target, key, value) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } target[key] = value; } };
在每种情况下,我们都会检查被访问的属性的名称是否以下划线开头,如果是的话我们就抛出一个错误从而阻止对它的访问。
通过以上方法保留使用instanceof
的能力(闭包那一块就出现了这个问题),但是此时又有一个新的问题:
当我们尝试执行JSON.stringify
时会出现问题,因为它试图对私有属性进行格式化。为了解决这个问题,我们需要重写toJSON
函数来仅返回“公共的”属性。我们可以通过更新我们的get
处理器来处理toJSON
的特定情况:
注:这将覆盖任何自定义的 toJSON 函数。
get: function(target, key) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } else if (key === 'toJSON') { const obj = {}; for (const key in target) { if (key[0] !== '_') { // 只复制公共属性 obj[key] = target[key]; } } return () => obj; } return target[key]; }
那么我们就可以整合一下代码了:
class Student { constructor(name, age) { this._name = name; this._age = age; } get userInfo() { return '姓名:' + this._name + ',年龄:' + this._age; } } const handler = { get: function (target, key) { if (key[0] === '_') { // 访问私有属性,返回一个 error throw new Error('Attempt to access private property'); } else if (key === 'toJSON') { const obj = {}; for (const key in target) { // 只返回公共属性 if (key[0] !== '_') { obj[key] = target[key]; } } return () => obj; } return target[key]; // 访问公共属性,默认返回 }, set: function (target, key, value) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } target[key] = value; } } const stu = new Proxy(new Student('mybj', 21), handler); console.log(stu.userInfo); // 姓名:mybj,年龄:21 console.log(stu instanceof Student); // true console.log(JSON.stringify(stu)); // "{}" for (const key in stu) { console.log(key); // _name _age }
我们现在已经封闭了我们的私有属性,而预计的功能仍然存在,唯一的警告是我们的私有属性仍然可被遍历。for(const key in stu)
会列出 _name
和 _age
。
为了解决上述私有属性遍历问题,我又想到了可以操作对象属性对应的属性描述符,然后配置 enumerable
,正好 Proxy
可以处理这个问题,它可以拦截对 getOwnPropertyDescriptor
的调用并操作我们的私有属性的输出,代码如下:
getOwnPropertyDescriptor(target, key) { const desc = Object.getOwnPropertyDescriptor(target, key); if (key[0] === '_') { desc.enumerable = false; } return desc; }
详细内容可参考:
Object.getOwnPropertyDescriptor 参考文档
终于,我们迎来了最终完整版本,整合代码如下:
class Student { constructor(name, age) { this._name = name; this._age = age; } get userInfo() { return '姓名:' + this._name + ',年龄:' + this._age; } } const handler = { get: function (target, key) { if (key[0] === '_') { // 访问私有属性,返回一个 error throw new Error('Attempt to access private property'); } else if (key === 'toJSON') { const obj = {}; for (const key in target) { // 只返回公共属性 if (key[0] !== '_') { obj[key] = target[key]; } } return () => obj; } return target[key]; // 访问公共属性,默认返回 }, set: function (target, key, value) { if (key[0] === '_') { throw new Error('Attempt to access private property'); } target[key] = value; }, // 解决私有属性能遍历问题,通过访问属性对应的属性描述符,然后设置 enumerable 为 false getOwnPropertyDescriptor(target, key) { const desc = Object.getOwnPropertyDescriptor(target, key); if (key[0] === '_') { desc.enumerable = false; } return desc; } } const stu = new Proxy(new Student('mybj', 21), handler); console.log(stu.userInfo); // 姓名:mybj,年龄:21 console.log(stu instanceof Student); // true console.log(JSON.stringify(stu)); // "{}" for (const key in stu) { // No output 不能遍历私有属性 console.log(key); } stu._name = 'Lionkk'; // Error: Attempt to access private property
新式做法
就发展趋势来看,TS 已经成为前端必备的技能之一,TypeScript 的 private 很好解决了私有属性这个问题,后续学习了 ts 之后再补充吧。
附:TypeScript 中的处理方式
TypeScript 是 JavaScript 的一个超集,它会编译为原生 JavaScript 用在生产环境。允许指定私有的、公共的或受保护的属性是 TypeScript 的特性之一。
class Student { private name; private age; constructor(name, age) { this.name = name; this.age = age; } get userInfo() { return '姓名:' + this.name + ',年龄:' + this.age; } } const stu = new Student('mybj', 21); console.log(stu.userInfo); // 姓名:mybj,年龄:21
使用 TypeScript 需要注意的重要一点是,它只有在 编译 时才获知这些类型,而私有、公共修饰符在编译时才有效果。如果你尝试访问 stu.name
,你会发现,居然是可以的。只不过 TypeScript 会在编译时给你报出一个错误,但不会停止它的编译。
// 编译时错误:属性 ‘name’ 是私有的,只能在 ‘Student ’ 类中访问。 console.log(stu.name); // 'mybj'
TypeScript 不会自作聪明,不会做任何的事情来尝试阻止代码在运行时访问私有属性。我只把它列在这里,也是让大家意识到它并不能直接解决问题。
另外,TypeScript 的 class
私有变量最终编译也是通过 WeakMap 来实现的。
码云笔记 » 如何设计ES6中class实现私有属性呢?