JavaScript Class 中的 Private 成员

6,326 阅读6分钟

前言

自从 ES6 加入了类的基础语法以来,Javascript 中的 class 已经 5 年没有更新了。早在 2019 年,class-fields 的一系列提案就已经进入了 stage 3,其中就包括给 class 加上 public / private 成员字段,而私有成员由于使用了诡异的语法并且引入天坑无数而备受争议。

浏览器版本帝谷歌已经在 Chrome 84 中实现了私有成员等 class 的新特性。如果这一提案最终入选,相信各大 JS 引擎也只能跟进。到时候私有成员 等 class 新特性恐怕要取代 JS 判等问题成为面试题新宠。

那不如趁现在,了解一下 class 私有成员, 体验一下它如何在面试题中埋坑吧。

基本语法

语法简介

这个看起来像注释一样的 # 号,就是用来声明私有成员和私有方法的。加 # 号是特意为了和普通属性区分开来,普通属性的存取可能需要访问对象的原型链,而私有成员完全使用另外一套检索体系。为了不影响普通属性的访问优化,提案决定在语法层面使用特殊前缀来表示私有成员。

class A {
  name = 'abc'; // public field
  #value = 100;  // private field

  // private method
  #doubleValue() {
      return this.#value * 2;
  }
  
  // private getter
  get #t() { return 1;}

  getValue() {
      return this.#value + this.#doubleValue() + this.#t;
  }

}
class B {
  // 静态私有
  static #p = 1;
  // 用类名访问或 this 都可以
  static getP() {return B.#p;}
}

JS 未引入语义的符号已无几,低头看看键盘就剩下@#了,而 @ 被装饰器语法用掉了,大佬们一拍大腿, # 就你了。大家都知道下划线 _ 一般用作君子协定式私有成员和方法名,如果你把 # 当作 _ 看,似乎也没那么糟糕,吧?

class A {
  name = 'abc'; // public field
  _value = 100;  // 约定下划线开头为私有

  // 君子协定私有方法
  _doubleValue() {
      return this.#value * 2;
  }

  getValue() {
      return this._value + this._doubleValue();
  }
}

const a = new A();
a._value = 200; // 不好意思我是小人

不存在类似 computed property 的 [] 访问方式

私有成员不是对象的 property,所以固然不能用索引名访问。

const name = 'aaa';
class Foo {
  #a = 1;
  getA() {return this['#a']; } // undefined,你只是把 '#a' 当 key 找 property

