JS中的私有属性和只读属性

3,864 阅读7分钟

什么是私有属性

私有属性是面向对象编程(OOP)中非常常见的一个特性,一般是指能被class内部的不同方法访问,但不能在类外部被访问,大多数语言都是通过public、private、protected 这些访问修饰符来实现访问控制的。

私有属性(方法)的意义很大程度在于将class的内部实现隐藏起来,而对外接口只通过public成员进行暴露,以减少外部对该class内部实现的依赖或修改。

简单地说,对于一个class来说,外部调用方其实只关注部分,不关注class内部的具体实现,也不会使用到一些内部的变量或者方法,这种时候,如果我把所有东西都暴露给外部的话,一方面增加了使用方的理解成本和接受成本,另一方面也增加了class本身的维护成本和被外部破坏的风险。

因此,只暴露和用户交互的接口,其他不交互的部分隐藏为私有的,既可以促进体系运行的可靠程度,(防止外部猪队友疯狂修改你的class),也可以减小使用者的信息负载(我只需要调用一个方法来获取我要的东西,其他的我不管)。

Js中的私有属性

众所周知,JavaScript 中没有 public、private、protected 这些访问修饰符(access modifiers),而且长期以来也没有私有属性这个概念,对象的属性/方法默认都是public的。

这意味着你写一个function或者class,外部其实可以任意访问,任意修改,所以你可以在js中看到很多看起来很hack的写法,从外部修改一个值,莫名的内部的运行逻辑就变化了。这本身是一件非常危险的事,同时对于一个开发而言,怎么能允许,所以通过对逻辑和数据进行一定封装和魔改,JS开发者们走上了曲线实现“私有属性”之路。

自欺欺人型

自欺欺人型,我说他是私有,那么他就是私有,不允许辩驳。或者说约定俗成,以一种不成文的规定,在变量前加上下划线"_"前缀,约定这是一个私有属性;但是实际上这一类属性与正常属性没有任何区别,你在外部仍然可以访问,仅仅是指你在访问是看到了这个前缀,哦,原来这是一个私有,我不该直接访问。

class Person {
  _name;
  constructor(name) {
    this._name = name;
  }

理论上,ts的private修饰符也是这一类,尽管ts实现了private,public等访问修饰符,但是实质只会在编译阶段进行检查,编译后的结果是不会实现访问控制的,也就是运行时是完全没用的。

闭包

闭包是指在 JavaScript 中,内部函数总是可以访问其所在的外部函数中声明的参数和变量,即使在其外部函数被返回(寿命终结)了之后.

基于这个特性,通过创建一个闭包,我们能模拟实现一个私有变量

var Person = function(name){
    var _name = name;
    this.getName = function(){
        return _name;
    };
    this.setName = function(str){
        _name = str;
    };
};
var person = new Person('hugh');

person.name;        // undefined, name是Person函数内的变量,外部无法直接访问
person.getName();   // 'hugh'
person.setName('test'); 

或者 class形式的

class Person {
  constructor(name) {
    // 私有属性
    let _name = name; 
    this.setName = function (name) {
      _name = name;
    };
    this.getName = function () {
      return _name;
    };
  }

