JavaScript中的Class

JavaScript中的类

一、基本语法

基本语法:

class MyClass {
  prop = value; // 属性

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

  method(...) {} // method

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

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

new MyClass的调用流程:

  • 一个新对象被创建
  • constructor 使用给定的参数运行

类的方法之间没有逗号。不要与对象字面量相混淆。

关系解释:

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

// JavaScript 中,class 是一个函数
alert(typeof User); // function

// ...或者,更确切地说,是 constructor 方法
alert(User === User.prototype.constructor); // true

// 方法在 User.prototype 中,例如:
alert(User.prototype.sayHi); // alert(this.name);

// 在原型中实际上有两个方法
alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi

类不仅仅是语法糖,它与函数之间存在着重大差异:

  1. 通过 class 创建的函数具有特殊的内部属性标记 [[IsClassConstructor]]: true。编程语言会在许多地方检查该属性。例如,与普通函数不同,必须使用 new 来调用它:
class User {
  constructor() {}
}

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

此外,大多数 JavaScript 引擎中的类构造器的字符串表示形式都以 “class…” 开头:

class User {
  constructor() {}
}

alert(User); // class User { ... }
  1. 类方法不可枚举。 类定义将 "prototype" 中的所有方法的 enumerable 标志设置为 false。这很好,因为如果我们对一个对象调用 for..in 方法,我们通常不希望 class 方法出现。

  2. 类总是使用 use strict。 在类构造中的所有代码都将自动进入严格模式。

类表达式

就像函数一样,类可以在另外一个表达式中被定义,被传递,被返回,被赋值等。

这是一个类表达式的例子:

let User = class {
  sayHi() {
    alert("Hello");
  }
};

甚至可以动态地“按需”创建类,就像这样:

function makeClass(phrase) {
  // 声明一个类并返回它
  return class {
    sayHi() {
      alert(phrase);
    }
  };
}
// 创建一个新的类
let User = makeClass("Hello");
new User().sayHi(); // Hello

Getters/setters

这样的类声明可以通过在 User.prototype 中创建 getters 和 setters 来实现:

class User {
  constructor(name) {
    // 调用 setter
    this.name = name;
  }
  get name() {
    return this._name;
  }
  set name(value) {
    if (value.length < 4) {
      alert("Name is too short.");
      return;
    }
    this._name = value;
  }
}

计算属性名称 […]

类似于对象字面量:

class User {
  ['say' + 'Hi']() {
    alert("Hello");
  }
}
new User().sayHi();

Class 字段

“类字段”是一种允许添加任何属性的语法。

类字段重要的不同之处在于,它们会在每个独立对象中被设好,而不是设在 User.prototype:

class User {
  name = "John";
}

let user = new User();
alert(user.name); // John
alert(User.prototype.name); // undefined

也可以在赋值时使用更复杂的表达式和函数调用:

class User {
  name = prompt("Name, please?", "John");
}

let user = new User();
alert(user.name); // John

使用类字段制作绑定方法

如果一个对象方法被传递到某处,或者在另一个上下文中被调用,则 this 将不再是对其对象的引用。

例如,此代码将显示 undefined:

class Button {
  constructor(value) {
    this.value = value;
  }

  click() {
    alert(this.value);
  }
}

let button = new Button("hello");

setTimeout(button.click, 1000); // undefined

这个问题被称为“丢失 this”。有两种可以修复它的方式:

  • 传递一个包装函数,例如 setTimeout(() => button.click(), 1000)。
  • 将方法绑定到对象,例如在 constructor 中。

类字段提供了另一种非常优雅的语法:

class Button {
  constructor(value) {
    this.value = value;
  }
  click = () => {
    alert(this.value);
  }
}

let button = new Button("hello");
setTimeout(button.click, 1000); // hello

类字段 click = () => {...} 是基于每一个对象被创建的,在这里对于每一个 Button 对象都有一个独立的方法,在内部都有一个指向此对象的 this。我们可以把 button.click 传递到任何地方,而且 this 的值总是正确的。

在浏览器环境中,它对于进行事件监听尤为有用。

二、继承

继承是一个类扩展另一个类,在现有功能之上创建新功能。

“extends” 关键字使用了很好的旧的原型机制进行工作。

在 extends 后允许任意表达式:类语法不仅允许指定一个类,在 extends 后可以指定任意表达式。

例如,一个生成父类的函数调用:

function f(phrase) {
  return class {
    sayHi() { alert(phrase); }
  };
}

class User extends f("Hello") {}

new User().sayHi(); // Hello

对于高级编程模式,例如当我们根据许多条件使用函数生成类,并继承它们时来说可能很有用。

重写方法

Class 为此提供了 "super" 关键字。

  • 执行 super.method(...) 来调用一个父类方法。
  • 执行 super(...) 来调用一个父类 constructor(只能在我们的 constructor 中)。

例如,让我们的 rabbit 在停下来的时候自动 hide:

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  run(speed) {
    this.speed = speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }
  stop() {
    this.speed = 0;
    alert(`${this.name} stands still.`);
  }
}

