首页 阅读DAY12 JavaScript高级程序设计 8章下 对象、类与面向对象编程
文章
取消

阅读DAY12 JavaScript高级程序设计 8章下 对象、类与面向对象编程

开始阅读JavaScript高级程序设计(第5版)学习JS,总共有1000+页,非常全面,短期看完不太现实,找到了一篇博客,花些时间跟着这篇博客过一下红宝书。

JavaScript高级程序设计(第5版)微信读书

红宝书《JavaScript高级程序设计(第5版)》学习大纲 - 大前端全栈开发 - SegmentFault 思否

箭头函数和类共同消除了函数的二义性。

类:

前几节深入讲解了如何只使用ECMAScript 5的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。

为解决这些问题,ECMAScript通过class关键字提供了正式定义类的能力。虽然ECMAScript类可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念

全文概览:

重要度知识点理由
⭐⭐⭐new 实例化 5 步理解类和构造函数的基石,面试高频
⭐⭐⭐三种成员存放位置(实例/原型/static)写类时必须知道属性和方法该放哪,面试常问
⭐⭐⭐extends + super()继承是类最核心的能力,super() 规则是必考点
⭐⭐⭐super() 必须在 this 之前调用派生类构造函数头号坑,面试经典题
⭐⭐⭐构造函数返回非 this 对象时 instanceof 失效理解 new 机制的关键,面试陷阱题
⭐⭐类声明 vs 类表达式基础概念,开发中类声明更常见
⭐⭐类提升与 TDZ面试可能问”类和函数声明的区别”
⭐⭐类字段声明age = 30 等号语法)现代开发高频用法,React 类组件常见
⭐⭐冒号 vs 等号(原型 vs 实例)理解类块语法限制的关键
⭐⭐私有成员 #ES2022 特性,现代项目越来越常用
⭐⭐static 方法与 this 指向类本身工厂模式、工具方法常用,面试会问 static 的 this
⭐⭐类构造函数必须 new 调用与普通构造函数的区别,坑点
⭐⭐抽象基类new.target设计模式相关,中高级面试可能涉及
⭐⭐三个 constructor 的区别容易混淆的概念,理解原型链的试金石
静态初始化块冷门特性,极少使用
迭代器与生成器方法特定场景才用,面试几乎不单独考
继承内置类型 + Symbol.species非常小众,只有扩展 Array/Map 时才涉及
类混入模式本身已过时,React 等框架转向组合模式
[[HomeObject]]引擎内部特性,只需知道 super 靠它工作

类定义:

与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用class关键字加大括号:

1
2
3
4
5
// 类声明
class Person {}

// 类表达式
const Animal = class {};

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能

实际上类声明会提升(hoisting),而类表达式不会。 但类的提升和 let 一样,存在暂时性死区(TDZ),技术上提升了但没有初始化,处在TDZ中,提了和没提一样,访问早了也会报错,所以实际差异不大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log(FunctionExpression);   // undefined
var FunctionExpression = function() {};
console.log(FunctionExpression);   // function() {}

console.log(FunctionDeclaration);  // FunctionDeclaration() {}
function FunctionDeclaration() {}
console.log(FunctionDeclaration);  // FunctionDeclaration() {}

console.log(ClassExpression);      // undefined
var ClassExpression = class {};
console.log(ClassExpression);      // class {}

console.log(ClassDeclaration);     // ReferenceError: ClassDeclaration is not defined
class ClassDeclaration {}
console.log(ClassDeclaration);     // class ClassDeclaration {}

虽然函数声明具有函数作用域,但类具有块作用域:

1
2
3
4
5
6
7
{
  function FunctionDeclaration() {}
  class ClassDeclaration {}
}

console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration);    // ReferenceError: ClassDeclaration is not defined

类的构成:

类可包含:构造函数、实例方法、getter、setter、静态方法全部可选,空类也有效。类体内代码默认严格模式执行。 类名首字母大写是惯例,以区别于实例:

1
2
3
4
class Foo {} // 空类,有效
class Bar { constructor() {} } // 有构造函数
class Baz { get myBaz() {} } // 有 getter
class Qux { static myQux() {} } // 有静态方法

类表达式名称可选,赋值后通过 .name 取名,但名称标识符只在类内部可用:

1
2
3
4
5
6
7
8
let Person = class PersonName {
    identify() {
    	console.log(PersonName.name); // PersonName ✓ 内部可用
    }
};
new Person().identify();
console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError ✗ 外部不可访问

