# 面向对象-类

之前我们使用ES5的特性来模拟类似于类的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。

为了解决这些问题,ES6 新引入的 class 关键字具有正式定义类的能力。class 是ECMAScript 中新的基础性语法糖结构。

虽然ESMAScript6 表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的任然是原型和构造函数的概念。

# 类的基本使用

# 类定义

与函数类型类似,定义类也有两种主要方式:类声明和类表达式。

// 类声明
class Person {}
// 类表达式
const Animal = class {}

基本用法

class MyClass {
  constructor() { ... }
  method1() { ... }
  method2() { ... }
  method3() { ... }
  // ...
}

然后使用 new MyClass() 来创建具有上述列出的所有方法的新对象。

let myObject = new MyClass();

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必须的。

class A {}

class B {
  constructor () {}
}

class C {
  get myC () {}
}

class D {
  static myD () {}
}

# 不仅仅是语法糖

  1. 首先,通过 class 创建的函数具有特殊的内部属性标记 [[IsClassConstructor]]: true。因此,它与手动创建并不完全相同。与普通函数不同,必须使用 new 来调用它
  2. 类方法不可枚举。 类定义将 "prototype" 中的所有方法的 enumerable 标志设置为 false。
  3. 类总是使用 use strict。 在类构造中的所有代码都将自动进入严格模式。

class User {
  constructor() {}
}

alert(User); // class User { ... }
alert(typeof User); // function
User(); // Error: Class constructor User cannot be invoked without 'new'

此外,class 语法还带来了许多其他功能,我们稍后将会探索它们。

# 类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。

  • 实例化

使用 new 调用类的构造函数会执行如下操作:

  • 在内存中创建一个新对象

  • 这个新对象内部的[[Prototype]]指针被赋值为构造函数 prototype 属性

  • 构造函数内部的 this 被赋值为这个新对象

  • 执行构造函数内部的代码

  • 如果构造函数返回非空对象,则返回该对象;否则自动返回刚创建的新对象。

  • 把类当成特殊函数

ECMAScript 中没有正式的类这个类型。从各方面来看,ECMAScript 类就是一种特殊函数。

class User {
  constructor(name) {
    this.name = name;
  }
  sayHi() {
    alert(this.name);
  }
}

// 佐证:User 是一个函数
alert(typeof User); // function
function User(name) {
  this.name = name;
}
User.prototyoe.sayHi = function () {
  alert(this.name);
};

# 实例、原型和类成员

类的语法可以非常方便地定义存在于实例上的成员、应该存在于原型上的成员,已经应该存在于类本身的成员。

  • 实例成员

每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:

class Person {
  constructor(name) {
    this.name = name
    this.sayName = () => console.log(this.name)
  }
}
let p1 = new Person("tom")
let p2 = new Person("tom")
p1.sayName() // tom
p2.sayName() // tom
console.log(p1.name == p2.name) // false
console.log(p1.sayName == p2.sayName) // false
  • 原型成员与访问器

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

class Person {
  constructor (){
    this.locate = () => console.log("instance")
  }

  // 在类块中定义的内容会在类的原型上
  locate(){
    console.log("prototype")
  }
}
let p = new Person()
p.locate() // instance
Person.prototype.locate() // prototype

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为key

class Person {
  stringKey(){

  }

  ['computed' + 'Key'](){

  }
}

类定义也支持获取和设置访问器。语法和行为跟普通对象一样

class Person {
  set name(newName){
    this._name = newName
  }
  get name(){
    return this._name
  }
}
  • 静态成员

我们可以把一个方法赋值给类的函数本身,而不是赋给它的 "prototype"。这样的方法被称为 静态的(static)。在静态成员中,this 引用类自身。

class User {
  static staticMethod() {
    alert(this === User);
  }
}

User.staticMethod(); // true

这实际上跟直接将其作为属性赋值的作用相同:

class User {}

User.staticMethod = function () {
  alert(this === User);
};

User.staticMethod(); // true
  • 非函数原型和类成员

虽然类定义并不显示支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

class Person{
  sayName(){
    console.log(`${Person.greeting} ${this.name}`)
  }
}
Person.greeting = "My name is";
Person.prototype.name = "Jake"
let p = new Person();
p.sayName() // My name is Jake
  • 迭代器和生成器方法

类定义语法支持在原型和类本身上定义生成器方法

