# 面向对象-类
之前我们使用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 () {}
}
# 不仅仅是语法糖
- 首先,通过 class 创建的函数具有特殊的内部属性标记
[[IsClassConstructor]]: true
。因此,它与手动创建并不完全相同。与普通函数不同,必须使用 new 来调用它 - 类方法不可枚举。 类定义将 "prototype" 中的所有方法的 enumerable 标志设置为 false。
- 类总是使用 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)而言,内部接口与外部接口的划分被称为 封装。 它具有以下优点:
- 保护用户,使他们不会误伤自己
- 可支持性,可拓展性
- 隐藏复杂性