多数场景用类声明而非类表达式,语法更自然。

类构造函数:

与普通构造函数不同,constructor关键字用于在类定义块内部创建类的构造函数。方法名constructor会告诉解释器在使用new操作符创建类的新实例时,应该调用这个函数。

构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

实例化:

new 做的事就是:创建对象 → 把参数传给 constructor → 执行构造函数 → 返回对象。参数只跟 constructor 打交道,和类本身无关。

new 调用类构造函数的执行步骤:

  1. 内存中创建新对象
  2. 新对象的 [[Prototype]] 指向构造函数的 prototype
  3. 构造函数的 this 指向新对象
  4. 执行构造函数内部代码
  5. 若构造函数返回非空对象则返回该对象(在return里特意构造的对象),否则返回新创建的对象(即this对象)
1
2
3
4
5
6
7
class Person {
    constructor(name) {
    	this.name = name || null;
    }
}
let p1 = new Person; // 无参时括号可省
let p2 = new Person('Jake'); // 参数传给类里的constructor

构造函数返回非 this 对象时,instanceof 失效,因为原型指针没被修改:

1
2
3
4
5
6
7
8
9
10
class Person {
    constructor(override) {
    	this.foo = 'foo';
    	if (override) return { bar: 'bar' };
    }
}
let p1 = new Person();
let p2 = new Person(true);
p1 instanceof Person; // true ← 正常的 this 对象
p2 instanceof Person; // false ← 返回的是普通对象,原型链断裂

类构造函数必须用 new 调用,否则直接报错(普通构造函数不用 new 则 this 指向 window):

1
2
3
4
5
function Person() {}
class Animal {}

Person(); // 正常执行,this → window
Animal(); // TypeError: class constructor cannot be invoked without 'new'

类构造函数实例化后成为实例的 constructor 属性,但仍需 new 调用:

1
2
3
let p1 = new Person();
p1.constructor(); // TypeError: 必须用 new
let p2 = new p1.constructor(); // ✓ 通过 new 调用

把类当成特殊函数:

ECMAScript中没有正式的Class类型。从各方面来看,ECMAScript类就是一种特殊函数。声明一个类之后,通过typeof操作符检测类标识符,表明它是一个函数:

1
2
3
4
class Person {}

console.log(Person);         // class Person {}
console.log(typeof Person);  // function

类标识符有prototype属性,而这个原型也有一个constructor属性指向类自身:

1
2
3
4
class Person{}

console.log(Person.prototype);                         // { constructor: f() }
console.log(Person === Person.prototype.constructor);  // true

与普通构造函数一样,可以使用instanceof操作符检查构造函数原型是否存在于实例的原型链中:

1
2
3
4
5
class Person {}

let p = new Person();

console.log(p instanceof Person); // true

由此可知,可以使用instanceof操作符检查一个对象与类构造函数,以确定这个对象是不是类的实例。只不过此时的类构造函数要使用类标识符,比如在前面的例子中要检查p和Person。

如前所述,类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用new调用时就会被当成构造函数。

重点在于,类中定义的constructor方法不会被当成构造函数,在对它使用instanceof操作符时会返回false。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么instanceof操作符的返回值会反转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {}
typeof Person; // "function" — 类本质就是函数
Person.constructor === Function; // true — "创建 Person 的是 Function"
 //  Person 是函数,函数的构造函数是 Function

let p1 = new Person();
p1.constructor === Person; // true — "创建我的是 Person"

console.log(p1.constructor === Person);         // true
console.log(p1 instanceof Person);              // true
console.log(p1 instanceof Person.constructor);  // false

let p2 = new Person.constructor(); // Person.constructor 就是 Function
 // 等于 new Function() → 创建一个空函数,跟 Person 毫无关系

console.log(p2.constructor === Person);         // false
console.log(p2 instanceof Person);              // false
console.log(p2 instanceof Person.constructor);  // true

所以这里其实我们在讨论三个constructor,他们完全不同:

名字是什么作用
类里的 constructor()关键字,方法名初始化实例的逻辑
p.constructor实例的属性指向创建 p 的函数,即 Person
Person.constructorPerson 的属性指向创建 Person 的函数,即 Function

容易混淆就是因为它们都叫 constructor。每个对象都有 .constructor 属性,指向”创建我的那个函数”。类里的 constructor 是方法名,实例的 .constructor 是属性,Person 的 .constructor 是更高一层的属性。