class Rabbit extends Animal {
  hide() {
    alert(`${this.name} hides!`);
  }
  stop() {
    super.stop(); // 调用父类的 stop
    this.hide(); // 然后 hide
  }
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit 以速度 5 奔跑
rabbit.stop(); // White Rabbit 停止了。White rabbit hide 了!

箭头函数没有 super,箭头函数没有this。如果被访问,它会从外部函数获取。

重写 constructor

class Rabbit extends Animal {
  // 为没有自己的 constructor 的扩展类生成的
  constructor(...args) {
    super(...args);
  }
}

它调用了父类的 constructor,并传递了所有的参数。如果我们没有写自己的 constructor,就会出现这种情况。

继承类的 constructor 必须调用 super(...),并且 (!) 一定要在使用 this 之前调用。

在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性 [[ConstructorKind]]:"derived"。这是一个特殊的内部标签。该标签会影响它的 new 行为:

  • 当通过 new 执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给 this。
  • 但是当继承的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作

因此,派生的 constructor 必须调用 super 才能执行其父类(base)的 constructor,否则 this 指向的那个对象将不会被创建。并且我们会收到一个报错。

重写类字段: 一个棘手的注意要点

高阶要点

当我们访问在父类构造器中的一个被重写的字段时,这里会有一个诡异的行为,这与绝大多数其他编程语言都很不一样。请思考此示例:

class Animal {
  name = 'animal';

  constructor() {
    alert(this.name); // (*)
  }
}

class Rabbit extends Animal {
  name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal

这里,Rabbit 继承自 Animal,并且用它自己的值重写了 name 字段。

因为 Rabbit 中没有自己的构造器,所以 Animal 的构造器被调用了。

有趣的是在这两种情况下:new Animal() 和 new Rabbit(),在 (*) 行的 alert 都打印了 animal。

换句话说, 父类构造器总是会使用它自己字段的值,而不是被重写的那一个。

古怪的是什么呢?

如果这还不清楚,那么让我们用方法来进行比较。

这里是相同的代码,但是我们调用 this.showName() 方法而不是 this.name 字段:

class Animal {
  showName() {  // 而不是 this.name = 'animal'
    alert('animal');
  }

