ES6 引入的 class 关键字具有定义类的能力。类是 ECMAScript 中新的基础性语法糖。虽然 ES6 表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的依旧是原型和构造函数的概念。(笔记内容参考《JavaScript 高级程序设计(第 4 版)》章节 8.4 类)。以前在笔记《js 中原型、原型链和继承概念(详细全面)》中简要提过部分内容,这里系统看一次。
定义类有两种主要方式:类声明和类表达式。两种方式都需要使用 class 关键字加大括号定义。类表达式与函数表达式类似,在它们被求值前都不能被引用。但是类声明与函数声明不同,类声明不能提升,即在声明之前引用该类都会抛出引用错误 ReferenceError(类似暂时性死区,因为类受块级作用域限制,个人认为这两者是存在关系的,书中没有说明)。
// 类声明
class Person { }
// 类表达式
const Tree = class {};
// 类声明不提升
// ReferenceError: Cannot access 'C' before initialization
console.log(C);
class C {}
// function 和 class 关键字类似于 var 和 let、const 的区别。
// 函数声明受函数作用域限制,类受块作用域限制。
{
function f() {
console.log('f');
}
class Cx {}
}
f(); // 'f'
// ReferenceError: Cx is not defined
console.log(Cx);
类包含可选的构造函数方法、静态类方法、实例方法、获取函数和设置函数等。与函数构造函数一样,多数编程风格都建议类名首字母大写,以区别于通过它创建的实例。默认情况下,类定义中的代码都在严格模式下执行。
class Person {
// 实例私有属性或者方法(ES10)
#age;
// 类构造函数
constructor(name, privateAge) {
this.name = name;
this.#age = privateAge;
}
// 实例方法(原型对象上)
say() {
console.log(`不想告诉你我的年龄是${this.#age}`);
}
// 静态类方法
static getName() {
console.log(this.name);
}
}
const p = new Person('tkop', 18);
console.log(p.name, p.age);
p.say();
Person.getName();
类表达式表现出来的特征与函数非常相似,例如 name 属性是一样,如果表达式声明具有 name 则首选返回该名称字符串,否则是表达式左边的值。本质就是表达式最后的值。且它们都无法在表达式作用域外部访问这个标识符。
const Person = class PersonName {
identify() {
console.log(Person.name, PersonName.name);
}
};
let p = new Person();
p.identify();
// ReferenceError: PersonName is not defined
// p = new PersonName();
const F = function Fn() {
console.log(F.name, Fn.name);
};
F();
let f = new F();
// ReferenceError: Fn is not defined
// f = new Fn();
// Fn();
// ========================
const Personx = class {
identify() {
console.log(Personx.name);
}
};
p = new Personx();
p.identify();
var Fx = function () {
console.log(Fx.name);
};
Fx();
实际上可以将类定义时大括号内部作用域包含的内容视为是对类(可视为特殊函数)的原型对象的初始化。内部定义的方法即为原型对象上的方法,例如 constructor 关键字用于在类定义块内部创建类的构造函数(即为 prototype 的 constructor 属性)。
方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必须的,不定义则相当于将构造函数定义为空函数。使用 new 操作符调用类的构造函数会执行如下操作。
// 构造函数为空函数
class C1 {
say() {
console.log('instanceof C1');
}
}
// 执行构造函数并为新对象添加属性
class C2 {
constructor(arg) {
this.instanceProperty = arg;
}
}
let instance1 = new C1();
let instance2 = new C2('arg_input');
instance1.say();
console.log(instance2.instanceProperty);
实例化时涉及的问题:
1、类实例化时传入的参数会作为构造函数的参数。如果不需要参数,则调用时类名后面的括号也是可选的。
2、构造函数执行完成后默认会返回 this 对象。构造函数返回的对象会被用作实例化的对象,如果外部没有什么引用返回的 this 对象,那么这个对象会被销毁。
3、如果构造函数返回的不是默认的 this 对象,而是其他对象。则需要注意这个对象与类并没有关联(使用 instanceof 操作符检测为 false,并不是类的实例)。因为该对象的对象原型并没有被修改。
class C3 {
constructor(arg) {
this.instanceProperty = arg || 'default';
}
}
let instance3 = new C3('arg_input');
let instance4 = new C3;
console.log(instance3.instanceProperty, instance4.instanceProperty);
class C4 {
constructor() {
return { uname: '扬尘' };
}
}
let instance5 = new C4();
console.log(instance5 instanceof C4);
注意:类构造函数与构造函数的主要区别是,类构造函数必须使用 new 操作符调用。否则会抛出错误 TypeError。类构造函数在实例化后会成为普通的实例方法(严格来说是原型对象上共享的方法,仍然需要使用 new 调用)。
class Person {}
let p1 = new Person();
// 实际上是 Person.prototype.constructor
let p2 = new p1.constructor();
console.log(p1.constructor === Person);
// TypeError: Class constructor Person cannot be invoked without 'new'
// Person();
// p1.constructor();
我们可以从类各方面的表现看出类就是一种特殊的函数,具有 prototype 属性,而这个原型对象也具有一个 constructor 属性指向类的本身。
class Person {}
console.log(typeof Person); // function
console.log(Person.prototype); // {constructor: ƒ}
console.log(Person === Person.prototype.constructor);
let p = new Person();
console.log(Person === p.constructor);
console.log(p instanceof Person);
至此,所有有关类定义的基本概念应该都非常明确了。但是书中却多余地给出了下面的这段描述。
之所以说多余是因为示例中获取的并不是类块中定义的 constructor 函数。前面我们提到过,在类内定义的所有内容可以视为是原型对象中的属性和方法。使用实例或者原型对象都可以访问,且 constructor 指向的是类自身。示例中的 Person.constructor 引用的当然不会是类中定义的该属性。它是类这个函数对象(将其视为一个对象)的对象原型(__proro__)上的一个属性,也就是实例化 Person 的构造函数。验证如下:
class Person {}
// 实例化的 Person 对象是函数类型
// 那用它的构造函数再次实例化一个对象也应该是函数类型;
const o = new Person.constructor();
console.log(typeof o); // 'function'
不应该将类块内的构造函数和类的 constructor 属性混为一谈,如果不理解其中的原理,就会徒增负担。
描述方面也不够详细:类中定义的 constructor 方法不会被当成构造函数?在对它使用 instanceof 操作符会返回 false ?(概念描述有点含糊了)。
class Person {}
// 这才是类中的constructor 方法,上面只是类的 constructor 属性
const o = new Person.prototype.constructor();
const p = new o.constructor();
// 可以当成构造函数使用,因为指向类的本身
console.log(typeof p); // 'object'
// true
console.log(o instanceof p.constructor && p instanceof o.constructor);
类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,及应该存在于类本身的成员。
实例成员是指每次使用 new 操作符调用构造函数时,内部为新创建的实例(this)添加的“自有”属性。每次创建的实例都对应唯一的成员对象,该对象上面的实例成员都不会在原型上共享。
class Person {
constructor(id) {
this.id = id;
this.friends = ['扬尘'];
}
}
let p0 = new Person(0);
let p1 = new Person(1);
console.log(p0.id, p1.id);
console.log(p0.friends === p1.friends);
// 当然可以继续添加新成员
p0.name = 'tkop';
console.log(p0.name);
类定义语法将在类块中定义的方法作为原型方法。类方法等同于对象属性,因此可以使用字符串或者计算的值作为键。同时也支持获取和设置访问器属性,语法和行为同普通对象一样。
class Person {
constructor() {
this.say = () => console.log('instance_say');
}
say() {
console.log('prototype_say');
}
[Symbol.for('symbolKey')]() {
console.log('symbolKey');
}
['conputed' + 'Key']() {
console.log('conputedKey');
}
set name(newName) {
this._name = 'get_' + newName;
}
get name() {
return this._name;
}
}
let p = new Person();
p.say(); // 'instance_say'
Person.prototype.say(); // 'prototype_say'
p.conputedKey(); // 'conputedKey'
p[Symbol.for('symbolKey')](); // 'symbolKey'
p.name = 'tkop';
console.log(p.name); // 'get_tkop'
可以将方法定义在类构造函数中或者类块中,但是不能在类块中给原型添加原始值或者对象作为成员数据。
// Uncaught SyntaxError: Unexpected identifier 'name'
class Person {
name: 'Jack';
// 这个会变为实例成员(公有字段声明)
// name = 'Jack';
}
可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。静态成员在类定义中使用 static 关键字作为前缀。静态成员由类调用,其中的 this 引用类自身。静态方法非常合适作为实例工厂。类似于 Promise.resolve() 和 Promise.reject()。
class Person {
constructor(age) {
this.age = age;
this.say = () => console.log('instance_say');
}
say() {
console.log('prototype_say', this === Person.prototype);
}
static say() {
console.log('Person_say', this === Person);
}
static create() {
// 创建返回一个随机年龄属性值的 Person 实例
// return new this(Math.floor(Math.random() * 100));
return new Person(Math.floor(Math.random() * 100));
}
}
let p = new Person();
p.say(); // 'instance_say'
Person.prototype.say(); // 'prototype_say' true
Person.say(); // 'Person_say' true
p = Person.create();
console.log(p); // Person { age: 88, say: [Function (anonymous)] }
JavaScript 以前没有私有成员的概念,只是利用闭包结合 Symbol 或者 WeakMap 等类型模拟实现了私有属性的概念。但是在 ECMAScript2019 中新增了在类块中使用 “#” 定义私有成员的语法(私有字段声明)。
class Person {
#address;
constructor(name, address) {
this.name = name;
this.#address = address;
}
say() {
console.log(this.#address);
}
}
let p = new Person('扬尘', '个人隐私-家庭地址');
p.say(); // '个人隐私-家庭地址'
console.log(Reflect.ownKeys(p)); // [ 'name' ]
可以注意到前面的类定义并不显示支持在原型或者类上添加成员数据。但是在类定义外部,可以手动添加(其实类块内支持在类上添加成员数据,示例如下)。
class Person {
static anotherName = 'P';
}
const p = new Person();
console.log(Person.anotherName); // 'P'
// 手动添加
class Per {
say() {
console.log(`${Per.greeting} ${this.name}`);
}
}
const p = new Per();
// 在类上定义数据成员。
Per.greeting = 'My name is';
// 在原型上定义数据成员。
Per.prototype.name = 'Lucky';
p.say(); // 'My name is Lucky'
类定义语法支持在原型和类本身上定义生成器方法。也可以通过添加默认的迭代器,将类实例定义为可迭代对象。这部分在迭代器和生成器部分已学习,这里只罗列代码。
class Per {
constructor(...rest) {
this.arg = [...rest];
}
*createNicknameIterator() {
yield 'Jack';
yield 'Jake';
yield 'J-Dog';
}
static *createJobIterator() {
yield* ['Butcher', 'Baker', 'Candlestick maker'];
}
// 生成器方法作为默认迭代器
// *[Symbol.iterator]() {
// yield * this.arg.entries();
// }
// 只返回迭代器实例
[Symbol.iterator]() {
return this.arg.entries();
}
}
const p = new Per();
let nicknameIterator = p.createNicknameIterator();
console.log(nicknameIterator.next().value); // 'Jack'
console.log(nicknameIterator.next().value); // 'Jake'
console.log(nicknameIterator.next().value); // 'J-Dog'
let jobIterator = Per.createJobIterator();
console.log(jobIterator.next().value); // 'Butcher'
console.log(jobIterator.next().value); // 'Baker'
console.log(jobIterator.next().value); // 'Candlestick maker'
const p2 = new Per('401', '402', '403');
for (let [index, errorCode] of p2) {
console.log(index + '=>' + errorCode);
}
// '0=>401' '1=>402' '2=>403'
ES6 新增特性原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。
ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有 [[Constructor]] 和原型的对象(这就包括着普通的构造函数)。派生类都会通过原型链访问到类和原型上定义的方法。
// 超类
class Sclass {
identifyPrototype(str) {
console.log(str, this);
}
static identifyClass(str) {
console.log(str, this);
}
}
// 派生类,可以使用表达式的形式。
// const Dclass = class extends Sclass {};
class Dclass extends Sclass {}
let s0 = new Sclass();
let d0 = new Dclass();
s0.identifyPrototype('s'); // s Sclass {}
d0.identifyPrototype('d'); // d Dclass {}
Sclass.identifyClass('S'); // S [class Sclass]
Dclass.identifyClass('D'); // D class Dclass extends Sclass {}
console.log(Sclass.prototype === Dclass.prototype.__proto__); // true
派生类的方法可以通过 super 关键字引用它们的原型。需要注意的是 super 是一个关键字,并且有一些特殊的语法结构。super 不是一个指向原型对象的变量。super 语法总结:
class Sclass {
constructor(name) {
// SyntaxError: 'super' keyword unexpected here
// super();
this.name = name;
}
say() {
console.log('log-in-S');
}
}
class Dclass extends Sclass {
constructor(name, identify) {
// 不能再调用 super() 之前引用 this,否则会抛出 ReferenceError
super(name);
this.identify = identify;
}
say() {
// 通过 super 调用父类原型对象上的成员
super.say();
console.log('log-in-D');
}
}
let d0 = new Dclass('派生类', 'd0');
console.log(d0); // Dclass { name: '派生类', identify: 'd0' }
d0.say(); // 'log-in-S' 'log-in-D'
// ----------第5点和第6点------
class Sclass {
constructor(name) {
this.name = name;
}
}
// class Dclass extends Sclass {}
class Dclass extends Sclass {
constructor(name) {
// super(name);
return {};
}
}
let d1 = new Dclass('派生类');
console.log(d1); // 对应结果分别为 Dclass { name: '派生类' } {}
ES6 给类构造函数和静态方法添加了内部属性 [[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,且供 JavaScript 引擎内部访问。super 始终定义为 [[HomeObject]] 的原型。
抽象基类是指通过在基类的构造函数中对类实例化过程进行控制,从而达到定义特殊基类的概念。例如定义一个可供继承,但本身不能被实例化的基类。定义派生类必须具有某个方法的基类。
class Sclass {
constructor(name) {
if (new.target === Sclass) {
throw new Error(`${new.target} 不能被实例化`);
}
if (!this.say) {
throw new Error(`${new.target} 必须定义一个 say 方法`);
}
}
}
class Dclass extends Sclass {}
// Error: class Sclass{...} 不能被实例化
let s0 = new Sclass();
// Error: class Dclass extends Sclass {} 必须定义一个 say 方法
// let d0 = new Dclass();
ES6 类的继承语法使得内置引用类型的继承变得更加方便。但是有些内置类型的方法会返回新的实例。默认情况下,返回实例的类型与原始实例的类型是一致的。如果需要覆盖这个默认行为,可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类。
class DeriveArray extends Array {
shuffle() {
this.map((item, index) => {
const j = Math.floor(Math.random() * this.length);
[this[index], this[j]] = [this[j], this[index]];
});
}
// static get [Symbol.species]() {
// return Array;
// }
}
let a = new DeriveArray('A', '2', '3', '4', 'J', 'Q', 'K');
console.log(a instanceof Array); // true
console.log(a instanceof DeriveArray); // true
// DeriveArray(7) ['A', '2', '3', '4', 'J', 'Q', 'K']
console.log(a);
a.shuffle();
// DeriveArray(7) ['4', 'Q', 'J', 'A', 'K', '3', '2']
console.log(a);
let a0 = a.filter(item => !isNaN(item));
console.log(a0); // DeriveArray(3) [ '3', '2', '4' ]
console.log(a0 instanceof DeriveArray); // true
// 覆盖 Symbol.species 访问器后创建返回的实例使用的类不再跟原始实例一样
// console.log(a0 instanceof DeriveArray); // false
略。。。
很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了“组合胜过继承”的软件设计原则。