类是JavaScript的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 类可以像函数一样在任何地方定义,比如在数组中
let classList = [
  class {
    constructor(id) {
      this.id_ = id;
      console.log(`instance ${this.id_}`);
    }
  }
];

function createInstance(classDefinition, id) {
  return new classDefinition(id);
}

let foo = createInstance(classList[0], 3141);  // instance 3141

与立即调用函数表达式相似,类也可以立即实例化:

1
2
3
4
5
6
7
8
// 因为是一个类表达式,所以类名是可选的
let p = new class Foo {
  constructor(x) {
    console.log(x);
  }
}('bar');        // bar

console.log(p);  // Foo {}

实例、原型和类成员:

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

实例成员:

每次通过new调用类标识符都会执行类构造函数。在构造函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,没有限制。

另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Person {
  constructor() {
    // 这个例子先使用对象包装类型定义一个字符串
    // 为的是在下面测试两个对象的相等性
    this.name = new String('Jack');

    this.sayName = () => console.log(this.name);

    this.nicknames = ['Jake', 'J-Dog']
  }
}

let p1 = new Person(),
    p2 = new Person();

p1.sayName(); // Jack
p2.sayName(); // Jack

console.log(p1.name === p2.name);            // false
console.log(p1.sayName === p2.sayName);      // false
console.log(p1.nicknames === p2.nicknames);  // false

p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];

p1.sayName();  // Jake
p2.sayName();  // J-Dog

类字段声明:

鉴于在构造函数中为每个实例的初始成员赋值是非常常见的模式,ECMAScript增加了类字段声明作为快捷方式。这样就可以直接在类体而非构造函数中初始化实例成员。

下面这两种写法的结果相同:

1
2
3
4
5
6
7
8
9
class PersonWithConstructor {
  constructor() {
    this.friendCount = 0;
  }
}

class PersonWithClassFields {
  friendCount = 0;
}

如果定义了构造函数,则在构造函数中可以访问类字段声明:

1
2
3
4
5
6
7
class Person {
  friendCount = 0;

  constructor() {
    console.log(this.friendCount); // 0
  }
}

可以只声明成员而不初始化,此时成员的值为undefined:

1
2
3
4
5
6
7
class Person {
  friendCount;

  constructor() {
    console.log(this.friendCount); // undefined
  }
}

原型方法与访问器:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Person {
  constructor() {
    // 添加到this的所有内容都会存在于不同的实例上
    this.locate = () => console.log('instance');
  }

  // 在类块中定义的所有内容都会定义在类的原型上
  locate() {
    console.log('prototype');
  }
}

let p = new Person();

p.locate();                 // instance
Person.prototype.locate();  // prototype

可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据

1
2
3
4
class Person {
  name: 'Jake'
}
// Uncaught SyntaxError: Unexpected token

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const symbolKey = Symbol('symbolKey');

class Person {

  stringKey() {
    console.log('invoked stringKey');
  }
   [symbolKey]() {
    console.log('invoked symbolKey');
  }
   ['computed' + 'Key']() {
    console.log('invoked computedKey');
  }
}

let p = new Person();

p.stringKey();    // invoked stringKey
p[symbolKey]();   // invoked symbolKey
p.computedKey();  // invoked computedKey

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

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
  set name(newName) {
    this.name_ = newName;
  }

  get name() {
    return this.name_;
  }
}

let p = new Person();
p.name = 'Jake';
console.log(p.name); // Jake

类限制了常规对象的部分行为,比如不能在类块中给原型添加原始值或对象作为成员数据,但又允许在类块中定义的方法作为原型方法,也支持获取和设置访问器。这些差别也许会导致在对类和对常规对象的理解上产生差别。

实际上类本身就是对象typeof Person === "function"),完全可以被当作对象处理。类块语法的限制不是为了阻止开发者把类当对象,而是为了强制区分数据和行为的归属

1
2
类块中能放什么? → 方法、getter/setter → 放在原型上 → 所有实例共享
类块中不能放什么?→ 原始值、对象 → 如果放原型上 → 所有实例共享同一个引用 ← 这就是之前讲的原型模式的问题

回忆一下原型模式的第二个问题:引用值属性共享

1
2
3
// 原型模式的坑
Person.prototype.friends = ['Jake']; 
// 所有实例共享同一个数组,一个改了全受影响