  constructor() {
    this.showName(); // 而不是 alert(this.name);
  }
}

class Rabbit extends Animal {
  showName() {
    alert('rabbit');
  }
}

new Animal(); // animal
new Rabbit(); // rabbit

请注意:这时的输出是不同的。

这才是我们本来所期待的结果。当父类构造器在派生的类中被调用时,它会使用被重写的方法。

……但对于类字段并非如此。正如前文所述,父类构造器总是使用父类的字段。

这里为什么会有这样的区别呢?

实际上,原因在于字段初始化的顺序。类字段是这样初始化的:

对于基类(还未继承任何东西的那种),在构造函数调用前初始化。
对于派生类,在 super() 后立刻初始化。
在我们的例子中,Rabbit 是派生类,里面没有 constructor()。正如先前所说,这相当于一个里面只有 super(...args) 的空构造器。

所以,new Rabbit() 调用了 super(),因此它执行了父类构造器,并且(根据派生类规则)只有在此之后,它的类字段才被初始化。在父类构造器被执行的时候,Rabbit 还没有自己的类字段,这就是为什么 Animal 类字段被使用了。

这种字段与方法之间微妙的区别只特定于 JavaScript。

幸运的是,这种行为仅在一个被重写的字段被父类构造器使用时才会显现出来。接下来它会发生的东西可能就比较难理解了,所以我们要在这里对此行为进行解释。

如果出问题了,我们可以通过使用方法或者 getter/setter 替代类字段,来修复这个问题。

深入:内部探究和 [[HomeObject]]

让我们问问自己,以技术的角度它是如何工作的?当一个对象方法执行时,它会将当前对象作为 this。随后如果我们调用 super.method(),那么引擎需要从当前对象的原型中获取 method。但这是怎么做到的?

这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的 this,所以它可以获取父 method 作为 this.proto.method。不幸的是,这个“天真”的解决方法是行不通的。

让我们演示一下这个问题。简单起见,我们使用普通对象而不使用类。

如果你不想知道更多的细节知识,你可以跳过此部分,并转到下面的 [[HomeObject]] 小节。这没关系的。但如果你感兴趣,想学习更深入的知识,那就继续阅读吧。

在下面的例子中,rabbit.proto = animal。现在让我们尝试一下:在 rabbit.eat() 我们将会使用 this.proto 调用 animal.eat():

let animal = {
  name: "Animal",
  eat() {
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {
    // 这就是 super.eat() 可以大概工作的方式
    this.__proto__.eat.call(this); // (*)
  }
};

rabbit.eat(); // Rabbit eats.

在 (*) 这一行,我们从原型(animal)中获取 eat,并在当前对象的上下文中调用它。请注意,.call(this) 在这里非常重要,因为简单的调用 this.proto.eat() 将在原型的上下文中执行 eat,而非当前对象。

在上面的代码中,它确实按照了期望运行:我们获得了正确的 alert。

现在,让我们在原型链上再添加一个对象。我们将看到这件事是如何被打破的:

let animal = {
  name: "Animal",
  eat() {
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  eat() {
    // ...bounce around rabbit-style and call parent (animal) method
    this.__proto__.eat.call(this); // (*)
  }
};

let longEar = {
  __proto__: rabbit,
  eat() {
    // ...do something with long ears and call parent (rabbit) method
    this.__proto__.eat.call(this); // (**)
  }
};

longEar.eat(); // Error: Maximum call stack size exceeded

代码无法再运行了!我们可以看到,在试图调用 longEar.eat() 时抛出了错误。

原因可能不那么明显,但是如果我们跟踪 longEar.eat() 调用,就可以发现原因。在 (*) 和 (**) 这两行中,this 的值都是当前对象(longEar)。这是至关重要的一点:所有的对象方法都将当前对象作为 this,而非原型或其他什么东西。

因此,在 (*) 和 (**) 这两行中,this.proto 的值是完全相同的:都是 rabbit。它们俩都调用的是 rabbit.eat,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。

这张图介绍了发生的情况:

  1. 在 longEar.eat() 中,(**) 这一行调用 rabbit.eat 并为其提供 this=longEar。
// 在 longEar.eat() 中我们有 this = longEar
this.__proto__.eat.call(this) // (**)
// 变成了
longEar.__proto__.eat.call(this)
// 也就是
rabbit.eat.call(this);
  1. 之后在 rabbit.eat 的 (*) 行中,我们希望将函数调用在原型链上向更高层传递,但是 this=longEar,所以 this.proto.eat 又是 rabbit.eat!
// 在 rabbit.eat() 中我们依然有 this = longEar
this.__proto__.eat.call(this) // (*)
// 变成了
longEar.__proto__.eat.call(this)
// 或(再一次)
rabbit.eat.call(this);
  1. ……所以 rabbit.eat 在不停地循环调用自己,因此它无法进一步地提升。

这个问题没法仅仅通过使用 this 来解决。

[[HomeObject]]

为了提供解决方法,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]]。

当一个函数被定义为类或者对象方法时,它的 [[HomeObject]] 属性就成为了该对象。

然后 super 使用它来解析(resolve)父原型及其方法。

让我们看看它是怎么工作的,首先,对于普通对象:

let animal = {
  name: "Animal",
  eat() {         // animal.eat.[[HomeObject]] == animal
    alert(`${this.name} eats.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "Rabbit",
  eat() {         // rabbit.eat.[[HomeObject]] == rabbit
    super.eat();
  }
};

let longEar = {
  __proto__: rabbit,
  name: "Long Ear",
  eat() {         // longEar.eat.[[HomeObject]] == longEar
    super.eat();
  }
};

// 正确执行
longEar.eat();  // Long Ear eats.

它基于 [[HomeObject]] 运行机制按照预期执行。一个方法,例如 longEar.eat,知道其 [[HomeObject]] 并且从其原型中获取父方法。并没有使用 this。

方法并不是“自由”的

正如我们之前所知道的,函数通常都是“自由”的,并没有绑定到 JavaScript 中的对象。正因如此,它们可以在对象之间复制,并用另外一个 this 调用它。

[[HomeObject]] 的存在违反了这个原则,因为方法记住了它们的对象。[[HomeObject]] 不能被更改,所以这个绑定是永久的。

在 JavaScript 语言中 [[HomeObject]] 仅被用于 super。所以,如果一个方法不使用 super,那么我们仍然可以视它为自由的并且可在对象之间复制。但是用了 super 再这样做可能就会出错。

下面是复制后错误的 super 结果的示例:

let animal = {
  sayHi() {
    alert(`I'm an animal`);
  }
};

// rabbit 继承自 animal
let rabbit = {
  __proto__: animal,
  sayHi() {
    super.sayHi();
  }
};

let plant = {
  sayHi() {
    alert("I'm a plant");
  }
};

// tree 继承自 plant
let tree = {
  __proto__: plant,
  sayHi: rabbit.sayHi // (*)
};

tree.sayHi();  // I'm an animal (?!?)

调用 tree.sayHi() 显示 “I’m an animal”。这绝对是错误的。

原因很简单:

  • 在 (*) 行,tree.sayHi 方法是从 rabbit 复制而来。也许我们只是想避免重复代码?
  • 它的 [[HomeObject]] 是 rabbit,因为它是在 rabbit 中创建的。没有办法修改 [[HomeObject]]。
  • tree.sayHi() 内具有 super.sayHi()。它从 rabbit 中上溯,然后从 animal 中获取方法。

这是发生的情况示意图:

方法,不是函数属性

[[HomeObject]] 是为类和普通对象中的方法定义的。但是对于对象而言,方法必须确切指定为 method(),而不是 "method: function()"。

这个差别对我们来说可能不重要,但是对 JavaScript 来说却非常重要。

在下面的例子中,使用非方法(non-method)语法进行了比较。未设置 [[HomeObject]] 属性,并且继承无效:

let animal = {
  eat: function() { // 这里是故意这样写的,而不是 eat() {...
    // ...
  }
};

let rabbit = {
  __proto__: animal,
  eat: function() {
    super.eat();
  }
};

rabbit.eat();  // 错误调用 super(因为这里没有 [[HomeObject]])

总结:方法在内部的 [[HomeObject]] 属性中记住了它们的类/对象。这就是 super 如何解析父方法的。
因此,将一个带有 super 的方法从一个对象复制到另一个对象是不安全的。

三、静态属性、静态方法

语法:

class MyClass {
  static property = ...;

  static method() {
    ...
  }
}

等于:

MyClass.property = ...
MyClass.method = ...

静态的(static),可以把一个方法赋值给类的函数本身,而不是赋给它的 "prototype"。

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

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

class User { }

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

User.staticMethod(); // true

通常,静态方法用于实现属于该类但不属于该类任何特定对象的函数。

例如,我们有对象 Article,并且需要一个方法来比较它们。一个自然的解决方案就是添加 Article.compare 方法,像下面这样:

class Article {
  constructor(title, date) {
    this.title = title;
    this.date = date;
  }

  static compare(articleA, articleB) {
    return articleA.date - articleB.date;
  }
}

// 用法
let articles = [
  new Article("HTML", new Date(2019, 1, 1)),
  new Article("CSS", new Date(2019, 0, 1)),
  new Article("JavaScript", new Date(2019, 11, 1))
];

articles.sort(Article.compare);
alert( articles[0].title ); // CSS

静态的属性也是可能的,它们看起来就像常规的类属性,但前面加有 static:

另一个例子是所谓的“工厂factory”方法。想象一下,我们需要通过几种方法来创建一个文章:

  • 通过用给定的参数来创建(title,date 等)。可以通过 constructor 来实现
  • 使用今天的日期来创建一个空的文章。可以创建类的一个静态方法来实现。
  • ……其它方法。
class Article {
  constructor(title, date) {
    this.title = title;
    this.date = date;
  }

  static createTodays() {
    // 记住 this = Article
    return new this("Today's digest", new Date());
  }
}
let article = Article.createTodays();
alert( article.title ); // Today's digest

静态方法也被用于与数据库相关的公共类,可以用于搜索/保存/删除数据库中的条目, 就像这样:

// 假定 Article 是一个用来管理文章的特殊类
// 静态方法用于移除文章:
Article.remove({id: 12345});

静态属性

静态的属性也是可能的,它们看起来就像常规的类属性,但前面加有 static,等同于直接给类赋值。

继承静态属性和方法

class Animal {
  static planet = "Earth";
  constructor(name, speed) {
    this.speed = speed;
    this.name = name;
  }
  run(speed = 0) {
    this.speed += speed;
    alert(`${this.name} runs with speed ${this.speed}.`);
  }
  static compare(animalA, animalB) {
    return animalA.speed - animalB.speed;
  }
}

// 继承于 Animal
class Rabbit extends Animal {
  hide() {
    alert(`${this.name} hides!`);
  }
}
let rabbits = [
  new Rabbit("White Rabbit", 10),
  new Rabbit("Black Rabbit", 5)
];
rabbits.sort(Rabbit.compare);
rabbits[0].run(); // Black Rabbit runs with speed 5.
alert(Rabbit.planet); // Earth

现在我们调用 Rabbit.compare 时,继承的 Animal.compare 将会被调用。

它是如何工作的?再次,使用原型。你可能已经猜到了,extends 让 Rabbit 的 [[Prototype]] 指向了 Animal。

所以,Rabbit extends Animal 创建了两个 [[Prototype]] 引用:

  1. Rabbit 函数原型继承自 Animal 函数。
  2. Rabbit.prototype 原型继承自 Animal.prototype。

继承对常规方法和静态方法都有效。

这里,让我们通过代码来检验一下:

class Animal {}
class Rabbit extends Animal {}

// 对于静态的
alert(Rabbit.__proto__ === Animal); // true

// 对于常规方法
alert(Rabbit.prototype.__proto__ === Animal.prototype); // true

一个有意思的问题:类扩展自对象?

正如我们所知道的,所有的对象通常都继承自 Object.prototype,并且可以访问“通用”对象方法,例如 hasOwnProperty 等。例如:

class Rabbit {
  constructor(name) {
    this.name = name;
  }
}
let rabbit = new Rabbit("Rab");
// hasOwnProperty 方法来自于 Object.prototype
alert( rabbit.hasOwnProperty('name') ); // true

但是,如果我们像这样 "class Rabbit extends Object" 把它明确地写出来,那么结果会与简单的 "class Rabbit" 有所不同么?

不同之处在哪里?

下面是此类的示例代码(它无法正常运行 — 为什么?修复它?):

class Rabbit extends Object {
  constructor(name) {
    this.name = name;
  }
}
let rabbit = new Rabbit("Rab");
alert( rabbit.hasOwnProperty('name') ); // Error

首先,让我们看看为什么之前的代码无法运行。

如果我们尝试运行它,就会发现原因其实很明显。派生类的 constructor 必须调用 super()。否则 "this" 不会被定义。

下面是修复后的代码:

class Rabbit extends Object {
  constructor(name) {
    super(); // 需要在继承时调用父类的 constructor
    this.name = name;
  }
}
let rabbit = new Rabbit("Rab");
alert( rabbit.hasOwnProperty('name') ); // true

但这还不是全部原因。

即便修复了它,"class Rabbit extends Object" 和 class Rabbit 之间仍存在着重要差异。

我们知道,“extends” 语法会设置两个原型:

  1. 在构造函数的 "prototype" 之间设置原型(为了获取实例方法)。
  2. 在构造函数之间会设置原型(为了获取静态方法)。

在我们的例子里,对于 class Rabbit extends Object,它意味着:

class Rabbit extends Object {}

alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
alert( Rabbit.__proto__ === Object ); // (2) true

所以,现在 Rabbit 可以通过 Rabbit 访问 Object 的静态方法,像这样:

class Rabbit extends Object {}

// 通常我们调用 Object.getOwnPropertyNames
alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // a,b

但是如果我们没有 extends Object,那么 Rabbit.proto 将不会被设置为 Object。

下面是示例:

class Rabbit {}

alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
alert( Rabbit.__proto__ === Object ); // (2) false (!)
alert( Rabbit.__proto__ === Function.prototype ); // true,所有函数都是默认如此

// error,Rabbit 中没有这样的函数
alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // Error

所以,在这种情况下,Rabbit 没有提供对 Object 的静态方法的访问。

顺便说一下,Function.prototype 有一些“通用”函数方法,例如 call 和 bind 等。在上述的两种情况下它们都是可用的,因为对于内建的 Object 构造函数而言,Object.proto === Function.prototype。

我们用一张图来解释:

所以,简而言之,这里有两点区别:

class Rabbit class Rabbit extends Object
needs to call super() in constructor
Rabbit.proto === Function.prototype Rabbit.proto === Object

四、私有的、受保护的属性和方法

内部接口和外部接口

私有的:只能从类的内部访问。这些用于内部接口。

受保护的 “waterAmount”

做一个简单的咖啡机类:

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

  constructor(power) {
    this.power = power;
    alert( `Created a coffee-machine, power: ${power}` );
  }
}
// 创建咖啡机
let coffeeMachine = new CoffeeMachine(100);
// 加水
coffeeMachine.waterAmount = 200;

受保护的属性通常以下划线 _ 作为前缀。一个众所周知的约定.

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; // Error: Negative water

只读的 “power”

对于 power 属性,让我们将它设为只读。有时候一个属性必须只能被在创建时进行设置,之后不再被修改。

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)

这里我们使用了 getter/setter 语法。但大多数时候首选 get.../set... 函数,像这样:

class CoffeeMachine {
  _waterAmount = 0;