  [name]() {
    // 这个可以有
  }
  #[name] = 2; // 这个不行
  [#name] = 2; // 目前来说也不行
}

特性

私有性

提问,私有成员的私有是对谁私有?

答,对类私有。这里的私有指的是访问控制,即,类内部能访问,而类外部不能访问,而非拥有者私有(只有拥有者可以读取)。

class Foo {
  #p;
  constructor(p) { this.#p = p; }
  getP() {return this.#p; }
}
const f = new Foo(1);
f.getP(); // 1
f.#p; // error: 私有成员只能在类内部使用

为了辅助理解 对类私有,我们需要另一个例子。说,我们有一个矩形类,规定如果两个矩形长宽都相等我们即认为相等,但是我们又不希望暴露长宽属性,所以打算用私有成员描述长宽。

class Rectangle {
  #width
  #height
  constructor(width, height) {
    this.#width = width;
    this.#height = height;
  }

  area() {
    return this.#width * this.#height;
  }

  compare(other) {
    return this.#width === other.#width &&
           this.#height === other.#height;
  }
}

const a = new Rectangle(10, 5);
const b = new Rectangle(10, 6);
a.compare(b); // no error, returns false

喏,对象 a 其实是可以访问对象 b 的 private 成员的,并非拥有者私有制。#width 被严格限制在 class Rectangle 类的两个花括弧内访问,并且访问前引擎会检查对象是否是当前类的实例。如果不是会怎么样?


const a = new Rectangle(10, 5);
const b = new Triangle(5, 5, 5);
a.compare(b); // Boom!

你会得到一个运行时错误, Cannot read private member from an object whose class did not declare it.

综上,所谓的 private 其实是相对于程序员而言。项目中多人分工合作,我写类来你调用,我声明的公开方法你可以用,私有的东西你别碰。当我需要重构我的类的时候,只要保证对外的行为一致就行,而内部的东西我可以随意增删。

vs 闭包,vs TypeScript Private

闭包是利用作用域来实现隐藏和私有的,加上工厂函数确实可以模拟私有成员,但是这相当于摒弃了继承和原型链带来的优势。类的体系中上下文其实是 this, 闭包没法跟 class 一起协作把私有域共享给整个 class 而不暴露到 class 外部,而且闭包实现的私有是拥有者私有制,你无法用闭包私有实现上述的 compare 函数。

Typescript 中的 private 是编译阶段的 private,它的缺点是运行时对私有成员是不知情的,甚至你如果愿意作死,一招 as any 就可以让你破除这道屏障,底裤都可以扒出来。我不是说 TS 中的私有成员不好,相反其实我更支持一般的项目用 TS 的 private 就够了,其他静态语言还能通过反射之类的操作拿到私有成员呢。我是说你要知道,它确实就是这么一回事。

缺点

ES class 的私有成员其实带来了很多心智负担,普通的属性如果不存在顶多返回一个 undefined,而私有成员直接给你抛运行时异常。

有人说,你上 Typescript 啊,TS 也支持了 ES class 的私有成员。上 TS 的话, TS 本身的 private 绝大部分情况下就够用了,为什么劳驾 ES class 私有成员?

真的觉得运行时私有很重要?那先练练手?

private 试炼

1. Duck Type?

class A {
  #p = 1;
}
class B {
  #p = 2;
  getP(){return this.#p;}
}

const a = new A();
const b = new B();

b.getP.apply(a); //报错么?

2. instance of 能否保障??

class C {
  #p = 1;
  getP() {
    if(this instanceof C) {
      return this.#p;
    }
  }
}

const c = new C();
const d = Object.create(c);
d.getP(); // Boom 不 Boom ??

3. class 是 prototype 的语法糖,所以,这种操作呢?

class D {
  #p = 1;
}
D.prototype.getP = function() { return this.#p; };

const d = new D();
d.getp();

4.搭配继承服用呢?

class Parent {
  #p = 1;
  q = 1;
  getP() { return this.#p; }
  getQ() { return this.q; }
}
class Son extends Parent{
  #p = 2;
  q = 2;

  SON_getP() { return this.#p; }
  SON_getQ() { return this.q; }
}
const son = new Son();
console.log(son.getP()); // 1 or 2?
console.log(son.getQ()); // 1 or 2?
console.log(son.SON_getP()); // 1 or 2?
console.log(son.SON_getQ()); // 1 or 2?

5. 内联 class

class Outer {
  #p = 1;
  get m() {
    const outer = this;
    return new class Inner {
      getP() {return outer.#p;}
    }
  }
}
const o = new Outer();
o.m.getP(); // error?

这个呢?

class Outer {
  #p = 1;
  get m() {
    const outer = this;
    return new class Inner {
      #p = 2;
      getP() {return outer.#p;}
    }
  }
}
const o = new Outer();
o.m.getP(); // error?

6. 配合 Proxy

// 先构造一个 logging handler 拦截所有的 property 访问并 log 记录
const logging = {
  get: function (target, prop, receiver) {
    console.log(`accessing prop: ${prop}`);
    return Reflect.get(...arguments);
  },
};

const proxy1 = new Proxy({a:1, b: 2, c() {return this.a + this.b}}, logging);
proxy1.a; // 打印 accessing prop: a
proxy1.b; // 打印 accessing prop: b
proxy1.c(); // 没问题

// 目前一切正常, 直到。。。
class S {
  #p = 1;
  getP() {return this.#p;}
}
const s = new S();
const proxy2 = new Proxy(s, logging);
proxy2.getP(); //你猜?

本文使用 mdnice 排版