类块语法直接堵死了这条路,不允许在类块里写 name: 'Jake'就是防止开发者把数据放到原型上导致实例间共享

数据必须写在 constructor 里,通过 this 挂到每个实例上:

1
2
3
4
5
class Person {
constructor() {
this.name = 'Jake'; // 每个实例独立拥有,互不影响
}
}

所以不是类不希望被当作对象处理,而是类块语法的设计理念:行为放原型(共享),数据放实例(独立)

由于前面提到的constructor的语法糖,在语法上冒号和等号的分别是这样的:

语法存放位置合法
key: value(冒号)原型禁止
key = value(等号)实例(= this.key = value允许

私有类成员:

JavaScript中的私有类成员用于定义只能在类自身中访问的属性和方法。私有成员体现了类的封装性和信息隐藏,能防止在类外部直接访问和修改。要声明私有类成员,需要在成员名前面加上 #。下面的例子对比了公有和私有类成员:

1
2
3
4
5
6
7
8
9
10
11
12
class Person {
  #name = "Alice";
  age = 30;

  getName() {
    return this.#name;
  }
}

const person = new Person();
console.log(person.age); // 30
console.log(person.getName()); // Alice

这个例子声明了公有成员age和私有成员 #name。

公有方法getName()用于访问私有成员,这是保护 #name的值只能间接访问的常见模式

为验证这个成员的确是私有的,下面的代码尝试了几种访问私有成员的方法,但都无法拿到值。

1
2
3
4
5
console.log(person.#name);
// SyntaxError: Private field '#name' must be declared in an enclosing class

console.log(person['age']); // 30
console.log(person['#name']); // undefined

私有类成员前面的 # 预示着特殊行为,JavaScript在执行编译检查时会检查这个字符并为匹配的属性应用特殊规则

  • 只能在定义它的类中访问,外部不可访问和修改
  • 不能被子类继承,仅限定义它的类
  • 不能由派生类的同名方法/属性访问或覆盖
  • 构造函数不能私有
  • 必须在类体中声明,不能在构造函数执行期间或之后动态添加
  • 不能 deletedelete this.#age → SyntaxError)

下面的例子展示了针对私有成员的一些不正确的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {
    #age;

    constructor() {
      this.#age = 30;

      // 不能删除私有成员
      delete this.#age; // SyntaxError

      // 必须在类体中声明私有成员
      this.#name = "Alice"; // SyntaxError
    }
}

new Person();

字段、方法、获取方法、设置方法、异步函数和静态成员都可以私有。下面的例子展示了这些可能的私有成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Person {
    #name;
    #age;
    static #counter = 0;

    constructor(name, age) {
      this.#name = name;
      this.#age = age;
      Person.#incrementCounter();
    }

    // 私有方法
    #getNameInUpperCase() {
      return this.#name.toUpperCase();
    }

    // 私有获取方法
    get #capitalizedName() {
      return this.#getNameInUpperCase();
    }

    // 公有获取方法
    get name() {
      return this.#name;
    }

    // 公有获取方法访问私有获取方法
    get capitalizedName() {
      return this.#capitalizedName;
    }

    // 静态方法
    static getCounter() {
      return Person.#counter;
    }

    // 私有静态方法
    static #incrementCounter() {
      Person.#counter += 1;
    }
}

let p = new Person("Alice", 30);
console.log(p.name); // Alice
console.log(p.capitalizedName); // ALICE
console.log(Person.getCounter()); // 1

静态类方法:

static 前缀定义静态成员,属于类本身而非实例,通常用于不依赖实例的操作。静态方法中 this 指向类自身

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
    static species = "sapiens"; // 静态字段 → 在类上

    constructor() {
    	this.locate = () => console.log('instance', this); // 在实例上
    }

    locate() { // 原型方法 → 在原型上
    	console.log('prototype', this);
    }

    static locate() { // 静态方法 → 在类上
    	console.log('class', this);
    }
}

let p = new Person();
p.locate(); // instance, Person {}
Person.prototype.locate(); // prototype, {constructor: ...}
Person.locate(); // class, class Person {}

三种成员的存放位置

成员存放位置调用方式
构造函数中的 this.xxx实例p.xxx()
类块中的方法原型p.xxx()Person.prototype.xxx()
static 成员类本身Person.xxx()

静态方法的典型用途是实例工厂:

1
2
3
4
5
6
7
8
class Person {
    constructor(age) { this.age_ = age; }
    sayAge() { console.log(this.age_); }
    static create() {
    	return new Person(Math.floor(Math.random() * 100));
    }
}
Person.create(); // 不需要 new,通过静态方法创建实例

一句话来说,static 成员属于类,不属于实例。调用方式是 Person.xxx() 而非 p.xxx()

静态初始化块:

在静态初始化比较重要的时候,类也支持通过静态初始化块来编写复杂的代码以初始化静态成员。下面是一个简单的例子:

1
2
3
4
5
6
7
8
class Person {
  static name = "Alice";
  static age;

  static {
    this.age = 30;
  }
}

静态初始化块提供了一种方式在类求值期间声明和执行任意初始化逻辑。在必须计算复杂静态值或检查已有静态值的情况下,初始化块可以派上用场。在使用静态初始化块时,要注意以下几点。

  • 可以在类中使用任意多个初始化块,多个块按出现的顺序求值。
  • 初始化块必须同步求值。
  • 初始化块的作用域按正常词法作用域对待。
  • 初始化块中的this引用类的构造函数。

迭代器与生成器方法:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Person {
  // 在原型上定义生成器方法
  *createNicknameIterator() {
    yield 'Jack';
    yield 'Jake';
    yield 'J-Dog';
  }

  // 在类上定义生成器方法
  static *createJobIterator() {
    yield 'Butcher';
    yield 'Baker';
    yield 'Candlestick maker';
  }
}

let jobIter = Person.createJobIterator();
console.log(jobIter.next().value);  // Butcher
console.log(jobIter.next().value);  // Baker
console.log(jobIter.next().value);  // Candlestick maker

let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value);  // Jack
console.log(nicknameIter.next().value);  // Jake
console.log(nicknameIter.next().value);  // J-Dog

因为支持生成器方法,所以可以通过添加一个默认的迭代器把类实例变成可迭代对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog'];
  }

  *[Symbol.iterator]() {
    yield *this.nicknames.entries();
  }
}

let p = new Person();
for (let [idx, nickname] of p) {
  console.log(nickname);
}
// Jack
// Jake
// J-Dog

也可以只返回迭代器实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
  constructor() {
    this.nicknames = ['Jack', 'Jake', 'J-Dog'];
  }

  [Symbol.iterator]() {
    return this.nicknames.entries();
  }
}

let p = new Person();
for (let [idx, nickname] of p) {
  console.log(nickname);
}
// Jack
// Jake
// J-Dog

类继承:

前面花了大量篇幅讨论如何使用ES5的机制实现继承,而ECMAScript类原生支持了类继承机制。

虽然类继承使用的是新语法,但背后依旧使用的是原型链

ES5 继承方式与 ES6 extends 的关系:

ES5 没有专门的继承语法,靠三种模式手动拼

1. 原型链继承:用 new 建链

1
Dog.prototype = new Animal();

问题:父类实例属性变成原型属性,所有实例共享引用值。

2. 构造函数借用:用 call 借父类构造函数

1
2
3
function Dog(name, breed) {
  Animal.call(this, name);
}

问题:只拿到实例属性,原型方法继承不到。

3. 组合继承:1 + 2 合在一起

1
2
3
4
5
function Dog(name, breed) {
  Animal.call(this, name); // 拿实例属性
}
Dog.prototype = new Animal(); // 建原型链
Dog.prototype.constructor = Dog;

问题:父类构造函数执行了两次,原型上多了无用的实例属性。

最优解:寄生组合继承

Object.create 替代 new,只建链不调构造函数:

1
2
3
4
5
function Dog(name, breed) {
  Animal.call(this, name); // 只执行 1 次
}
Dog.prototype = Object.create(Animal.prototype); // 建链,不调构造函数
Dog.prototype.constructor = Dog;

父类只执行一次,原型上没有多余属性,是 ES5 最干净的继承方式。

ES6 extends 本质就是寄生组合继承

1
2
3
4
5
6
class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
 }
}

extends 自动完成了寄生组合继承的所有手动步骤:

手动步骤extends 自动完成
Animal.call(this, name)super(name)
Object.create(Animal.prototype)自动建原型链
Dog.prototype.constructor = Dog自动修复
静态方法继承自动

