15.4 子类的细节

在 ECMAScript 6 中,子类看起来像下面这样。

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    ···
}

class ColorPoint extends Point {
    constructor(x, y, color) {
        super(x, y);
        this.color = color;
    }
    ···
}

let cp = new ColorPoint(25, 8, 'green');

这段代码产生下面的对象。

下一小节讲解原型链(上图中的两列),该节的后面一节介绍了 cp 是如何分配内存和初始化的。

15.4.1 原型链

上上图中,可以看到有两条原型链(对象通过 [[Prototype]] 关系连接,该关系就是继承关系):

  • 左侧的一列:类(函数)。继承类的原型是它继承的类。基类的原型是 Function.prototypeFunction.prototype 也是函数的原型:
> const getProto = Object.getPrototypeOf.bind(Object);

> getProto(Point) === Function.prototype
true
> getProto(function () {}) === Function.prototype
true
  • 右侧的一列:实例的原型链。一个类的整个目的就是设置这条原型链。这条原型链以 Object.prototypeObject 的原型是 null )结束,同时,Object.prototype 也是通过对象字面量创建的对象的原型:
> const getProto = Object.getPrototypeOf.bind(Object);

> getProto(Point.prototype) === Object.prototype
true
> getProto({}) === Object.prototype
true

图中左侧一列表明了静态方法也会被继承。

15.4.2 分配和初始化实例

类构造器之间的数据流和 ES5 规范中的继承方法有差异。实际上,看起来大致如下所示:

// Instance is allocated here
function Point(x, y) {
    // Performed before entering this constructor:
    this = Object.create(new.target.prototype);

    this.x = x;
    this.y = y;
}
···

function ColorPoint(x, y, color) {
    // Performed before entering this constructor:
    this = uninitialized;

    this = Reflect.construct(Point, [x, y], new.target); // (A)
        // super(x, y);

    this.color = color;
}
Object.setPrototypeOf(ColorPoint, Point);
···

let cp = Reflect.construct( // (B)
             ColorPoint, [25, 8, 'green'],
             ColorPoint);
    // let cp = new ColorPoint(25, 8, 'green');

在 ES6 和 ES5 中,实例对象创建的地方不一样:

  • 在 ES6 中,在基类的构造器中创建,构造器调用链的末端。
  • 在 ES5 中,在 new 操作符中创建,构造器调用链的始端。

前面的代码使用了两个新的 ES6 特性:

  • new.target 是一个隐式的参数,所有的函数都有。它用于构造器调用,而 this 用于方法调用。
    • 如果构造器已经显示地通过 new 调用了,它的值就是当前构造器(行 B )。
    • 如果构造器通过 super() 调用,它的值就是发出调用的构造器中的 new.target (行 A )。
    • 在一个普通的函数调用中,它的值是 undefined 。这意味着可以使用 new.target 来区分一个函数是否被当成普通函数调用还是被当成构造器调用(通过 new )。
    • 在箭头函数内部, new.target 指向祖先作用域中最近的一个非箭头函数中的 new.target
  • Reflect.construct() 完成构造器调用,最后一个参数用于指定 new.target

此种子类化方式的优点就是让普通的代码能够子类化内置的构造器(比如 ErrorArray )。后面一节讲解了为什么需要一种不同的方式。

15.4.2.1 安全检查

  • 在继承的构造器中,如果在 super() 调用之前就访问 this ,会抛出错误,因为此时实例尚未创建, this 尚未初始化。
  • 一旦 this 初始化了,调用 super() 就会产生一个 ReferenceError 错误。这避免了调用两次 super() 的问题。
  • 如果构造器隐式返回(不使用 return ),那么返回结果就是 this 。如果 this 未被初始化,会抛出 ReferenceError 错误。这避免了忘记调用 super() 的问题。
  • 如果构造器显示地返回一个非对象(包括 undefinednull ),最终返回结果就是 this (这种行为需要保持与 ES5 和更早的版本兼容)。如果 this 未被初始化,就会抛出 TypeError 错误。
  • 如果构造器显示地返回一个对象,该对象会被用作最终返回结果。此时 this 是否初始化已经没有关系了。

