class继承_ES6笔记12

写在前面

class_ES6笔记10中提到classconstructorstatic等关键字简化了类的定义,包括更简单的getter/setter以及各种函数属性的简化定义方式,如果只是这样,ES6的类好像还缺点什么

嗯,继承。ES5之前有6种继承方案,ES5提供了官方版beget方法(Object.create()),表示对寄生组合式继承的支持,ES6希望带来一些更强大更方便的变革,而不只是一个被贴上原生标签的工具函数

所以简化类的定义只是简化对象定义方式时顺便完成的,ES6真正想说的是:现在我们提供了一种更方便的继承机制

一.如何继承静态属性?

仔细回想下,我们在讨论JS继承时,似乎从未提到过静态属性,例如:

function Super() {}
Super.staticAttr = 'static';
function Sub() {}
Sub.prototype = new Super();
console.log(Sub.staticAttr);  // undefined

这样做当然无法继承静态属性,与之类似的其它5种方案也都没有考虑过静态属性的感受。静态属性在JS中的应用场景不多,不能继承也不会影响什么,但对于继承机制来说,无法继承静态属性多少算是个缺憾

如果非要继承静态属性呢,有没有办法?我们试着分析:

// 定义一个Function实例Type,所谓的“类”
function Type() {}
// Function实例的原型自然是所属类Function的prototype属性指向的东西
Type.__proto__ === Function.prototype
// 反过来看,类Type的prototype属性指向的东西自然是该类实例的原型
Type.prototype === new Type().__proto__
// 所以,这是两个不同的东西
Type.prototype !== Type.__proto__

最后一行左边的Type应该叫自定义类,右边的Type应该叫Function实例。因为Type.prototype指向的东西会影响自定义类Type的所有实例,它们都能访问这个东西;而Type.__proto__指向的东西影响的是Type所属类的所有实例,Type本身就是其中之一。prototype只能影响未来,而__proto__会改写历史:

function SubType() {}
var p = new Type();
SubType.prototype = p;
Type.__proto__ = {a: 1};
console.log(new SubType().a);   // undefined
// p.__proto__ = {a: 1};
// console.log(new SubType().a);   // 1

每个对象都有内部属性__proto__,属性访问时的原型链查找就是追溯该属性的过程,之所以存在上面的差异,是因为SubType实例的原型链与SubType本身的原型链唯一的交点是Object.prototype,此外毫不相干:

// SubType实例的原型链
new SubType().__proto__ === SubType.prototype
new SubType().__proto__.__proto__ === Type.prototype
new SubType().__proto__.__proto__.__proto__ === Object.prototype
new SubType().__proto__.__proto__.__proto__.__proto__ === null
// SubType本身的原型链
SubType.__proto__ === Function.prototype
SubType.__proto__.__proto__ === Object.prototype
SubType.__proto__.__proto__.__proto__ === null

new出来的实例的原型链上根本不会有Function.prototype这一环,除非它的某个祖先的原型是function类型,但这不太可能,因为没有任何理由这样做:

// 除非这样做,但这个函数要如何调用呢?
Type.prototype = function() {};

现在一切都清楚了。那么想要继承静态属性的话,我们应该修改另一条原型链

Sub.__proto__ = Super;  // 继承静态属性

用最开始的例子测试效果:

function Super() {}
Super.staticAttr = 'static';
function Sub() {}
Sub.prototype = new Super();    // 继承实例属性和原型属性
Sub.__proto__ = Super;          // 继承静态属性
console.log(Sub.staticAttr);    // static

这是一个支持静态属性的简单原型链继承,但是很可惜,我们开挂了__proto__这个内部属性不是广泛兼容的,沮丧地发现白忙活了,这也是为什么一直以来没有继承静态属性一说,因为我们需要直接修改一个Function实例的原型,可是这真的做不到

ES6解决了这个问题,因为它想提供一套完整的继承机制

二.原型操作API

ES6提供了Object.get/setPrototypeOf()来代替内部属性__proto__,例如:

