logo

深入理解 JavaScript 中的原型继承 (2/2)

上篇文章讲了 JavaScript 中的原型继承和基于类的继承的重要区别,即原型继承的动态性。以及在继承一个类时如何利用其原型的构造函数、如何覆盖原型方法及修改原型链,其中最重要的是理解 Get 操作和 Set 操作的区别,这一点至关重要。

本篇继续讲一下如何决定基类方法中返回的类型。

实例类型的动态化

思考一下,在 JavaScript 中使用基类的方法创建一个实例时,这个实例的类型应该是和基类一致还是和派生类一致?

为了理解这个问题,先来看一下代码,一码胜千言。

内置类的不同行为

不知道大家有没有注意,JavaScript 的两个内置类 StringArray 在各自的派生类的行为是不同的:

class MyString extends String {}
class MyArray extends Array {}

const str = new MyString().concat(new MyString())
const arr = new MyArray().concat(new MyArray())

console.log(str instanceof MyString) // false
console.log(arr instanceof MyArray) // true

String 类上的 concat() 方法返回的实例仍然是 String 类型,即便这个方法是在派生类 MyString 上调用的,其返回的实例的类型仍然和其基类类型保持一致;而 Array 类上的 concat() 方法返回的实例类型和其派生类 MyArray 一致。

一般来说,当在基类中实现一个方法时,我们可以做到:

  1. 让方法返回的实例类型和基类一致
  2. 让方法返回的实例类型和派生类一致
  3. 让派生类来决定方法应该返回什么类型

虽然 Array 的行为像是第二种,但其实是第三种。我们的派生类 MyArray 可以告诉其基类 Array 的 concat() 方法应该返回什么类型。让我们分别看一下这些是如何做到的。

和基类一致

class Names {
    constructor(...names) {
        this.names = names
    }
    
    filter1(selector) {
        return new Names(...this.names.filter(selector))
    }
}

class ChildNames extends Names {}

const child = new ChildNames('Java', 'C#', 'JavaScript')

child.filter1(name => name.startsWith('Java'))
// Names { names: [ 'Java', 'JavaScript' ] }

Names 作为基类,其 filter1() 方法将返回值硬编码为 new Names(...),所以即便是在 Names 的派生类上调用 filter1() 返回类型也是 Names。这和 String 类的 concat () 方法有点像:无论运行时实例的类型是什么,返回类型都和基类保持一致。

方法就是将返回值类型硬编码为基类。

和派生类一致

在运行时,如果我们想调用 this 的实际类型的构造函数,而不是硬编码的类名,我们必须获得对构造函数的引用。要访问一个对象的构造函数,就需要获取它的原型并查询其原型的 constructor 属性。

class Names {
    constructor(...names) {
        this.names = names
    }
    
    filter2(selector) {
        const constructor = Reflect.getPrototypeOf(this).constructor
        return new constructor(...this.names.filter(selector))
    }
}

class ChildNames extends Names {}

const child = new ChildNames('Java', 'C#', 'JavaScript')

child.filter2(name => name.startsWith('Java'))
// ChildNames { names: [ 'Java', 'JavaScript' ] }

这段代码并不难理解,无非是把硬编码的类名改为运行时动态获取。

看到这里,我们可能会想到:如何做到兼而有之?即根据需要返回基类或派生类的类型。

动态配置

一旦我们获取了构造函数 (注意,JavaScript 中的类实际上就是一个构造函数,只是 ES6 扩展了语法),就可以进一步获取到 static 方法和属性。假设我们定义一个名为 kindHint 的 static 方法来作为提示:如果实例有这个类属性,就根据这个属性值来决定返回值类型;否则就回退到派生类类型。

class Names {
    constructor(...names) {
        this.names = names
    }
    
    filter3(selector) {
        const constructor = Reflect.getPrototypeOf(this).constructor.kindHint ||
            Reflect.getPrototypeOf(this).constructor
        return new constructor(...this.names.filter(selector))
    }
}

class ChildNames extends Names {}

const child = new ChildNames('Java', 'C#', 'JavaScript')

child.filter3(name => name.startsWith('Java'))
// ChildNames { names: [ 'Java', 'JavaScript' ] }

// 给 ChildNames1 类实现 kindHint 属性
class ChildNames1 extends Names {
    static get kindHint() { return Names }
}

const child1 = new ChildNames1('Java', 'C#', 'JavaScript')

child1.filter3(name => name.startsWith('Java'))
// Names { names: [ 'Java', 'JavaScript' ] }

基类 Names 的 filter3() 方法提供了选择:如果派生类存在 kindHint 属性,就根据属性来决定返回类型;否则直接返回派生类类型。

这个方法有点完美。唯一的遗憾是 kindHint 这个名字,如果派生类存在这个属性名但用于其他用途该怎么办?这就要求我们要有一个唯一、独特的名字。说到唯一、独特,不禁让人浮想联翩到 Symbol

这就是终极

我们可以创建自己的 Symbol,不过 JavaScript 已经提供了一个用于此目的的 Symbol:Symbol.speciesSymbol.species 用于传递创建派生类的构造函数。于是上面的代码可以重写为:

class Names {
    constructor(...names) {
        this.names = names
    }

    filter3(selector) {
        const constructor = Reflect.getPrototypeOf(this).constructor[Symbol.species] ||
            Reflect.getPrototypeOf(this).constructor
        return new constructor(...this.names.filter(selector))
    }
}

class ChildNames1 extends Names {
    static get [Symbol.species]() { return Names }
}

Array 类正是利用 Symbol.species 达到类似的目的。

class MyArray extends Array {
    static get [Symbol.species]() { return Array }
}

const arr = new MyArray().concat(new MyArray())
console.log(arr instanceof Array) // true

本文开头我们从 Array 派生出的 MyArray 类调用 concat() 方法返回的是 MyArray 类,如今经过我们的一番操作,已然是 Array。真是令人不胜唏嘘。