15.4.2.2 extends 子句

让我们看看 extends 子句是如何影响类的设置的( Sect. 14.5.14 of the spec )。

extends 子句的值必须是“可构造的( constructible )”(可通过 new 调用)。但是允许为 null

class C {
}
  • 构造器类型:基类构造器
  • C 的原型: Function.prototype (类似于普通的函数)
  • C.prototype 的原型: Object.prototype (也是通过对象字面量创建的对象的原型)
class C extends B {
}
  • 构造器类型:继承造器
  • C 的原型: B
  • C.prototype 的原型: B.prototype
class C extends Object {
}
  • 构造器类型:继承构造器
  • C 的原型: Object
  • C.prototype 的原型: Object.prototype

注意下面的代码与第一种情况的微妙区别:如果没有 extends 子句,那么类就是积累并且负责创建实例。如果一个类继承自 Object ,就是一个继承类,并且 Object 创建实例。最终的实例(包括它们的原型)都是一样的,但是实例构建过程是不一样的。

class C extends null {
}
  • 构造器类型:继承构造器
  • C 的原型: Function.prototype
  • C.prototype 的原型: null

这种类就避免了 Object.prototype 出现在原型链中。但是基本没有什么用。并且,必须得小心:通过 new 调用这样的类会抛出错误,因为默认的构造器会调用父构造器,而 Function.prototype (父构造器)不能作为构造器调用。唯一一种避免该错的方式是添加一个返回对象的构造器

class C extends null {
    constructor() {
        let _this = Object.create(new.target.prototype);
        return _this;
    }
}

new.target 确保 C 能正确地继承 - _this 的原型总是 new 的操作数。

15.4.3 为什么在 ES5 中不能子类化内置构造器?

在 ECMAScript 5 中,绝大多数构造器不能被子类化(有几种迂回方法)。

要理解为什么,让我们使用标准的 ES5 方式去子类化 Array 。我们将会很快看到,这是不行的。

function MyArray(len) {
    Array.call(this, len); // (A)
}
MyArray.prototype = Object.create(Array.prototype);

很不幸,如果实例化 MyArray ,我们发现不会正确地工作:在添加元素的时候,实例的 length 属性并不会自动变化:

> var myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
0

有两个障碍导致 myArr 无法成为一个正确的数组。

第一个障碍:初始化。传递给构造器 Array (行 A 处)的 this 会被忽略。这意味着不能使用 Array 来设置 MyArray 创建的实例。

> var a = [];
> var b = Array.call(a, 3);
> a !== b  // a is ignored, b is a new object
true
> b.length // set up correctly
3
> a.length // unchanged
0

第二个障碍:分配。通过 Array 创建的实例对象是特异的( exotic )( ECMAScript 规范使用这个术语来描述那些有不同于普通对象特性的对象): Array 创建的实例的属性 length 追踪和影响数组元素的管理。一般地,可以创建特异对象,但是不能将一个已存在的普通对象转换成特意对象。不幸的是,这就是 Array 必须要做的,在行 A 执行的时候:必须要将 MyArray 创建的普通的对象转换成特异对象。

15.4.3.1 解决方案: ES6 的子类化

在 ECMAScript 6 中,子类化 Array 看起来像下面这样:

class MyArray extends Array {
    constructor(len) {
        super(len);
    }
}

这会有效(但是转换器肯定不会支持,这依赖于 JavaScript 本地引擎支持):

> let myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
1