  setWaterAmount(value) {
    if (value < 0) throw new Error("Negative water");
    this._waterAmount = value;
  }
  getWaterAmount() {
    return this._waterAmount;
  }
}
new CoffeeMachine().setWaterAmount(100);

私有的 “#waterLimit”

这儿有一个马上就会被加到规范中的已完成的 Javascript 提案,它为私有属性和方法提供语言级支持。

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

例如,这儿有一个私有属性 #waterLimit 和检查水量的私有方法 #checkWater:

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(); // Error
coffeeMachine.#waterLimit = 1000; // Error

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

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

例如,让我们使 waterAmount 成为 #waterAmount 的一个访问器:

class CoffeeMachine {

  #waterAmount = 0;

  get waterAmount() {
    return this.#waterAmount;
  }

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

let machine = new CoffeeMachine();

machine.waterAmount = 100;
alert(machine.#waterAmount); // Error

与受保护的字段不同,私有字段由语言本身强制执行。这是好事儿。

但是如果我们继承自 CoffeeMachine,那么我们将无法直接访问 #waterAmount。我们需要依靠 waterAmount getter/setter。在许多情况下,这种限制太严重了。如果我们扩展 CoffeeMachine,则可能有正当理由访问其内部。这就是为什么大多数时候都会使用受保护字段,即使它们不受语言语法的支持。

私有字段不能通过 this[name] 访问私有字段很特别。正如我们所知道的,通常我们可以使用 this[name] 访问字段。对于私有字段来说,这是不可能的:this['#name'] 不起作用。这是确保私有性的语法限制。

五、扩展内建类

内建的类,例如 Array,Map 等也都是可以扩展的(extendable)。

例如,这里有一个继承自原生 Array 的类 PowerArray:

// 给 PowerArray 新增了一个方法(可以增加更多)
class PowerArray extends Array {
  isEmpty() {
    return this.length === 0;
  }
}

let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false

let filteredArr = arr.filter(item => item >= 10);
alert(filteredArr); // 10, 50
alert(filteredArr.isEmpty()); // false

请注意一个非常有趣的事儿。内建的方法例如 filter,map 等 — 返回的正是子类 PowerArray 的新对象。它们内部使用了对象的 constructor 属性来实现这一功能。

在上面的例子中,arr.constructor === PowerArray

当 arr.filter() 被调用时,它的内部使用的是 arr.constructor 来创建新的结果数组,而不是使用原生的 Array。这真的很酷,因为我们可以在结果数组上继续使用 PowerArray 的方法。

甚至,我们可以定制这种行为。

我们可以给这个类添加一个特殊的静态 getter Symbol.species。如果存在,则应返回 JavaScript 在内部用来在 map 和 filter 等方法中创建新实体的 constructor。

如果我们希望像 map 或 filter 这样的内建方法返回常规数组,我们可以在 Symbol.species 中返回 Array,就像这样:

class PowerArray extends Array {
  isEmpty() {
    return this.length === 0;
  }

  // 内建方法将使用这个作为 constructor
  static get [Symbol.species]() {
    return Array;
  }
}

let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false

// filter 使用 arr.constructor[Symbol.species] 作为 constructor 创建新数组
let filteredArr = arr.filter(item => item >= 10);

// filteredArr 不是 PowerArray,而是 Array
alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function

正如你所看到的,现在 .filter 返回 Array。所以扩展的功能不再传递。其他集合,例如 Map 和 Set 的工作方式类似。它们也使用 Symbol.species。

内建类没有静态方法继承

内建对象有它们自己的静态方法,例如 Object.keys,Array.isArray 等。

如我们所知道的,原生的类互相扩展。例如,Array 扩展自 Object。

通常,当一个类扩展另一个类时,静态方法和非静态方法都会被继承。

但内建类却是一个例外。它们相互间不继承静态方法。

例如,Array 和 Date 都继承自 Object,所以它们的实例都有来自 Object.prototype 的方法。但 Array.[[Prototype]] 并不指向 Object,所以它们没有例如 Array.keys()(或 Date.keys())这些静态方法。

这里有一张 Date 和 Object 的结构关系图:

正如你所看到的,Date 和 Object 之间没有连结。它们是独立的,只有 Date.prototype 继承自 Object.prototype,仅此而已。

与我们所了解的通过 extends 获得的继承相比,这是内建对象之间继承的一个重要区别。

六、类检查 instanceOf 操作符

instanceof 操作符用于检查一个对象是否属于某个特定的 class。同时,它还考虑了继承。

在许多情况下,可能都需要进行此类检查。例如,它可以被用来构建一个 多态性(polymorphic) 的函数,该函数根据参数的类型对参数进行不同的处理。

语法:obj instanceof Class

如果 obj 隶属于 Class 类(或 Class 类的衍生类),则返回 true。

可以与构造函数一起使用。new Rabbit() instanceof Rabbit

let arr = [1, 2, 3];
alert( arr instanceof Array ); // true
alert( arr instanceof Object ); // true

arr 同时还隶属于 Object 类。因为从原型上来讲,Array 是继承自 Object 的。instanceof 在检查中会将原型链考虑在内。此外,我们还可以在静态方法 Symbol.hasInstance 中设置自定义逻辑。

obj instanceof Class 算法的执行过程大致如下:

  1. 如果这儿有静态方法 Symbol.hasInstance,那就直接调用这个方法:
// 设置 instanceOf 检查
// 并假设具有 canEat 属性的都是 animal
class Animal {
  static [Symbol.hasInstance](obj) {
    if (obj.canEat) return true;
  }
}

let obj = { canEat: true };
alert(obj instanceof Animal); // true:Animal[Symbol.hasInstance](obj) 被调用
  1. 大多数 class 没有 Symbol.hasInstance。在这种情况下,标准的逻辑是:使用 obj instanceOf Class 检查 Class.prototype 是否等于 obj 的原型链中的原型之一。

换句话说就是,一个接一个地比较:

obj.__proto__ === Class.prototype?
obj.__proto__.__proto__ === Class.prototype?
obj.__proto__.__proto__.__proto__ === Class.prototype?
...
// 如果任意一个的答案为 true,则返回 true
// 否则,如果我们已经检查到了原型链的尾端,则返回 false

在上面那个例子中,rabbit.proto === Rabbit.prototype,所以立即就给出了结果。

而在继承的例子中,匹配将在第二步进行:

class Animal {}
class Rabbit extends Animal {}

let rabbit = new Rabbit();
alert(rabbit instanceof Animal); // true

// rabbit.__proto__ === Rabbit.prototype
// rabbit.__proto__.__proto__ === Animal.prototype(匹配!)

下图展示了 rabbit instanceof Animal 的执行过程中,Animal.prototype 是如何参与比较的:

这里还要提到一个方法 objA.isPrototypeOf(objB),如果 objA 处在 objB 的原型链中,则返回 true。所以,可以将 obj instanceof Class 检查改为 Class.prototype.isPrototypeOf(obj)。

这很有趣,但是 Class 的 constructor 自身是不参与检查的!检查过程只和原型链以及 Class.prototype 有关。

创建对象后,如果更改 prototype 属性,可能会导致有趣的结果。

就像这样:

function Rabbit() {}
let rabbit = new Rabbit();

// 修改了 prototype
Rabbit.prototype = {};

// ...再也不是 rabbit 了!
alert( rabbit instanceof Rabbit ); // false

福利:使用 Object.prototype.toString 方法来揭示类型

大家都知道,一个普通对象被转化为字符串时为 [object Object]:

let obj = {};

alert(obj); // [object Object]
alert(obj.toString()); // 同上

这是通过 toString 方法实现的。但是这儿有一个隐藏的功能,该功能可以使 toString 实际上比这更强大。我们可以将其作为 typeof 的增强版或者 instanceof 的替代方法来使用。

听起来挺不可思议?那是自然,精彩马上揭晓。

按照 规范 所讲,内建的 toString 方法可以被从对象中提取出来,并在任何其他值的上下文中执行。其结果取决于该值。

  • 对于 number 类型,结果是 [object Number]
  • 对于 boolean 类型,结果是 [object Boolean]
  • 对于 null:[object Null]
  • 对于 undefined:[object Undefined]
  • 对于数组:[object Array]
  • ……等(可自定义)
// 方便起见,将 toString 方法复制到一个变量中
let objectToString = Object.prototype.toString;
// 它是什么类型的?
let arr = [];
alert( objectToString.call(arr) ); // [object Array]

这里我们用到了在 装饰器模式和转发,call/apply 一章中讲过的 call 方法来在上下文 this=arr 中执行函数 objectToString。

在内部,toString 的算法会检查 this,并返回相应的结果。再举几个例子:

let s = Object.prototype.toString;

alert( s.call(123) ); // [object Number]
alert( s.call(null) ); // [object Null]
alert( s.call(alert) ); // [object Function]

Symbol.toStringTag

可以使用特殊的对象属性 Symbol.toStringTag 自定义对象的 toString 方法的行为。

例如:

let user = {
  [Symbol.toStringTag]: "User"
};
alert( {}.toString.call(user) ); // [object User]

对于大多数特定于环境的对象,都有一个这样的属性。下面是一些特定于浏览器的示例:

// 特定于环境的对象和类的 toStringTag:
alert( window[Symbol.toStringTag]); // Window
alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest

alert( {}.toString.call(window) ); // [object Window]
alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest]

正如我们所看到的,输出结果恰好是 Symbol.toStringTag(如果存在),只不过被包裹进了 [object ...] 里。

这样一来,我们手头上就有了个“磕了药似的 typeof”,不仅能检查原始数据类型,而且适用于内建对象,更可贵的是还支持自定义。

所以,如果我们想要获取内建对象的类型,并希望把该信息以字符串的形式返回,而不只是检查类型的话,我们可以用 {}.toString.call 替代 instanceof。

总结一下我们知道的类型检查方法:

用于 返回值
typeof 原始数据类型 string
{}.toString 原始数据类型,内建对象,包含 Symbol.toStringTag 属性的对象 string
instanceof 对象 true/false

有意思的问题:不按套路出牌的 instanceof

重要程度: 5

在下面的代码中,为什么 instanceof 会返回 true?我们可以明显看到,a 并不是通过 B() 创建的。

function A() {}
function B() {}

A.prototype = B.prototype = {};

let a = new A();

alert( a instanceof B ); // true

是的,看起来确实很奇怪。

instanceof 并不关心函数,而是关心函数的与原型链匹配的 prototype。

并且,这里 a.proto == B.prototype,所以 instanceof 返回 true。

总之,根据 instanceof 的逻辑,真正决定类型的是 prototype,而不是构造函数。

七、Mixin模式

在 JavaScript 中,我们只能继承单个对象。每个对象只能有一个 [[Prototype]]。并且每个类只可以扩展另外一个类。

但是有些时候这种设定(译注:单继承)会让人感到受限制。例如,我有一个 StreetSweeper 类和一个 Bicycle 类,现在想要一个它们的 mixin:StreetSweepingBicycle 类

或者,我们有一个 User 类和一个 EventEmitter 类来实现事件生成(event generation),并且我们想将 EventEmitter 的功能添加到 User 中,以便我们的用户可以触发事件(emit event)。

有一个概念可以帮助我们,叫做 “mixins”。

根据维基百科的定义,mixin 是一个包含可被其他类使用而无需继承的方法的类

换句话说,mixin 提供了实现特定行为的方法,但是我们不单独使用它,而是使用它来将这些行为添加到其他类中。

一个 Mixin 实例

在 JavaScript 中构造一个 mixin 最简单的方式就是构造一个拥有实用方法的对象,以便我们可以轻松地将这些实用的方法合并到任何类的原型中。

例如,这个名为 sayHiMixin 的 mixin 用于给 User 添加一些“语言功能”:

// mixin
let sayHiMixin = {
  sayHi() {
    alert(`Hello ${this.name}`);
  },
  sayBye() {
    alert(`Bye ${this.name}`);
  }
};

// 用法:
class User {
  constructor(name) {
    this.name = name;
  }
}

// 拷贝方法
Object.assign(User.prototype, sayHiMixin);

// 现在 User 可以打招呼了
new User("Dude").sayHi(); // Hello Dude!

这里没有继承,只有一个简单的方法拷贝。所以 User 可以从另一个类继承,还可以包括 mixin 来 "mix-in“ 其它方法,就像这样:

class User extends Person {
  // ...
}

Object.assign(User.prototype, sayHiMixin);

Mixin 可以在自己内部使用继承。

例如,这里的 sayHiMixin 继承自 sayMixin:

let sayMixin = {
  say(phrase) {
    alert(phrase);
  }
};

let sayHiMixin = {
  __proto__: sayMixin, // (或者,我们可以在这儿使用 Object.create 来设置原型)

  sayHi() {
    // 调用父类方法
    super.say(`Hello ${this.name}`); // (*)
  },
  sayBye() {
    super.say(`Bye ${this.name}`); // (*)
  }
};

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

// 拷贝方法
Object.assign(User.prototype, sayHiMixin);

// 现在 User 可以打招呼了
new User("Dude").sayHi(); // Hello Dude!

请注意,在 sayHiMixin 内部对父类方法 super.say() 的调用(在标有 (*) 的行)会在 mixin 的原型中查找方法,而不是在 class 中查找。

这是示意图(请参见图中右侧部分):

这是因为方法 sayHi 和 sayBye 最初是在 sayHiMixin 中创建的。因此,即使复制了它们,但是它们的 [[HomeObject]] 内部属性仍引用的是 sayHiMixin,如上图所示。

当 super 在 [[HomeObject]].[[Prototype]] 中寻找父方法时,意味着它搜索的是 sayHiMixin.[[Prototype]],而不是 User.[[Prototype]]。

EventMixin

现在让我们为实际运用构造一个 mixin。

例如,许多浏览器对象的一个重要功能是它们可以生成事件。事件是向任何有需要的人“广播信息”的好方法。因此,让我们构造一个 mixin,使我们能够轻松地将与事件相关的函数添加到任意 class/object 中。

