前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >【译】《Understanding ECMAScript6》- 第五章-Class

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

作者头像
寒月十八
发布2018-01-30 11:58:22
1K0
发布2018-01-30 11:58:22
举报
文章被收录于专栏:前端架构与工程
目录

自JavaScript面世以来,许多开发者疑惑为何JavaScript没有Class。大多数面向对象语言都支持Class以及Class继承,尽管部分开发者认为JavaScript语言并不需要Class,但事实上很多第三方库通过工具方法来模拟Class。

ES6正式引入了Class规范。为了保证JavaScript语言的动态性,ES6的Class规范与其他面向对象语言的Class并不完全相同。

ES5中的拟Class结构

在详细讲述Class之前,我们首先了解一下Class的内层机制。ES5甚至更早的版本中,在没有Class的环境下,最接近Class的模式是创建一个构造函数并且扩展它的prototype方法。这种模式通常被称为自定义类型。如下:

代码语言:javascript
复制
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如下:

代码语言:javascript
复制
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等价于以下代码:

代码语言:javascript
复制
// 等价于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不能被自身的方法函数重命名,但是可以在外部进行重命名。如下:

代码语言:javascript
复制
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的表达式声明语法也可以省略类名:

代码语言:javascript
复制
// 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关键字后的类名可以被省略,也可以不省略,如下:

代码语言:javascript
复制
let PersonClass = class PersonClass2 {
    constructor(name) {
        this.name = name;
    }
    sayName() {
        console.log(this.name);
    }
};

console.log(PersonClass === PersonClass2);  // true

上述代码中的PersonClass和PersonClass2是同一个class的引用,两者是完全等价的。

Class表达式还有一些其他很有趣的使用场景。比如可以作为参数传入函数:

代码语言:javascript
复制
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表达式的末尾需要圆括号传入参数。如下:

代码语言:javascript
复制
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关键字+空格+方法名。如下:

代码语言:javascript
复制
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,并且是不可枚举的。上述代码改写为常规函数模式如下:

代码语言:javascript
复制
// 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中常用的模式之一。如下:

代码语言: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修饰即可。前例中的代码可以改写为以下形式:

代码语言:javascript
复制
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之前实现继承需要非常繁琐的逻辑,比如:

代码语言:javascript
复制
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()调用父类的方法。基于此规范,前例的代码可以简化为以下形式:

代码语言:javascript
复制
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是完全等价的:

代码语言:javascript
复制
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()方法:

代码语言:javascript
复制
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()间接调用父类的方法,如下:

代码语言:javascript
复制
class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }

    // override, shadow, and call Rectangle.prototype.getArea()
    getArea() {
        return super.getArea();
    }
}

Class方法没有内部属性[[Construct]],不能被new调用。如下:

代码语言:javascript
复制
// throws an error
var x = new Square.prototype.getArea();

正是由于class方法不可被new调用,减少了被错误使用导致的意外状况。

与Object字面量类似,class方法名可以使用方括号动态运算。如下:

代码语言:javascript
复制
let methodName = "getArea";
class Square extends Rectangle {
    constructor(length) {
        super(length, length);
    }

    // override, shadow, and call Rectangle.prototype.getArea()
    [methodName]() {
        return super.getArea();
    }
}

上述代码与前例等价。唯一的区别便是getArea()的方法名是通过方括号运算得到的。

静态成员

派生类中仍然可以使用其父类的静态成员。如下:

代码语言:javascript
复制
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属性的函数,就可以生成一个派生类。例如:

代码语言:javascript
复制
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语法的动态性可以为很多强大的功能提供理论基础。比如动态生成继承对象:

代码语言:javascript
复制
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模式:

代码语言:javascript
复制
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及其早期版本中并不支持这种需求:

代码语言:javascript
复制
// 内置数组对象的行为
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继承可以实现自定义数组类型的需求:

代码语言:javascript
复制
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的静态成员,可以直接使用:

代码语言:javascript
复制
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的构造函数,如下:

代码语言:javascript
复制
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的取值也不同:

代码语言:javascript
复制
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的工作原理可以创建抽象类(即不能被直接实例化的类):

代码语言:javascript
复制
// 抽象类
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语言非常重要的模块,它提供了更加功能化的机制以及更加简洁的语法,使自定义类型的创建过程更加安全统一。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2016-03-18 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 目录
  • ES5中的拟Class结构
  • Class声明
    • 常量类名
    • Class表达式
    • 存储器属性
    • 静态成员
    • 派生类
      • Class方法
        • 静态成员
          • 动态派生类
            • 内置对象的继承
            • new.target
            • 总结
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档