【译】《Understanding ECMAScript6》- 第五章-Class

时间:2022-04-25
本文章向大家介绍【译】《Understanding ECMAScript6》- 第五章-Class,主要内容包括目录、ES5中的拟Class结构、Class声明、Class表达式、存储器属性、静态成员、派生类、静态成员、动态派生类、内置对象的继承、new.target、总结、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

目录

自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时需要注意以下几点区别

  1. Class不会被声明提升。与let声明类似,Class在声明语句执行之前是不能被访问的;
  2. Class声明语句内部的代码全部运行在严格模式下;
  3. Class的所有方法都是不可枚举的。而常规的自定义类型需要使用Object.defineProperty()来定义非枚举属性;
  4. 必须使用new调用Class构造函数,否则会报错;
  5. 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()是需要注意以下几点

  1. super()只能在派生类中使用,否则会产生错误;
  2. super()必须在操作this之前使用。因为super()的作用便是初始化this的指向,如果在super()之前操作this会产生错误;
  3. 构造函数中不使用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经典继承模型有以下几点区别:

  1. ES5经典继承模型中,this的由派生类型(如本例的MyArray)初始化,然后通过Array.apply()调用基础类型(Array)的构造函数。也就是说,this最初是MyArray的一个实例,随后被赋予了基础类型Array的属性。
  2. 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语言非常重要的模块,它提供了更加功能化的机制以及更加简洁的语法,使自定义类型的创建过程更加安全统一。