ES5 继承是拼积木,原型链继承有共享坑,借用构造函数缺原型方法,组合继承重复执行,寄生组合继承最优;ES6 的 extends 就是把寄生组合继承的步骤自动化了。

继承基础:

ECMAScript类只支持单继承,也就是只能有一个父类。使用extends关键字可以继承任何拥有 [[Construct]] 和原型的对象。很大程度上,这意味着不仅可以继承类,也可以继承普通的构造函数(保持向后兼容)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Vehicle {}

// 继承类
class Bus extends Vehicle {}

let b = new Bus();
console.log(b instanceof Bus);      // true
console.log(b instanceof Vehicle);  // true
 
 
function Person() {}

// 继承普通构造函数
class Engineer extends Person {}

let e = new Engineer();
console.log(e instanceof Engineer);  // true
console.log(e instanceof Person);    // true

派生类都会通过原型链访问到类和原型上定义的方法。this的值会反映调用相应方法的实例或者类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Vehicle {
  identifyPrototype(id) {
    console.log(id, this);
  }

  static identifyClass(id) {
    console.log(id, this);
  }
}

class Bus extends Vehicle {}

let v = new Vehicle();
let b = new Bus();

b.identifyPrototype('bus');       // bus, Bus {}
v.identifyPrototype('vehicle');   // vehicle, Vehicle {}

Bus.identifyClass('bus');         // bus, class Bus {}
Vehicle.identifyClass('vehicle'); // vehicle, class Vehicle {}

extends关键字也可以在类表达式中使用,因此let Bar = class extends Foo {} 是有效的语法。

构造函数、HomeObject和super():

派生类的方法可以通过super关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用super可以调用父类构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Vehicle {
  constructor() {
    this.hasEngine = true;
  }
}

class Bus extends Vehicle {
  constructor() {
    // 不要在调用super()之前引用this,否则会抛出ReferenceError

    super(); // 相当于super.constructor()

    console.log(this instanceof Vehicle);  // true
    console.log(this);                     // Bus { hasEngine: true }
  }
}

new Bus();

在静态方法中可以通过super调用继承的类上定义的静态方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Vehicle {
  static identify() {
    console.log('vehicle');
  }
}

class Bus extends Vehicle {
  static identify() {
    super.identify();
  }
}

Bus.identify();  // vehicle

ECMAScript给类构造函数和静态方法添加了内部特性 [[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在JavaScript引擎内部访问。super始终会定义为[[HomeObject]] 的原型。

在使用super时要注意几个问题:

super只能在派生类构造函数和静态方法中使用。

1
2
3
4
5
6
class Vehicle {
  constructor() {
    super();
    // SyntaxError: 'super' keyword unexpected
  }
}

不能单独引用super关键字,要么用它调用构造函数,要么用它引用静态方法。

1
2
3
4
5
6
7
8
class Vehicle {}

class Bus extends Vehicle {
  constructor() {
    console.log(super);
    // SyntaxError: 'super' keyword unexpected here
  }
}

调用super()会调用父类构造函数,并将返回的实例赋值给this。

1
2
3
4
5
6
7
8
9
10
11
class Vehicle {}

class Bus extends Vehicle {
  constructor() {
    super();

    console.log(this instanceof Vehicle);
  }
}

new Bus(); // true

super()的行为如同构造函数,如果需要给父类构造函数传参,则需要手动传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Vehicle {
  constructor(licensePlate) {
    this.licensePlate = licensePlate;
  }
}

class Bus extends Vehicle {
  constructor(licensePlate) {
    super(licensePlate);
  }
}

console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }

如果没有定义类构造函数,在实例化派生类时会隐式调用super(),而且会传入所有传给派生类的参数。

1
2
3
4
5
6
7
8
9
class Vehicle {
  constructor(licensePlate) {
    this.licensePlate = licensePlate;
  }
}

class Bus extends Vehicle {}

console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }

在类构造函数中,不能在调用super()之前引用this。

1
2
3
4
5
6
7
8
9
10
11
class Vehicle {}

class Bus extends Vehicle {
  constructor() {
    console.log(this);
  }
}

new Bus();
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor

如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Vehicle {}

class Car extends Vehicle {}

class Bus extends Vehicle {
  constructor() {
    super();
  }
}

class Van extends Vehicle {
  constructor() {
    return {};
  }
}

console.log(new Car());  // Car {}
console.log(new Bus());  // Bus {}
console.log(new Van());  // {}

抽象基类:

有时候可能需要定义这样一个类:它可供其他类继承,但本身不会被实例化,这就是抽象基类

虽然ECMAScript没有专门支持这种类的语法,但通过new.target也很容易实现。new.target保存通过new关键字调用的类或函数。通过在实例化时检测new.target是不是抽象基类,可以阻止对抽象基类的实例化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 抽象基类
class Vehicle {
  constructor() {
    console.log(new.target);
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }
  }
}

// 派生类
class Bus extends Vehicle {}

new Bus();       // class Bus {}
new Vehicle();   // class Vehicle {}
// Error: Vehicle cannot be directly instantiated

另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过this关键字来检查相应的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 抽象基类
class Vehicle {
  constructor() {
    if (new.target === Vehicle) {
      throw new Error('Vehicle cannot be directly instantiated');
    }

    if (!this.foo) {
      throw new Error('Inheriting class must define foo()');
    }

    console.log('success!');
  }
}

// 派生类
class Bus extends Vehicle {
  foo() {}
}

// 派生类
class Van extends Vehicle {}

new Bus(); // success!
new Van(); // Error: Inheriting class must define foo()

继承内置类型:

ECMAScript类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class SuperArray extends Array {
  shuffle() {
    // 洗牌算法
    for (let i = this.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [this[i], this[j]] = [this[j], this[i]];
    }
  }
}

let a = new SuperArray(1, 2, 3, 4, 5);

console.log(a instanceof Array);       // true
console.log(a instanceof SuperArray);  // true

console.log(a);  // [1, 2, 3, 4, 5]
a.shuffle();
console.log(a);  // [3, 1, 4, 5, 2]

有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的:

1
2
3
4
5
6
7
8
9
class SuperArray extends Array {}

let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter(x => !!(x%2))

console.log(a1);  // [1, 2, 3, 4, 5]
console.log(a2);  // [1, 3, 5]
console.log(a1 instanceof SuperArray);  // true
console.log(a2 instanceof SuperArray);  // true

如果想覆盖这个默认行为,则可以覆盖Symbol.species访问器,这个访问器决定在创建返回的实例时使用的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class SuperArray extends Array {
  static get [Symbol.species]() {
    return Array;
  }
}

let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter(x => !!(x%2))

console.log(a1);  // [1, 2, 3, 4, 5]
console.log(a2);  // [1, 3, 5]
console.log(a1 instanceof SuperArray);  // true
console.log(a2 instanceof SuperArray);  // false

类混入:

把不同类的行为集中到一个类是一种常见的JavaScript模式。虽然ECMAScript没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为。

Object.assign()方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用Object.assign()就可以了。

在下面的代码片段中,extends关键字后面是一个JavaScript表达式。任何可以解析为一个类或一个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值:

1
2
3
4
5
6
7
8
9
class Vehicle {}

function getParentClass() {
  console.log('evaluated expression');
  return Vehicle;
}

class Bus extends getParentClass() {}
// 可求值的表达式

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果Person类需要组合A、B、C,则需要某种机制实现B继承A,C继承B,而Person再继承C,从而把A、B、C组合到这个超类中。实现这种模式有不同的策略。

一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Vehicle {}

let FooMixin = (Superclass) => class extends Superclass {
  foo() {
    console.log('foo');
  }
};
let BarMixin = (Superclass) => class extends Superclass {
  bar() {
    console.log('bar');
  }
};
let BazMixin = (Superclass) => class extends Superclass {
  baz() {
    console.log('baz');
  }
};

class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}

let b = new Bus();
b.foo();  // foo
b.bar();  // bar
b.baz();  // baz

通过写一个辅助函数,可以把嵌套调用展开:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Vehicle {}

let FooMixin = (Superclass) => class extends Superclass {
  foo() {
    console.log('foo');
  }
};
let BarMixin = (Superclass) => class extends Superclass {
  bar() {
    console.log('bar');
  }
};
let BazMixin = (Superclass) => class extends Superclass {
  baz() {
    console.log('baz');
  }
};

function mix(BaseClass, ...Mixins) {
  return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
}

class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}

let b = new Bus();
b.foo();  // foo
b.bar();  // bar
b.baz();  // baz

很多JavaScript框架(特别是React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。

本文由作者按照 CC BY 4.0 进行授权

阅读DAY11 JavaScript高级程序设计 8章中 对象、类与面向对象编程

重启DAY9 二叉树的层序遍历