博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
【译】《Understanding ECMAScript6》- 第五章-Class
阅读量:5020 次
发布时间:2019-06-12

本文共 16666 字,大约阅读时间需要 55 分钟。

目录

自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);  // trueconsole.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);     // trueconsole.log(person instanceof Object);          // trueconsole.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等价于以下代码:

// 等价于PersonClasslet 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 okayFoo = "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);     // trueconsole.log(person instanceof Object);          // trueconsole.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);   // trueconsole.log("set" in descriptor);   // trueconsole.log(descriptor.enumerable); // false

译者注:Object.getOwnPropertyDescriptor() 返回指定对象上一个自有属性对应的属性描述符,包括value、writable、get、set、configurable、enumerable。

上述代码中,CustomHTMLElement类是对指定DOM一系列操作的简单封装。html的setter和getter方法是原生innerHTML方法的事件代理。存储器属性归属于CustomHTMLElement.prototype,并且是不可枚举的。上述代码改写为常规函数模式如下:

// direct equivalent to previous examplelet 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 methodPersonType.create = function(name) {    return new PersonType(name);};// instance methodPersonType.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());              // 9console.log(square instanceof Square);      // trueconsole.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());              // 9console.log(square instanceof Square);      // trueconsole.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 errorvar 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);     // trueconsole.log(rect.getArea());                // 12console.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());               // 9console.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());               // 9console.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());               // 9console.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);         // 1colors.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);         // 0colors.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);         // 1colors.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 Rectanglevar 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等于Squarevar 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 errorvar y = new Rectangle(3, 4);        // no errorconsole.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语言非常重要的模块,它提供了更加功能化的机制以及更加简洁的语法,使自定义类型的创建过程更加安全统一。

转载于:https://www.cnblogs.com/ihardcoder/articles/5293392.html

你可能感兴趣的文章
bug记录_signalr执行$.connnection.testhub结果为空
查看>>
【转】常用的latex宏包
查看>>
[TMS320C674x] 一、GPIO认识
查看>>
酷狗的皮肤文件存放在哪
查看>>
iOS RunLoop简介
查看>>
C++的引用
查看>>
T-SQL查询进阶--深入浅出视图
查看>>
MapKeyboard 键盘按键映射 机械革命S1 Pro-02
查看>>
Android读取url图片保存及文件读取
查看>>
完整ASP.Net Excel导入
查看>>
判断CPU大小端示例代码
查看>>
ARTS打卡第13周
查看>>
循环队列的运用---求K阶斐波那契序列
查看>>
pta 编程题14 Huffman Codes
查看>>
初始化bootstrap treeview树节点
查看>>
python selenium向<sapn>标签中写入内容
查看>>
JS常用坐标
查看>>
使用”结构化的思考方式“来编码和使用”流程化的思考方式“来编码,孰优孰劣?...
查看>>
C#调用斑马打印机打印条码标签(支持COM、LPT、USB、TCP连接方式和ZPL、EPL、CPCL指令)【转】...
查看>>
关于git的认证方式
查看>>