JavaScript最近有很多改进,新的语法和特性一直在添加。但有些事情不会改变,所有的东西仍然是一个对象,几乎所有的东西都可以在运行时改变,也没有公有/私有属性的概念。但是我们可以用一些技巧来改变这些,在这篇文章中,我将研究实现私有属性的各种方法
JavaScriopt 在 2015 年引入了“类”这种大家都熟悉的面向对象方法,基于 C 语言的经典语言 Java 和 C# 就提供这种方法。不过很快大家就发现这些类并不是习惯的那样 —— 他们没有控制访问的属性修饰符,而且所有属性都必须定义在函数中。
那么,我们该如何保护那些不应该在运行期间被修改的数据呢?先来看一些办法。
这篇文章中我会使用一个创建图形的类作为示例。它的宽度和高度只能高度只能在初始化时设置,同时提供一个用于获取面积的属性。这些示例中用到了 get 关键字,你可以在我的文章 Getter 和 Setter 中了解到这一知识点
第一个方法是使用特定的命名来表示属性应该被视为私有,这是最成熟的方法。其常见作法是给属性名前缀一个下划线(比如 _count)。但这种方法不能阻止值被访问或被修改,它依赖于不同开发者之间的共识,公认这个值应该被禁止访问。
class Shape {
constructor(width, height) {
this._width = width;
this._height = height;
}
get area() {
return this._width * this._height;
}
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(square._width); // 10
WeakMap
使用 WeakMap 保存私有值的方法限制性会稍微强一些。虽然这个方法仍然不能阻止访问数据,但它把私有值与用户交互对象隔离开了。在这种技巧中,我们把拥有私有属性的对象实例作为 WeakMap 的键,并使用一个函数(我们称为 internal)来创建或返回一个存储所有私有属性值的对象。这种技术的优点是在枚举对象属性或者使用 JSON.stringify 时不会把私有属性显示出来,但它依赖 WeakMap,而 WeakMap 对象类的作用域外仍然可以被访问到,也可以进行操作。
const map = new WeakMap();
// Create an object to store private values in per instance
const internal = obj => {
if (!map.has(obj)) {
map.set(obj, {});
}
return map.get(obj);
}
class Shape {
constructor(width, height) {
internal(this).width = width;
internal(this).height = height;
}
get area() {
return internal(this).width * internal(this).height;
}
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(map.get(square)); // { height: 100, width: 100 }
Symbols
Symbol 的用法跟 WeakMap 类似。我们把 Symbol 当作实例属性的键来使用。这种方式也不会在枚举属性和 JSON.stringify 时呈现出来。这个技巧需要为每个私有属性创建 Symbol。不过,只要能访问到这些 Symbol 值,那么在类之外同样也能访问以它们为键的属性。
const widthSymbol = Symbol('width');
const heightSymbol = Symbol('height');
class Shape {
constructor(width, height) {
this[widthSymbol] = width;
this[heightSymbol] = height;
}
get area() {
return this[widthSymbol] * this[heightSymbol];
}
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(square.widthSymbol); // undefined
console.log(square[widthSymbol]); // 10
闭包
前面提到的种种技巧都不能避免从类外部访问私有属性,这一问题可以使用闭包来解决。可以把闭包和 WeakMap 或者 Symbol 一起使用,当然也可以把闭包用于标准的 JavaScript 对象。闭包的原理是将数据封装在函数作用域内,这个作用域在函数调用时创建,从内部返回函数的结果,在外部访问不到。
function Shape() {
// private vars
const this$ = {};
class Shape {
constructor(width, height) {
this$.width = width;
this$.height = height;
}
get area() {
return this$.width * this$.height;
}
}
return new Shape(...arguments);
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(square.width); // undefined
不过这个技巧有一点小问题。假设我们有两个不同的 Shape 对象,代码调了外面那层 Shape(函数),但返回的实例却是内部 Shape(类)的实例。多数情况下可能没什么问题,但是它会导致 square instanceof Shape 返回 false,这就是代码中潜藏的问题。
为了解决这个问题,有一种办法是将外部的 Shape 设置为其返回实例的原型:
return Object.setPrototypeOf(new Shape(...arguments), this);
然而仅更改这一句话还不行,square.area 会变成未定义。get 关键字背后的工作原理是这一问题的根源。我们可以在构造器中手工指定 getter 来解决这个问题。
function Shape() {
// private vars
const this$ = {};
class Shape {
constructor(width, height) {
this$.width = width;
this$.height = height;
Object.defineProperty(this, 'area', {
get: function() {
return this$.width * this$.height;
}
});
}
}
return Object.setPrototypeOf(new Shape(...arguments), this);
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(square.width); // undefined
console.log(square instanceof Shape); // true
另外,我们也可以把 this 设置为实例原型的原型,这样一来,instanceof 和 get 就都没问题了。下面的示例中,我们生成了这样的原型链:Object -> Outer Shape -> Inner Shape Prototype -> Inner Shape。
function Shape() {
// private vars
const this$ = {};
class Shape {
constructor(width, height) {
this$.width = width;
this$.height = height;
}
get area() {
return this$.width * this$.height;
}
}
const instance = new Shape(...arguments);
Object.setPrototypeOf(Object.getPrototypeOf(instance), this);
return instance;
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(square.width); // undefined
console.log(square instanceof Shape); // true
代理
代理是 JavaScript 中非常迷人的新特性,它能有效地把对象封装在称为代理的对象中,由代理拦截所有与该对象的交互。上面我们提到了使用“命名规范”的方法来创建私有属性,现在可以用代理来限制从类外部对私有属性的访问。
代理可以拦截很多不同类型的交互操作,但我们这里重点关注 get 和 set,因为它可以拦截读取或写入属性的动作。创建代理时需要提供两个参数,第一个是要封装的实例,第二个是“处理器”对象,这个对象中定义了你想拦截的各种方法。
我们的处理器对象有点儿像这样:
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;
}
};
这里我们检查每个属性是否以下划线开始,如果是就抛出一个错误来阻止对它的访问。
class Shape {
constructor(width, height) {
this._width = width;
this._height = height;
}
get area() {
return this._width * this._height;
}
}
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;
}
}
const square = new Proxy(new Shape(10, 10), handler);
console.log(square.area); // 100
console.log(square instanceof Shape); // true
square._width = 200; // Error: Attempt to access private property
在这个示例中可以看到,instanceof 有效,所以不会出现什么意想不到的结果。
可惜用 JSON.stringify 时还是会有问题,因为它会尝试将私有属性序列化成字符串。为了避免这个问题,我们需要重载 toJSON 函数来返回“公有”属性。我们通过更新处理器对象来专门处理 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] !== '_') { // Only copy over the public properties
obj[key] = target[key];
}
}
return () => obj;
}
return target[key];
}
现在我们在保留原有功能 同时封装了私有属性,唯一的问题在于私有属性仍然可以枚举出来。for (const key in square) 会把 _width 和_height 列出来。幸好这个问题也可以用处理器来解决!我们可以拦截对 getOwnPropertyDescriptor 的调用,控制对私有属性的输出:
getOwnPropertyDescriptor(target, key) {
const desc = Object.getOwnPropertyDescriptor(target, key);
if (key[0] === '_') {
desc.enumerable = false;
}
return desc;
}
现在把所有代码放在一起:
class Shape {
constructor(width, height) {
this._width = width;
this._height = height;
}
get area() {
return this._width * this._height;
}
}
const handler = {
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];
},
set: function(target, key, value) {
if (key[0] === '_') {
throw new Error('Attempt to access private property');
}
target[key] = value;
},
getOwnPropertyDescriptor(target, key) {
const desc = Object.getOwnPropertyDescriptor(target, key);
if (key[0] === '_') {
desc.enumerable = false;
}
return desc;
}
}
const square = new Proxy(new Shape(10, 10), handler);
console.log(square.area); // 100
console.log(square instanceof Shape); // true
console.log(JSON.stringify(square)); // "{}"
for (const key in square) { // No output
console.log(key);
}
square._width = 200; // Error: Attempt to access private property
在 JavaScript 中创建私有属性的方法中,代理是我目前最喜欢的一种方法。这个方法使用了老派 JS 开发者熟悉的技术,因此它可以应用于现有的旧代码,只需要用同样的代理处理器封装起来就好。
如果你还不知道 TypeScript,我告诉你,TypeScript 是基于类型的 JavaScript 超集,它会编译成普通的 JavaScript。TypeScript 语言允许你指定私有、仅有和受保护的属性。
class Shape {
private width;
private height;
constructor(width, height) {
this.width = width;
this.height = height;
}
get area() {
return this.width * this.height;
}
}
const square = new Shape(10, 10)
console.log(square.area); // 100
使用 TypeScript 要特别注意,它只是在编译期识别类型和私有/公有修饰。如果你尝试访问 square.width 会发现毫无压力。TypeScript 会在编译时报告一个错误,但并不会中止编译。
// Compile time error: Property 'width' is private and only accessible within class 'Shape'.
console.log(square.width); // 10
TypeScript 不会尝试在运行时智能地阻止访问私有属性。我只是把它列在这里,让人们意识到它并不能解决我们所看到的任何问题。你可以自己看看上面的 TypeScript 会生成什么样的 JavaScript。
未来
我已经介绍了目前可以使用的各种方法,但未来会怎样?哇哦,未来似乎很有意思。目前有一个提议为 JavaScript 引入私有字段,使用 # 符号来表示私有属性。它的用法和命名规范技术相似,但会提供确确实实的访问限制。
class Shape {
#height;
#width;
constructor(width, height) {
this.#width = width;
this.#height = height;
}
get area() {
return this.#width * this.#height;
}
}
const square = new Shape(10, 10);
console.log(square.area); // 100
console.log(square instanceof Shape); // true
console.log(square.#width); // Error: Private fields can only be referenced from within a class.
如果你对此感兴趣,可以阅读完整的提议来获得所有细节。我发现有趣的是私有字段需要预先定义而且不能针对性的创建或删除。这是 JavaScript 中让我感到非常奇怪的概念,所以我想看到这一提议接下来的发展。目前该提议主要关注私有属性,没有私有函数,也没有对象字面量中的私有成员,可能以后会有的。
本文来自云栖社区合作伙伴“开源中国”
本文作者:h4cd