【译】《Understanding ECMAScript6》- 第五章-Class
目录
自JavaScript面世以来,许多开发者疑惑为何JavaScript没有Class。大多数面向对象语言都支持Class以及Class继承,尽管部分开发者认为JavaScript语言并不需要Class,但事实上很多第三方库通过工具方法来模拟Class。
ES6正式引入了Class规范。为了保证JavaScript语言的动态性,ES6的Class规范与其他面向对象语言的Class并不完全相同。
ES5中的拟Class结构
在详细讲述Class之前,我们首先了解一下Class的内层机制。ES5甚至更早的版本中,在没有Class的环境下,最接近Class的模式是创建一个构造函数并且扩展它的prototype方法。这种模式通常被称为自定义类型。如下:
function PersonType(name) {
this.name = name;
}
PersonType.prototype.sayName = function() {
console.log(this.name);
};
let person = new PersonType("Nicholas");
person.sayName(); // outputs "Nicholas"
console.log(person instanceof PersonType); // true
console.log(person instanceof Object); // true
上述代码中,PersonType是一个构造函数,它创建了一个name属性。sayName()方法是prototype的扩展方法,它可以被PersonType的所有实例使用。随后,通过new创建了PersonType的一个实例person对象,根据原型链继承原理,person同时也是Object的实例。
这种机制是各种拟Class模式的理论基础,也是ES6中Class规范的基础。
Class声明
Class的声明语法与其他语言类似,采用class关键字+类名的语法。Class内部的语法与Object字面量方法的简洁语法类似,只不过方法之间不必使用逗号隔开。将上例改写为Class如下:
class PersonClass {
// 等价于构造函数PersonType
constructor(name) {
this.name = name;
}
// 等价于PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
}
let person = new PersonClass("Nicholas");
person.sayName(); // outputs "Nicholas"
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"
上述代码的PersonClass与前例中的PersonType作用类似。Class声明内部使用constructor关键字定义构造函数。方法的定义可以使用简洁语法,不必使用function关键字。除constructor以外的方法名可以根据产品需求自由定义。
私有属性只能在Class的构造函数内声明。比如本例中的name属性便是私有属性,属性值与实例声明时的传参有关。笔者强烈推荐所有的私有属性均在构造函数内创建,以便统一管理。
译者注:私有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性
实际上,ES6中的Class只是在语法更加语义化,本质上仍然是基于prototype原理。比如本例中的PersonClass本质上是一个构造函数,typeof PersonClass的运行结果为"function"。sayName()同前例的PersonType.prototype.sayName()一样,是PersonClass.prototype的扩展方法。
但是Class与常规的构造函数并不完全相同,再使用Class时需要注意以下几点区别:
- Class不会被声明提升。与let声明类似,Class在声明语句执行之前是不能被访问的;
- Class声明语句内部的代码全部运行在严格模式下;
- Class的所有方法都是不可枚举的。而常规的自定义类型需要使用Object.defineProperty()来定义非枚举属性;
- 必须使用new调用Class构造函数,否则会报错;
- Class不能被自身的方法函数重命名。
基于以上规范,前例中的PersonClass等价于以下代码:
// 等价于PersonClass
let PersonType2 = (function() {
"use strict";
const PersonType2 = function(name) {
// 确保只能被new调用
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.name = name;
}
Object.defineProperty(PersonType2.prototype, "sayName", {
value: function() {
console.log(this.name);
},
enumerable: false,
writable: true,
configurable: true
});
return PersonType2;
}());
虽然不使用Class也可以实现同样的功能,但是Class的语法更加简洁易读。
常量类名
Class的类名与const类似,在其内部是一个不可变的常量。也就是说,Class不能被自身的方法函数重命名,但是可以在外部进行重命名。如下:
class Foo {
constructor() {
Foo = "bar"; // throws an error when executed
}
}
// but this is okay
Foo = "baz";
上述代码中的,Foo在其内部代码与外部代码中的行为完全不同。在内部,Foo类名是一个不能被重写的常量,尝试重写会抛出错误;在外部,Foo是一个类似let声明的变量,可以被随意重写。
Class表达式
Class与function都有两种声明方式:字面量声明和表达式声明。字面量声明即关键字(class/function)+类名/函数名。函数的表达式声明语法可以省略函数名,类似的,Class的表达式声明语法也可以省略类名:
// class expressions do not require identifiers after "class"
let PersonClass = class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
};
let person = new PersonClass("Nicholas");
person.sayName(); // outputs "Nicholas"
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"
Class的字面量声明与表达式声明是完全等价的。class关键字后的类名可以被省略,也可以不省略,如下:
let PersonClass = class PersonClass2 {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
};
console.log(PersonClass === PersonClass2); // true
上述代码中的PersonClass和PersonClass2是同一个class的引用,两者是完全等价的。
Class表达式还有一些其他很有趣的使用场景。比如可以作为参数传入函数:
function createObject(classDef) {
return new classDef();
}
let obj = createObject(class {
sayHi() {
console.log("Hi!");
}
});
obj.sayHi(); // "Hi!"
上述代码中,匿名class表达式作为createObject()的参数使用,在函数内部使用new创建并返回了一个class实例。
Class表达式还可以通过立即执行构造函数来创建单例。这种模式下,必须使用new调用class表达式,并且class表达式的末尾需要圆括号传入参数。如下:
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}("Nicholas");
person.sayName(); // "Nicholas"
上述代码中,匿名class表达式被创建时立即执行构造函数。这种模式可以使用class语法创建单例,而不必遗留class的引用。
Class声明与class表达式只在语法上存在差异,两者可以互相替换。与函数声明/表达式不同的是,class声明/表达式并不会被声明提升。
存储器属性
尽管私有属性应该在class的构造函数内创建,class允许在构造函数以外的区域定义其原型的存储器属性,语法类似Object字面量。创建getter的语法是get关键字+空格+方法名;创建setter的语法是set关键字+空格+方法名。如下:
class CustomHTMLElement {
constructor(element) {
this.element = element;
}
get html() {
return this.element.innerHTML;
}
set html(value) {
this.element.innerHTML = value;
}
}
var descriptor = Object.getOwnPropertyDescriptor(CustomHTMLElement.prototype,"html");
console.log("get" in descriptor); // true
console.log("set" in descriptor); // true
console.log(descriptor.enumerable); // false
译者注:Object.getOwnPropertyDescriptor() 返回指定对象上一个自有属性对应的属性描述符,包括value、writable、get、set、configurable、enumerable。
上述代码中,CustomHTMLElement类是对指定DOM一系列操作的简单封装。html的setter和getter方法是原生innerHTML方法的事件代理。存储器属性归属于CustomHTMLElement.prototype,并且是不可枚举的。上述代码改写为常规函数模式如下:
// direct equivalent to previous example
let CustomHTMLElement = (function() {
"use strict";
const CustomHTMLElement = function(element) {
// make sure the function was called with new
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.element = element;
}
Object.defineProperty(CustomHTMLElement.prototype, "html", {
enumerable: false,
configurable: true,
get: function() {
return this.element.innerHTML;
},
set: function(value) {
this.element.innerHTML = value;
}
});
return CustomHTMLElement;
}());
与前例的class语法相比,上述代码要繁琐很多。
译者注:请注意前例class语法中的getter和setter方法的名称是相同的,因为两者都是CustomHTMLElement.prototype.html的存储器属性。这一点容易产生困惑,本例中Object.defineProperty()则一目了然。
静态成员
为构造函数添加额外的方法来模拟静态成员是JavaScript中常用的模式之一。如下:
function PersonType(name) {
this.name = name;
}
// static method
PersonType.create = function(name) {
return new PersonType(name);
};
// instance method
PersonType.prototype.sayName = function() {
console.log(this.name);
};
var person = PersonType.create("Nicholas");
在其他编程语言中,工厂方法PersonType.create()被称为静态方法,因为它与PersonType的实例无关。
Class简化了静态方法的创建过程,在方法名或存储器属性之前使用static修饰即可。前例中的代码可以改写为以下形式:
class PersonClass {
// 等价于构造函数PersonType
constructor(name) {
this.name = name;
}
// 等价于PersonType.prototype.sayName
sayName() {
console.log(this.name);
}
// 等价于PersonType.create
static create(name) {
return new PersonClass(name);
}
}
let person = PersonClass.create("Nicholas");
PersonClass使用static修饰符定义了一个静态方法create()。
static修饰符可以用于除constructor以外的任何class方法和存储器属性。
与class的其他成员一样,静态成员默认不可枚举。
派生类
ES6之前实现继承需要非常繁琐的逻辑,比如:
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
function Square(length) {
Rectangle.call(this, length, length);
}
Square.prototype = Object.create(Rectangle.prototype, {
constructor: {
value:Square,
enumerable: true,
writable: true,
configurable: true
}
});
var square = new Square(3);
console.log(square.getArea()); // 9
console.log(square instanceof Square); // true
console.log(square instanceof Rectangle); // true
上述代码中,Square继承自Rectangle。首先,以Rectangle.prototype为原型创建Square.prototype;其次,Square函数内部需要使用call()函数调用Rectangle。实现继承的逻辑太过繁琐,不仅仅令新手望而却步,即使是经验丰富的开发者也会在此跌跟头。
ES6规范并简化了实现继承的方式,使用extends关键字便可以指定派生类的父类。派生类内部可以使用super()调用父类的方法。基于此规范,前例的代码可以简化为以下形式:
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
}
class Square extends Rectangle {
constructor(length) {
// 等同于前例的Rectangle.call(this, length, length)
super(length, length);
}
}
var square = new Square(3);
console.log(square.getArea()); // 9
console.log(square instanceof Square); // true
console.log(square instanceof Rectangle); // true
Square类使用extends关键字继承自Rectangle。Square的构造函数内使用super()调用Rectangle的构造函数并传入指定参数。需要注意的是,Rectangle只在派生类声明时,即extends之后使用,这是与ES5不同的地方。
译者注:最后一句话可以这样理解,派生类内部调用父类全部使用super(),而不用直接使用类名来调用父类。
如果派生类内显式定义了构造函数,那么构造函数内部必须使用super()调用父类,否则会产生错误。如果构造函数没有被显式定义,class会默认隐式定义一个构造函数,并且构造函数内部使用super()调用父类,同时传入生成class实例时的所有参数。例如,以下两个class是完全等价的:
class Square extends Rectangle {
//constructor没有被显式定义
}
// 等价于
class Square extends Rectangle {
constructor(...args) {
super(...args);
}
}
上述代码中的第二种写法表示的是构造函数未被显式定义时的行为。所有的参数按顺序被传入父类的构造函数。笔者建议始终显式定义构造函数,以保证参数的正确性。
使用super()是需要注意以下几点:
- super()只能在派生类中使用,否则会产生错误;
- super()必须在操作this之前使用。因为super()的作用便是初始化this的指向,如果在super()之前操作this会产生错误;
- 构造函数中不使用super()的唯一场景是返回一个Object。
Class方法
派生类中定义的方法会覆盖父类中的同名方法。例如,派生类Square中定义了getArea()方法:
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// override and shadow Rectangle.prototype.getArea()
getArea() {
return this.length * this.length;
}
}
上述代码中,派生类Square的定义了方法getArea(),Square的实例便不再调用Rectangle.prototype.getArea()。当然,你仍然可以使用super.getArea()间接调用父类的方法,如下:
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// override, shadow, and call Rectangle.prototype.getArea()
getArea() {
return super.getArea();
}
}
Class方法没有内部属性[[Construct]],不能被new调用。如下:
// throws an error
var x = new Square.prototype.getArea();
正是由于class方法不可被new调用,减少了被错误使用导致的意外状况。
与Object字面量类似,class方法名可以使用方括号动态运算。如下:
let methodName = "getArea";
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// override, shadow, and call Rectangle.prototype.getArea()
[methodName]() {
return super.getArea();
}
}
上述代码与前例等价。唯一的区别便是getArea()的方法名是通过方括号运算得到的。
静态成员
派生类中仍然可以使用其父类的静态成员。如下:
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
static create(length, width) {
return new Rectangle(length, width);
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}
var rect = Square.create(3, 4);
console.log(rect instanceof Rectangle); // true
console.log(rect.getArea()); // 12
console.log(rect instanceof Square); // false
上述代码中,Rectangle有一个静态方法create()。派生类可以调用Square.create(),但是功能等价于Rectangle.create()。
动态派生类
派生类强大的功能之一便是可以通过表达式动态生成派生类。extends可以用于任何表达式,只要表达式可以生成一个具有[[Construct]]和prototype属性的函数,就可以生成一个派生类。例如:
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x instanceof Rectangle); // true
上述代码中的Rectangle是ES5规范的常规函数,而Square是一个类。由于Rectangle具备[[Construct]]和prototype属性,Square类可以直接继承它。
extends语法的动态性可以为很多强大的功能提供理论基础。比如动态生成继承对象:
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
function getBase() {
return Rectangle;
}
class Square extends getBase() {
constructor(length) {
super(length, length);
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x instanceof Rectangle); // true
上述代码功能与前例等价。getBase()函数在class声明语句中被执行。开发者可以继续增强getBase()函数的动态性,以产生不同的被继承对象。比如,我们可以使用mixin模式:
let SerializableMixin = {
serialize() {
return JSON.stringify(this);
}
};
let AreaMixin = {
getArea() {
return this.length * this.width;
}
};
function mixin(...mixins) {
var base = function() {};
Object.assign(base.prototype, ...mixins);
return base;
}
class Square extends mixin(AreaMixin, SerializableMixin) {
constructor(length) {
super();
this.length = length;
this.width = length;
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x.serialize()); // "{"length":3,"width":3}"
上述代码中的mixin()函数接受任意数目的参数,将这些参数作为扩展属性赋值给base.prototype,并返回base函数以使extends语法生效。需要注意的是,你仍然需要再显式定义的构造函数内调用super()。
Square的实例x同时具备AreaMixin的getArea()方法和SerializableMixin的serialize方法。
虽然extends可以用于任意的表达式,但并非所有的表达式都能够产生一个合法的class。以下表达式会产生错误:
- null
- 生成器表达式(第八章会详细讲述)
以上表达式生成的class不能被创建实例,否则会抛出错误。
内置对象的继承
一直以来,开发者都希望能够继承JavaScript数组并且自定义特殊的数组类型。然而在ES5及其早期版本中并不支持这种需求:
// 内置数组对象的行为
var colors = [];
colors[0] = "red";
console.log(colors.length); // 1
colors.length = 0;
console.log(colors[0]); // undefined
//ES5环境中尝试继承内置数组对象
function MyArray() {
Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArray,
writable: true,
configurable: true,
enumerable: true
}
});
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0
colors.length = 0;
console.log(colors[0]); // "red"
上述代码是JavaScript实现继承的经典方式,但是最终得到的结果并未达到预期。length属性以及枚举属性的行为与内置数组对象的行为并不相同,这是由于不论是Array.apply(),还是通过扩展prototype,派生类型的属性修改并未映射到基础类型。
译者注: 也就是说,修改colors.length并未改变内置数组类型的length。实际上,本例中的MyArray并非数组,而是一个类似于arguments的类数组对象。
ES6引入Class的目标之一,便是支持内置对象的继承。class的继承模型与ES5经典继承模型有以下几点区别:
- ES5经典继承模型中,this的由派生类型(如本例的MyArray)初始化,然后通过Array.apply()调用基础类型(Array)的构造函数。也就是说,this最初是MyArray的一个实例,随后被赋予了基础类型Array的属性。
- ES6的class继承模型中,this由基础类(Array)初始化,然后被派生类(MyArray)的构造函数修正。也就是说,this拥有基础类的所有属性和功能。
以下的class继承可以实现自定义数组类型的需求:
class MyArray extends Array {
// ...
}
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 1
colors.length = 0;
console.log(colors[0]); // undefined
上述代码中的MyArray继承自内置数组对象Array,与Array的行为完全一致。枚举属性与length属性互相影响,改变length属性的同时,枚举属性被更新。
另外,MyArray也继承了Array的静态成员,可以直接使用:
class MyArray extends Array {
// ...
}
var colors = MyArray.of(["red", "green", "blue"]);
console.log(colors instanceof MyArray); // true
上述代码中的静态方法MyArray.of()与Array.of()的行为一致,它创建了一个MyArray的实例而不是Array的实例。这是内置对象的静态方法与常规对象静态方法的不同之处。
译者注:请注意内置对象与常规对象的派生类中,静态成员表现的区别。
JavaScript的所有内置对象都支持class继承,并且派生类的行为与内置对象完全一致。
new.target
第二章里介绍了new.target与函数调用方式的关系。new.target也可以在class构造函数内使用,用来判断class的执行方式。这种场景下,new.target相当于class的构造函数,如下:
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
// new.target is Rectangle
var obj = new Rectangle(3, 4); // outputs true
译者注:要理解“new.target相当于class的构造函数”这句话,首先要理解class本质上是一个构造函数。根据第二章的讲诉,使用new调用构造函数时,new.target的取值是构造函数的函数名。
上述代码中,执行new Rectangle(3, 4)时,new.target等于Rectangle。Class本质上是一个特殊的构造函数,它只能被new调用,所以new.target始终在class的构造函数内被定义。不同的场景下,new.target的取值也不同:
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length)
}
}
// new.target等于Square
var obj = new Square(3); // 输出false
上述代码中创建Square实例时,Square类调用Rectangle的构造函数,所以Rectangle构造函数内的new.target等于Square。这种机制可以支持构造函数根据调用方式的不同,改变自身的行为模式。比如,利用new.target的工作原理可以创建抽象类(即不能被直接实例化的类):
// 抽象类
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error("This class cannot be instantiated directly.")
}
}
}
class Rectangle extends Shape {
constructor(length, width) {
super();
this.length = length;
this.width = width;
}
}
var x = new Shape(); // throws error
var y = new Rectangle(3, 4); // no error
console.log(y instanceof Shape); // true
上述代码中,new Shape()会抛出错误,因为Shape类的构造函数不允许new.target等于Shape。抽象类Shape不能被实例化,但是可以作为基类由派生类继承。
总结
ES6制订了class的正式规范,使JavaScript语言的编程思想更加接近其他面向对象语言。Class并不仅仅是ES5经典继承模式的语法规范,还增加了一系列强大的新功能。
Class机制建立在原型继承的基础上,非静态方法被赋予构造函数的prototype,静态方法直接赋予构造函数本身。Class的所有方法都是不可枚举的,这一点与内置对象的属性行为是一致的。另外,class只能作为构造函数使用,也就是只能被new调用,而不能作为常规函数执行。
Class继承机制允许从class、函数,甚至表达式生成派生类。这种机制可以提供多种途径和模式来创建一个新的class。并且,继承机制同样适用于内置对象(比如Array)。
Class被执行的方式不同,class构造函数内的new.target的取值也不同,利用这个机制可以满足一些特殊的需求。比如创建一个不能被实例化但是可以被继承的抽象类。
总之,class是JavaScript语言非常重要的模块,它提供了更加功能化的机制以及更加简洁的语法,使自定义类型的创建过程更加安全统一。