class Person {
  *createNicknameIterator(){
    yield "Jack";
    yield "Jake";
    yield "J-Dog";
  }

  static *createJobIterator(){
    yield "Butcher";
    yield "Baker";
    yield "Candlestick maker"
  }
}

# 总结

class MyClass {
  prop = value; // 属性

  constructor(...) { // 构造器
    // ...
  }

  method(...) {} // method

  get something(...) {} // getter 方法
  set something(...) {} // setter 方法

  [Symbol.iterator]() {} // 有计算名称(computed name)的方法(此处为 symbol)
  // ...
}

技术上来说,MyClass 是一个函数(我们提供作为 constructor 的那个),而 methods、getters 和 settors 都被写入了 MyClass.prototype。

# 私有成员

面向对象编程最重要的原则之一 —— 将内部接口与外部接口分隔开来。

在开发比 “hello world” 应用程序更复杂的东西时,这是“必须”遵守的做法。

# 内部接口和外部接口

在面向对象的编程中,属性和方法分为两组:

  • 内部接口 —— 可以通过该类的其他方法访问,但不能从外部访问的方法和属性。
  • 外部接口 —— 也可以从类的外部访问的方法和属性。

在 JavaScript 中,有两种类型的对象字段(属性和方法):

  • 公共的:可从任何地方访问。它们构成了外部接口。到目前为止,我们只使用了公共的属性和方法。
  • 私有的:只能从类的内部访问。这些用于内部接口。

# 受保护的属性

首先,让我们做一个简单的咖啡机类:

class CoffeeMachine {
  waterAmount = 0; // 内部的水量

  constructor(power) {
    this.power = power;
    alert(`Created a coffee-machine, power: ${power}`);
  }
}

// 创建咖啡机
let coffeeMachine = new CoffeeMachine(100);

// 加水
coffeeMachine.waterAmount = 200;

现在,属性 waterAmount 和 power 是公共的。我们可以轻松地从外部将它们 get/set 成任何值。

让我们将 waterAmount 属性更改为受保护的属性,以对其进行更多控制。

受保护的属性通常以下划线 _ 作为前缀。这不是在语言级别强制实施的,但是程序员之间有一个众所周知的约定,即不应该从外部访问此类型的属性和方法。所以我们的属性将被命名为 _waterAmount

class CoffeeMachine {
  _waterAmount = 0;

  set waterAmount(value) {
    if (value < 0) throw new Error("Negative water");
    this._waterAmount = value;
  }

  get waterAmount() {
    return this._waterAmount;
  }

  constructor(power) {
    this._power = power;
  }
}
// 创建咖啡机
let coffeeMachine = new CoffeeMachine(100);

// 加水
coffeeMachine.waterAmount = -10; // Uncaught Error: Negative water

现在访问已受到控制,因此将水量的值设置为小于零的数将会失败。

# 只读的属性

要让一个属性只读,很简单,要做到这一点,我们只需要设置 getter,而不设置 setter

class CoffeeMachine {
  constructor(power) {
    this._power = power;
  }

  get power() {
    return this._power;
  }
}

// 创建咖啡机
let coffeeMachine = new CoffeeMachine(100);

alert(`Power is: ${coffeeMachine.power}W`); // 功率是:100W

coffeeMachine.power = 25; // Error(没有 setter)

# 私有的属性

私有属性和方法应该以 # 开头。它们只在类的内部可被访问。

class CoffeeMachine {
  #waterLimit = 200;

  #checkWater(value) {
    if (value < 0) throw new Error("Negative water");
    if (value > this.#waterLimit) throw new Error("Too much water");
  }
}

let coffeeMachine = new CoffeeMachine();

// 不能从类的外部访问类的私有属性和方法
coffeeMachine.#checkWater(); // Uncaught SyntaxError: Private field '#checkWater'
coffeeMachine.#waterLimit = 1000; // Error

在语言级别,# 是该字段为私有的特殊标志。我们无法从外部或从继承的类中访问它。

私有字段与公共字段不会发生冲突。我们可以同时拥有私有的 #waterAmount 和公共的 waterAmount 字段。

与受保护的字段不同,受保护字段是我们手动实现的而私有字段由语言本身强制执行。

# 总结

就面向对象编程(OOP)而言,内部接口与外部接口的划分被称为 封装。 它具有以下优点:

  • 保护用户,使他们不会误伤自己
  • 可支持性,可拓展性
  • 隐藏复杂性