let obj = {};
Object.setPrototypeOf(obj, Array.prototype);
obj.push(1);
console.log(obj.pop());     // 1
console.log(obj.length);    // 0
console.log(obj instanceof Array);  // true

通过Object.setPrototypeOf()直接修改一个普通对象的原型,让它变成数组的实例

非常强大,但要注意

Changing the [[Prototype]] of an object is, by the nature of how modern JavaScript engines optimize property accesses, a very slow operation, in every browser and JavaScript engine. The effects on performance of altering inheritance are subtle and far-flung, and are not limited to simply the time spent in obj.__proto__ = … statement, but may extend to any code that has access to any object whose [[Prototype]] has been altered. If you care about performance you should avoid setting the [[Prototype]] of an object. Instead, create a new object with the desired [[Prototype]] using Object.create().

(引自Object.setPrototypeOf() – JavaScript | MDN

大意是这个API性能很差,手动篡改原型链会导致JS引擎无法优化属性访问,带来的性能影响要比篡改obj.__proto__大得多,因为所有访问被篡改过原型的对象的代码都会受到影响。如果很在意性能的话,建议使用Object.create()新建一个原型,再以此为模板创建需要的对象

性能差是因为篡改__proto__是在改写历史(穿越到以前,换掉关键人物,将改写该点之后的所有历史),而修改Type.prototype性能不差,因为是在铺垫未来,不影响已经存在的东西,经典例子:

function Super() {
    this.key = 'value';
}
function Sub() {}
var obj1 = new Sub();
// 铺垫未来
Sub.prototype = new Super();
var obj2 = new Sub();
console.log(obj1.key);  // undefined
console.log(obj2.key);  // value

因为修改prototype不影响已经存在的东西(obj1),性能影响自然就小。而__prototype__明显不一样:

function Super() {
    this.key = 'value';
}
function Sub() {}
var obj1 = new Sub();
// 改写历史
Sub.prototype.__proto__ = new Super();
var obj2 = new Sub();
console.log(obj1.key);  // value
console.log(obj2.key);  // value

虽然两个例子不是对照试验,后者改的是obj的下下一环,但我们成功改写了历史,而用prototype肯定是做不到的(改Object.prototype?确实可以,但这样的话,讨论继承还有什么意义呢?)

所以,继承要付出代价,继承原生类代价更大(原生类内部有一些盘根错节的东西,而且数组对象和普通对象的内存布局都不同,想想都费劲)

三.最完美的继承

ES5最完美的继承方式是寄生组合式,如下:

function Super(){
    // 只在此处声明基本属性和引用属性
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 静态属性
//  在此处声明函数
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1.继承实例属性val, arr
    Super.call(this);
    // ...
}
// 2.继承原型属性fun1, fun2
var proto = Object.create(Super.prototype);
proto.constructor = Sub;
Sub.prototype = proto;

完美继承实例属性和原型属性,避免了原型引用在子类实例间共享的问题且切掉了多余的那份实例属性

但它同样没有考虑静态属性的继承问题,我们手动添上:

function Super(){
    // 只在此处声明基本属性和引用属性
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 静态属性
//  在此处声明函数
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1.继承实例属性val, arr
    Super.call(this);
    // ...
}
// 2.继承原型属性fun1, fun2
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 3.继承静态属性
Object.setPrototypeOf(Sub, Super);

这里把继承原型属性的3句简化成1句了,对比一下:

/* 以前的3句 */
// 1.把Super.prototype包进一个匿名对象的原型,返回这个匿名对象
var proto = Object.create(Super.prototype);
// 2.修正constructor属性
proto.constructor = Sub;
// 3.让子类实例获得匿名对象原型属性的访问权
// new Sub().__proto__ === proto
Sub.prototype = proto;

/* 简化成1句 */
// 效果等同于上面3句(子类实例获得了父类原型属性的访问权),实现方式类似于“下下一环”
Object.setPrototypeOf(Sub.prototype, Super.prototype);  // 等价于Sub.prototype.__proto__ = Super.prototype

这样做除了精简代码(3行变1行)外,没有太大意义,考虑性能的话,就更没理由做这种精简了

Object.setPrototypeOf(Sub, Super)是无法避免的,这是到ES6为止,唯一合法的能够真正意义上“改写历史”的手段

添上静态属性继承支持后的完整示例如下:

function Super(){
    // 只在此处声明基本属性和引用属性
    this.val = 1;
    this.arr = [1];
}
Super.staticProp = 1;   // 静态属性
//  在此处声明函数
Super.prototype.fun1 = function(){};
Super.prototype.fun2 = function(){};
//Super.prototype.fun3...

function Sub(){
    // 1.继承实例属性val, arr
    Super.call(this);
    // ...
}
// 2.继承原型属性fun1, fun2
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 3.继承静态属性
Object.setPrototypeOf(Sub, Super);

// test
var sub = new Sub();
console.log(sub.val);   // 1
console.log(sub.arr);   // [1]
console.log(Super.staticProp);  // 1
console.log(Sub.staticProp);    // 1

这就是最完美的继承了,支持静态属性的寄生组合式继承。但存在一个问题,“应该在哪里声明什么”只是一个道德约束,这种弱约束不利于车间生产,我们需要一个更强的约束

四.ES6继承

我们已经学会了classstatic(见class_ES6笔记10),也知道了如何继承静态属性,那么ES6的继承应该是这样:

class Super {
    constructor(sub) {
        console.log(sub);
        this.greeting = 'hello' + (sub && `, ${sub.name}`);
    }
}
class Sub {
    constructor() {
        // 继承实例属性
        this.name = 'sam';
        return new Super(this);
    }
}
// 继承原型属性
Object.setPrototypeOf(Sub.prototype, Super.prototype);
// 继承静态属性
Object.setPrototypeOf(Sub, Super);

// test
console.log(new Sub().greeting);    // hello, sam

简洁了不少,看起来也很完美,但是:

console.log(new Sub() instanceof Super);    // true
console.log(new Sub() instanceof Sub);      // false

类型果然凌乱了,再手动修改constructor?太麻烦了。ES6也发现了这一点,所以提供了extends关键字:

class Super {
    constructor() {
        // 实例属性
        this.val = 1;
        this.arr = [1];
    }
    // 静态属性
    static get staticProp() {
        return this._staticProp || 1;
    }
    static set staticProp(val) {
        this._staticProp = val;
    }
    // 原型属性
    fun1() {}
    fun2() {}
}
class Sub extends Super {
    // ...
}

// test
var obj = new Sub();
console.log(obj.val);    // 1
console.log(obj.arr);    // [1]
console.log(Super.staticProp);    // 1
console.log(Sub.staticProp);      // 1
console.log(obj instanceof Super);  // true
console.log(obj instanceof Sub);    // true

这才看到几个新增关键字的真正作用,如下:

  • 对“应该在哪里声明什么”提供强约束

  • 支持静态属性继承

  • 自动维护类型

class Sub extends Super语法,Super可以是其它类、基于原型继承的函数、一般函数、包含函数或类的变量、对象上的某个属性、函数调用。此外,如果不想继承自Object.prototype还可以extends null,这提供了足够大的灵活性,让我们可以轻松使用新语法扩展现有类以及第三方类

继承机制的基本要素有了,自然就会有一些更高级的需求,比如,怎么访问父类属性?怎么给父类构造函数传参?

访问祖先类属性

通过super关键字可以访问祖先类属性,调用父类构造函数,如下:

class A {
    constructor(name) {
        // 实例属性
        this.name = name;
    }
    // 原型属性
    fn() {
        console.log('fn at A');
    }
    // 静态属性
    static get staticProp() {
        return this._staticProp || 1;
    }
    static set staticProp(val) {
        this._staticProp = val;
    }
}
class B extends A {
    constructor(name) {
        super(name.toUpperCase());
        super.fn();
    }
    fn() {
        super.fn();
    }
}
var b = new B('BextendsfromA'); // fn at A
console.log(b.name);    // BEXTENDSFROMA

super会跳过子类中定义的属性,直接从子类原型开始查找

因为内部仍然是原型链查找,所以super能访问的祖先类属性仅限于原型属性,无法访问祖先类的静态属性和实例属性,例如:

class B extends A {
    constructor(name) {
        super(name.toUpperCase());
        super.fn();
        // 从子类实例的原型开始做原型链查找,当然找不到位于另一条原型链上的祖先类静态属性
        console.log(super.staticProp);  // undefined
        // 想通过原型链找到祖先类的实例属性就更不可能了,明显应该通过`this.key`来找(实例属性的继承方式是值拷贝,而不是持有属性访问权)
        console.log(super.key);         // undefined
    }
    ...
}

注意super看起来和this很像,this像一个内置的变量名,在不同作用域可能指向不同的值,但super不一样,super是一个关键字

typeof super;   // Uncaught SyntaxError: 'super' keyword unexpected here
super instanceof SuperType; // 同上

要么super.xxx/super['xxx']访问父类属性,要么super()调用父类构造函数,其它形式都是非法的

支持扩展原生类型

如果CharArray extends Array,那么Array.isArray()检测应该返回trueinstanceof检测应该返回true,而且slice之类的方法也应该返回CharArray(Chrome47返回Array,目前53已经能够正确返回CharArray了),示例如下:

class CharArray extends Array {
    constructor(str) {
        console.log(typeof str, str);
        if (str.length > 1) {
            // 先创建实例[],否则this报错
            // Uncaught ReferenceError: this is not defined
            super();
            // 再push
            super.push.apply(this, str.split(''));
        }
        else {
            super(str);
        }
    }
    toUpperCase() {
        return this.map(function(c) {
            return c.toUpperCase();
        });
    }
}
var ca1 = new CharArray('abcde');   // string abcde
var ca2 = new CharArray('c');       // string c
console.log(ca1);   // ["a", "b", "c", "d", "e"]
console.log(ca2);   // ["c"]
console.log(ca1.slice(1));  // ["b", "c", "d", "e"] number 4
console.log(ca1.toUpperCase());  // ["A", "B", "C", "D", "E"] number 5
console.log(Array.isArray(ca1));    // true
console.log(ca1 instanceof CharArray);  // true
//! 理论上应该返回true
console.log(ca1.slice(2) instanceof CharArray);  // true number 3
console.log(ca1 instanceof Array);      // true

原生类的扩展类除了能够像真货一样通过现有的所有类型检测外,继承来的所有原生方法都应该有同样的行为,上面代码中的一个关键点是console.log(typeof str, str);,我们预期的第一个参数应该是string,但实际上有时候这个参数是number(调用slice()的时候),这说明原生slice()可能是这样:

Array.prototype.slice = function(start) {
    // 暂不考虑start为负数的情况
    let size = this.length - start;
    let res = new Array(size);
    for (let i = 0; i < size; i++) {
        res[i] = this[start + i];
    }
    return res;
}

new Array(size)就是有时候构造函数收到number的原因,这说明自定义的假货和真货内部机制完全一致

子类实例的创建过程

子类constructor中,this需要调用super()获得,在super()之前使用this会报错ReferenceError(这个约束类似于在Java类的构造函数中,super()必须位于第一行,同样的道理),例如:

class C {}
class D extends C {
    constructor() {
        // this.a = 1; // Uncaught ReferenceError: this is not defined
        super();    // 调用C默认的空构造函数
        // 也可以直接return一个其它对象,不用this
        // return {a: 1};
    }
}
// test
console.log(new D());

定义子类constructor就是告诉JS“创建子类实例这个事情交给我们吧,不用你管了”

所以在子类构造函数中,我们可以调用父类构造函数来创建合适的实例,然后借用祖先属性对实例进行初始化,甚至更粗暴的,我们可以放弃this,手动返回一个毫不相干的其它对象,作为new运算的结果

当然,也可以不定义constructor,表示我们不关心子类实例的创建过程,那么会创建一个该类的空白实例

在访问this之前必须调用super()是理所应当的,否则this的结构不确定(是Array还是普通对象?)

new.target

祖先类构造函数中可以通过检测new.target的值来得知是谁在调用该构造函数

因为子类构造函数必须先执行super()才能获取this,而某些兄弟子类存在本质的差异(比如Array和一般对象内存布局不同),所以父类需要知道应该返回哪种对象作为调用者的thisnew.target用来解决这个问题。例如:

class E {
    constructor() {
        switch(new.target) {
            case E:
                console.log('call from E');
                break;
            case F:
                console.log('call from F');
                break;
            case G:
                console.log('call from G');
                break;
            case H:
                console.log('call from H');
                break;
            default: break;
        }
    }
}
// 建立继承树
//     E
//    / \
//   F   G
//       |
//       H
class F extends E {}    // 默认调用父类构造函数
class G extends E {}
class H extends G {}
// test
new E();    // call from E
new F();    // call from F
new G();    // call from G
new H();    // call from H

P.S.new.target在任何函数中都是合法的,如果函数不是通过new调用,new.target将被赋值为undefined

基类需要知道子类信息,这很难理解,感觉是抽象反过来依赖具体了,那么基类为什么需要知道想new什么子类?

如上所述,因为

某些兄弟子类存在本质的差异

既然差异这么大,为什么分离开作为2个基类呢?Java中这样做肯定能解决问题,但JS不行,因为JS的继承树只有一个根节点——Object,所以Object需要知道我们想new个普通Object还是Array

说白了就是因为JS把所有东西都挂在Object这个根节点下,才出现了“兄弟子类差异巨大”的问题,然后有了new.target的解决方案。而Java的继承树不止一个根节点,所以不存在这个问题

五.多继承

有一种方式叫class extends Mixin

function mix(...mixins) {
    class Mix {}
    function copy(target, source) {
        // 命名函数有name属性
        let filterSet = new Set(['constructor', 'prototype', 'name']);
        // for (let key of Reflect.ownKeys(source)) {
        for (let key in source) {
            // if (!filterSet.has(key)) {
            if (source.hasOwnProperty(key) && !filterSet.has(key)) {
                let desc = Object.getOwnPropertyDescriptor(source, key);
                Object.defineProperty(target, key, desc);
            }
        }
    }

    for (let mixin of mixins) {
        // 如果mixin是“类”,“继承”其静态属性和原型属性
        // 如果mixin是普通对象,“继承”其实例属性
        copy(Mix, mixin);
        copy(Mix.prototype, mixin.prototype);
    }

    return Mix;
}

以前的mix/extends方案是把多个对象揉成一个对象,现在多走了一步,把多个mixin揉成一个类,这个类的实例能“继承”到模版“类”的静态属性和原型属性以及模版对象的实例属性

注意:“类”带有双引号很关键,因为这里的类不是指用class关键字定义的类,而是用function关键字定义的类型,区别是前者不可枚举(这也是class封装性的体现),copy方案失效,那就什么都“继承”不到了

测试一下效果:

let obj = {key: 'value'};
function Type() {}
Type.staticProp = 'static value from Type';
Type.prototype.fn = function() {
    return 'proto fn from Type';
};
let M = mix(obj, Type);
console.log(M.key);         // value
console.log(M.staticProp);  // static value from Type
console.log(new M().fn());  // proto fn from Type

怎么说,感觉效果有限,比以前的mix/extends方案略强大一点,但效果比较有限,尤其是mixin不支持用class关键字定义的类,这种方案就很难再有发展了

所以,JS还是没有多继承。

六.总结

如果跟着全文一步一步从ES5走过来,那么没有理由排斥ES6的class,因为它解决了一直以来存在的很多问题,比如“应该在哪里声明什么”和“更优雅的继承实现”

参考资料

  • 《ES6 in Depth》:InfoQ中文站提供的免费电子书

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code