现在可以看下 ES6 方式的子类化是怎么绕过这两个障碍的:

  • 实例创建是在基构造器中进行的,这意味着 Array 可以生成一个特异的对象。相对于绝大多数的 new 一个对象的方式都依赖于子构造器的行为,这一步需要基构造器知道 new.target ,并且将 new.target.prototype 设置为分配出来的实例的原型。
  • 实例初始化也会在基构造器中进行,派生的构造器获得初始化了的对象,并运用这个对象继续执行,而不是将自己的实例传递给父构造器,然后让父构造器去设置这个实例。

15.4.4 在方法中访问父属性

下面的 ES6 代码在行 B 调用了一个父方法:

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() { // (A)
        return `(${this.x}, ${this.y})`;
    }
}

class ColorPoint extends Point {
    constructor(x, y, color) {
        super(x, y);
        this.color = color;
    }
    toString() {
        return super.toString() // (B)
               + ' in ' + this.color;
    }
}

let cp = new ColorPoint(25, 8, 'green');
console.log(cp.toString()); // (25, 8) in green

要理解父调用的原理,让我们看下 cp 的对象图:

ColorPoint.prototype 调用(行 B )在本类中覆盖了的父类方法(开始于行 A )。我们称方法存放的对象是此方法的宿主对象( home object )。例如, ColorPoint.prototypeColorPoint.prototype.toString() 的宿主对象。

在行 B 调用父方法,总共包含三个步骤:

1、开始在宿主对象的原型链上搜索当前方法。 2、找到名为 toString 的方法。 3、用当前的 this 调用找到的方法。这样做的原因是:调用的父方法一定要能够访问到相同的实例属性(在我们的例子中,是 cp 的属性)。

注意,即便仅获取或设置一个属性(不是调用方法),仍然需要在第三步中考虑 this ,因为这个属性可能是通过 getter 或 setter 实现的。

让我们用三种不同的,但是等价的方式来描述这些步骤:

// Variation 1: super-method calls in ES5
var result = Point.prototype.toString.call(this) // steps 1,2,3

// Variation 2: ES5, refactored
var superObject = Point.prototype; // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3

// Variation 3: ES6
var homeObject = ColorPoint.prototype;
var superObject = Object.getPrototypeOf(homeObject); // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3

第三种就是 ECMAScript 6 处理父调用的方式。这种方式通过两个内部绑定的变量来支持,这两个绑定变量是函数内部环境提供的(函数环境给作用域中的变量提供存储空间,所谓的绑定变量):

  • [[thisValue]] :该内部绑定变量在 ECMAScript 5 中也存在,存放在 this 值中。
  • [[HomeObject]] :指向环境函数的宿主对象。 [[HomeObject]] 是一个方法的内部属性,方法在被调用的时候, super 就会指向这个对象。绑定变量和内部属性都是 ECMAScript 6 新引入的。

现在方法是一种特殊的函数

在类中,一个使用 super 的方法定义会创建一种特殊的函数:仍然是一个函数,但是有内部的 [[HomeObject]] 属性。这个属性通过方法定义设定,在 JavaScript 代码中不能修改。因此,不可以自以为是地将这样的方法移到一个不同的对象上面去(但是这在将来的 ECMAScript 版本中可能可以这样做)。

15.4.4.1 什么地方可以使用 super

当原型链参与进来的时候,访问父属性就变得很方便,这就是为什么能在对象字面量和类(类既可以是被继承的,也可以是不被继承的;既可以是静态的,也可以是非静态的)的方法定义中使用 super

在下列场景中不能使用 super 访问属性:函数声明中,函数表达式中和生成器函数中。

15.4.4.2 不能移动使用 super 的方法

不能移动使用 super 的方法:这样的方法有一个内部的属性 [[HomeObject]] ,该属性与创建此方法的对象绑在一起。如果通过赋值的方式移动方法,方法中的 [[HomeObject]] 属性将继续指向原来的对象的父属性。在未来的 ECMAScript 版本中,可能会有一种方式来移动这样的方法。

results matching ""

    No results matching ""