  • Mixin 将提供 .trigger(name, [...data]) 方法,以在发生重要的事情时“生成一个事件”。name 参数(arguments)是事件的名称,[...data] 是可选的带有事件数据的其他参数(arguments)。

  • 此外还有 .on(name, handler) 方法,它为具有给定名称的事件添加了 handler 函数作为监听器(listener)。当具有给定 name 的事件触发时将调用该方法,并从 .trigger 调用中获取参数(arguments)。

  • ……还有 .off(name, handler) 方法,它会删除 handler 监听器(listener)。

添加完 mixin 后,对象 user 将能够在访客登录时生成事件 "login"。另一个对象,例如 calendar 可能希望监听此类事件以便为登录的人加载日历。

或者,当一个菜单项被选中时,menu 可以生成 "select" 事件,其他对象可以分配处理程序以对该事件作出反应。诸如此类。

下面是代码:

let eventMixin = {
  /**
   * 订阅事件,用法:
   *  menu.on('select', function(item) { ... }
  */
  on(eventName, handler) {
    if (!this._eventHandlers) this._eventHandlers = {};
    if (!this._eventHandlers[eventName]) {
      this._eventHandlers[eventName] = [];
    }
    this._eventHandlers[eventName].push(handler);
  },

  /**
   * 取消订阅,用法:
   *  menu.off('select', handler)
   */
  off(eventName, handler) {
    let handlers = this._eventHandlers?.[eventName];
    if (!handlers) return;
    for (let i = 0; i < handlers.length; i++) {
      if (handlers[i] === handler) {
        handlers.splice(i--, 1);
      }
    }
  },

  /**
   * 生成具有给定名称和数据的事件
   *  this.trigger('select', data1, data2);
   */
  trigger(eventName, ...args) {
    if (!this._eventHandlers?.[eventName]) {
      return; // 该事件名称没有对应的事件处理程序(handler)
    }

    // 调用事件处理程序(handler)
    this._eventHandlers[eventName].forEach(handler => handler.apply(this, args));
  }
};
  1. .on(eventName, handler) — 指定函数 handler 以在具有对应名称的事件发生时运行。从技术上讲,这儿有一个用于存储每个事件名称对应的处理程序(handler)的 _eventHandlers 属性,在这儿该属性就会将刚刚指定的这个 handler 添加到列表中。

