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
类不仅仅是语法糖,它与函数之间存在着重大差异:
- 通过 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 { ... }
-
类方法不可枚举。 类定义将 "prototype" 中的所有方法的 enumerable 标志设置为 false。这很好,因为如果我们对一个对象调用 for..in 方法,我们通常不希望 class 方法出现。
-
类总是使用 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,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。
这张图介绍了发生的情况:
- 在 longEar.eat() 中,(**) 这一行调用 rabbit.eat 并为其提供 this=longEar。
// 在 longEar.eat() 中我们有 this = longEar
this.__proto__.eat.call(this) // (**)
// 变成了
longEar.__proto__.eat.call(this)
// 也就是
rabbit.eat.call(this);
- 之后在 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);
- ……所以 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]] 引用:
- Rabbit 函数原型继承自 Animal 函数。
- 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” 语法会设置两个原型:
- 在构造函数的 "prototype" 之间设置原型(为了获取实例方法)。
- 在构造函数之间会设置原型(为了获取静态方法)。
在我们的例子里,对于 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 算法的执行过程大致如下:
- 如果这儿有静态方法 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) 被调用
- 大多数 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));
}
};
-
.on(eventName, handler) — 指定函数 handler 以在具有对应名称的事件发生时运行。从技术上讲,这儿有一个用于存储每个事件名称对应的处理程序(handler)的 _eventHandlers 属性,在这儿该属性就会将刚刚指定的这个 handler 添加到列表中。
-
.off(eventName, handler) — 从处理程序列表中删除指定的函数。
-
.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 的命名方法,以最大程度地降低发生这种冲突的可能性。