  greet() {
    console.log(`hi, i'm ${this.getName()}`);
  }
}

闭包是一个比较好的实现,借助js本身的特性实现了访问控制,但是同样毕竟是个魔改,仍然遗留了一点问题,你需要为每个变量定义getter和setter,否则你甚至无法在class内部获取到整个私有变量,但是当你定义了getter,外部也可以通过这个getter来获取私有变量。

所以闭包实现了你无法直接读取内部的私有属性,同样,在class内部你也无法直接使用这个私有属性。

symbol和weakMap

我们可以知道,实现私有属性,只要是外部无法知道这个属性名,只有内部知道的属性名,就可以做到外部无法访问的特性,基于ES6的新语法symbol和weakMap,我们可以去实现这个能力。

基于symbol

Symbol 是ES6 引入了一种新的原始数据类型,表示独一无二的值,并且可以所谓对象的属性名。一个完全独一无二,并且除了通过变量直接获取,无法被明确表示的属性名。完美。

var Person = (function(){
    const _name = Symbol('name');
    class  Person {
        constructor(name){
            this[_name] = name;
        }
        get name(){
					return this[_name]
        }
    }
    return Person
}())

let person = new Person('hugh');
person.name  // hugh

基于WeakMap

WeakMap也是和symbol一样,是一个把对象作为key的map,所以我们可以把实例本身作为key

var Person = (function(){
    const _name = new WeakMap();
    class  Person {
        constructor(name){
            _name.set(this, name)
        }
        get name(){
						return _name.get(this)
        }
    }
		return Person
}())

let person = new Person('hugh');
person.name  // hugh

class提案

当然好消息是ES2019中已经增加了对 class 私有属性的原生支持,只需要在属性/方法名前面加上 '#' 就可以将其定义为私有,并且支持定义私有的 static 属性/方法,同时我们现在也可以通过 Babel 已使用(babel会把#编译成上面weakMap的形式来实现私有属性),并且 Node v12 中也增加了对私有属性的支持。

class Person {
  // 私有属性
  #name; 

  constructor(name) {
    this.#name = name;
  }
	get name(){
		return this.#name;
  }
}

至于为什么是#,而不是常用的private修饰符,可以看这篇文章 zhuanlan.zhihu.com/p/47166400

只读属性

只读属性与上面私有变量有点类似,逻辑上你只要给你的私有属性增加一个getter,而不增加setter那么他就是一个只读属性。

class Person {
  constructor(name) {
    // 私有属性
    let _name = name; 
    this.name = function () {
      return _name;
    };
  }
}

比较麻烦的是你必须使用getter方法来获取属性,当然我们可以通过class的get来简化这个

class Person {
  // 私有属性
  #name; 

  constructor(name) {
    this.#name = name;
  }
	get name(){
		return this.#name;
  }
}

然而对于简单类型这个就是比较完美的只读属性了,但是对于对象,数组等复杂类型,你仍然可以通过外部去增加属性。

class Person {
  // 私有属性
  #name; 

  constructor() {
    this.#name = {};
  }
	get name(){
		return this.#name;
  }
}

let person  = new Person();
person.name.title = 'hugh';
person.name // {title:'hugh'}

为了让对象类型的属性不可变,我们可以将这个属性freeze

使用Object.freeze()冻结的对象中的现有属性值是不可变的,不可编辑,不可新增。用Object.seal()密封的对象可以改变其现有属性值,但是不可新增。

class Person {
  // 私有属性
  #name; 

  constructor() {
    this.#name = {title:'hugh'};
		Object.freeze(this.#name)
  }
	get name(){
		return this.#name;
  }
}

当你freeze这个属性后,会造成一个问题就是,你在class内部也无法修改这个属性了,所以如果你是希望外部只读,但是会有方法可以修改这个值的话,那么就不可以使用freeze了。

Object.defineProperty与proxy

要设置一个对象的值可读,我们可以用更简单的办法,使用defineProperty,将其writable设为false

var obj = {};
Object.defineProperty( obj, "<属性名>", {
  value: "<属性值>",
  writable: false
});

当然其限制也很大:

  1. 无法阻止整个对象的替换,也就是obj可以被直接赋值
  2. 需要对对象的每个属性进行设置,同时对于新增属性无法生效(除非你在新增的时候再调用一下这个)
  3. 嵌套对象也无法阻止对内部的编辑修改。

对此我们可以使用es6的proxy来进行优化,proxy能实现defineProperty的大多数功能,又没有以上的问题

var obj = {};
const objProxy = new Proxy(obj, {
    get(target,propKey,receiver) {
      return Reflect.get(target, propKey, receiver);
    },
    set() { // 拦截写入属性操作
      console.error('obj is not writeable');
      return true;
    },
  });

对此,我们就不需要关心obj内部属性的新增了(尽管,对于嵌套对象,仍然无法阻止)

基于以上方案,我们可以对一开始的只读属性进行优化

class Person {
  // 私有属性
  #name; 

  constructor() {
    this.#name = {};
  }
	get name(){
		return new Proxy(this.#name, {
		    get(target,propKey,receiver) {
		      return Reflect.get(target, propKey, receiver);
		    },
		    set() { // 拦截写入属性操作
		      console.error('obj is not writeable');
		      return true;
		    },
		  });
  }

  addName(name){
		this.#name[name] = name;
	}
}

let person  = new Person();
person.name.title = 'hugh'; // obj is not writeable
person.addName('hugh')

对于proxy的兼容我们可以引入Google的proxy-polyfill,但是需要注意,proxy-polyfill由于需要遍历对象的所有属性,为每个属性设置defineProperty,所以必须是已知对象的属性,对于对象内新增的属性无法监听,所以proxy-pollyfill seal了target和proxy,已防止你新增属性。参见:

github.com/GoogleChrom…