  2. .off(eventName, handler) — 从处理程序列表中删除指定的函数。

  3. .trigger(eventName, ...args) — 生成事件:所有 _eventHandlers[eventName] 中的事件处理程序(handler)都被调用,并且 ...args 会被作为参数传递给它们。

// 创建一个 class
class Menu {
  choose(value) {
    this.trigger("select", value);
  }
}
// 添加带有事件相关方法的 mixin
Object.assign(Menu.prototype, eventMixin);

let menu = new Menu();

// 添加一个事件处理程序(handler),在被选择时被调用:
menu.on("select", value => alert(`Value selected: ${value}`));

// 触发事件 => 运行上述的事件处理程序(handler)并显示:
// 被选中的值:123
menu.choose("123");

现在,如果我们希望任何代码对菜单选择作出反应,我们可以使用 menu.on(...) 进行监听。

使用 eventMixin 可以轻松地将此类行为添加到我们想要的多个类中,并且不会影响继承链。

总结

Mixin — 是一个通用的面向对象编程术语:一个包含其他类的方法的类。

一些其它编程语言允许多重继承。JavaScript 不支持多重继承,但是可以通过将方法拷贝到原型中来实现 mixin。

我们可以使用 mixin 作为一种通过添加多种行为(例如上文中所提到的事件处理)来扩充类的方法。

如果 Mixins 意外覆盖了现有类的方法,那么它们可能会成为一个冲突点。因此,通常应该仔细考虑 mixin 的命名方法,以最大程度地降低发生这种冲突的可能性。

posted @ 2021-10-15 15:05  汪淼焱  阅读(361)  评论(0编辑  收藏  举报