从extend看JS继承

3,901 阅读12分钟
原文链接: veronicaf.github.io

PS:前置知识:原型链

碎碎念

说到继承,第一印象就是红宝书里的各种方法,原型式继承、组合继承、寄生继承balabala。

初学JS的时候看到继承内心是崩溃的,人家py、java一个关键字就搞定了的东西你怎么能玩出这么多花样的。

其实这是语言设计层面的问题,JS并没有像java那种严格的面向对象系统,但是。。。

我们知道面向对象系统其实是可以用数据+过程的方式实现的(没错我说的就是SICP的第三章),那么JS提供了什么原材料让我们来实现面向对象呢?

JavaScript是一门基于原型、函数先行的语言,是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程(来自wiki)。

其实就是用原型啦,具体来说是原型链。

原型链

快速理解

我们通过一道面试题来理解原型链(前方啰嗦预警。。。

Object.prototype.a = 'a'

Function.prototype.b = 'b'

function Animal() {}

const obj = new Animal()

console.log(obj.a) // 'a'
console.log(obj.b) // undefined

PS:太长不看

  1. 新建的obj对象是构造函数Animal的实例
  2. 那么obj__proto__属性指向Animal.prototype
  3. Animal.prototype是一个对象,因此它的__proto__属性指向Object.prototype
  4. obj中查找a属性而没有找到
  5. obj.__proto__继续查找,没有找到
  6. obj.__proto__.__proto__继续查找
  7. 找到属性a,返回它的值'a'
  8. obj中查找b属性而没有找到
  9. obj.__proto__继续查找,没有找到
  10. obj.__proto__.__proto__继续查找,没有找到
  11. obj.__proto__.__proto__.__proto__null,查找结束,没有找到b属性,返回undefined

这个xxx.__proto__.__proto__.__proto__就是原型链,其实是一系列关联起来的对象。

obj -> Animal.prototype -> Object.prototype

总结:原型链就是一系列关联起来的对象。

小疑问

那么我们的b属性在哪里呢?

其实在Animal构造函数上,或者说在所有的函数对象上。

我们知道函数字面量其实与下面这种写法是一样的(其实有区别,详见MDN):

const Animal = new Function()

此时的原型链:

// 一个构造函数的原型链
Animal -> Function.prototype -> Object.prototype

所以可以通过原型链取到b,甚至可以取到a

PS: 尤其需要注意的是:Function.prototype是一个函数对象。

ES5的继承

// 父类
function Person(name) {
    this.name = name
}

Person.prototype.sayHi = function () {
    console.log('Hi, this is ', this.name)
}

// 子类
function Student(name, grade) {
    // 继承父类的属性
    Person.call(this, name)

    this.grade = grade
}

// 继承父类的方法
Student.prototype = Object.create(Person.prototype, {
    constructor: {
        value: Student,
        writable: true,
        configurable: true,
        enumerable: false,
    },
})

// 子类自己的方法
Student.prototype.doHomework = function () {
    console.log('so much homework!')
}

可以看到,与之前一篇文章一样,构造函数本身和构造函数的prototype属性仍然是我们关注的重点。

完成继承的步骤:

  1. 通过new关键字调用,新建一个子类的实例对象this(自动完成)
  2. 构造函数中调用父类的构造函数,来给this对象添加属性,子类实例就有了父类中定义的属性
  3. 修改构造函数的原型,主要是添加丢失的constructor属性,并将原型的__prop__属性设为父类的原型对象(处理原型链,通过Object.create完成)

Object.create返回一个新的对象,将第一个参数设置为返回的对象的__proto__

看一下效果:

const p = new Person('Joel')
const s = new Student('Bill', 3)
console.log(p, s)
console.log(s instanceof Student, s instanceof Person)

emmmm,行为符合预期,就是太啰嗦了。

而且如上文所说,这只是实现继承的其中一个范式,这意味着不同的人来写的话会有多种继承的写法,很杂乱。或者可以求助于工具库。

extend关键字的出现就是为了解决上述两个问题。

what the hell does extend do

继续使用上篇文章的例子

class Person {
    constructor(name) {
        this.name = name
    }

    sayHi() {
        console.log('Hi, this is ', this.name)
    }

    waveArm = () => {
        console.log(this.name, 'is waving arm')
    }
}

class Student extends Person {
    constructor(name, grade) {
        super(name)
        this.grade = grade
    }

    doHomework() {
        console.log('so much homework!')
    }
}

经过babel编译后的代码如下(上篇文章中出现过的函数不具体列出):

'use strict';

var _createClass = function () {
    ...
}();

function _possibleConstructorReturn(self, call) {
    if (!self) {
        throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
    }
    return call && (typeof call === "object" || typeof call === "function") ? call : self;
}

function _inherits(subClass, superClass) {
    if (typeof superClass !== "function" && superClass !== null) {
        throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
    }
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

function _classCallCheck(instance, Constructor) {
    ...
}

var Person = function () {
    function Person(name) {
        var _this = this;

        _classCallCheck(this, Person);

        this.waveArm = function () {
            console.log(_this.name, 'is waving arm');
        };

        this.name = name;
    }

    _createClass(Person, [{
        key: 'sayHi',
        value: function sayHi() {
            console.log('Hi, this is ', this.name);
        }
    }]);

    return Person;
}();

var Student = function (_Person) {
    _inherits(Student, _Person);

    function Student(name, grade) {
        _classCallCheck(this, Student);

        var _this2 = _possibleConstructorReturn(this, (Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name));

        _this2.grade = grade;
        return _this2;
    }

    _createClass(Student, [{
        key: 'doHomework',
        value: function doHomework() {
            console.log('so much homework!');
        }
    }]);

    return Student;
}(Person);

可以看到多了两个函数,分别是:

  • _inherits
  • _possibleConstructorReturn

我们依旧从最下面开始看。

PersonStudent两个变量分别是我们的父类和子类,Student变量保存的依旧是一个IIFE返回的函数。先看这个IIFE(调整了代码顺序,手动把函数声明提升了)。

var Student = function (_Person) {
    function Student(name, grade) {
        _classCallCheck(this, Student);

        var _this2 = _possibleConstructorReturn(this, (Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name));

        _this2.grade = grade;
        return _this2;
    }

    _inherits(Student, _Person);

    _createClass(Student, [{
        key: 'doHomework',
        value: function doHomework() {
            console.log('so much homework!');
        }
    }]);

    return Student;
}(Person);

原型链的处理

原型链的处理就看_inherits_createClass两个函数。

_createClass的详细内容在上一篇文章

IIFE内部的Student函数就是子类的构造函数,先不看他,先关注_inherits函数。

function _inherits(subClass, superClass) {
    if (typeof superClass !== "function" && superClass !== null) {
        throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
    }
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
    });
    if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}

_inherits函数的签名很好理解

  • subClass:子类
  • superClass:父类

再来看开头的if代码块。

if (typeof superClass !== "function" && superClass !== null) {
        throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}

它要求父类必须是个函数或者null

看到这儿你大概会疑惑:父类还能是null???是null的话继承个什么东西???这似乎与我们在ES5中的经验很不一样。因为ES5中父类肯定是一个函数。别着急,继续往下看。

关注一下测试不通过时抛出的错误信息,这里所谓的”Super expression”其实就是父类的构造函数。

接下来就是我们喜闻乐见的处理原型链环节。与ES5中的区别仅仅在于父类为null时的处理。

subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            enumerable: false,
            writable: true,
            configurable: true
        }
});

两个分支:

  • 如果父类是null的话Object.create返回的就是一个没有__proto__属性的对象,也就是说原型链在此中断了。
  • 否则与我们在ES5中的所做的一样。

最后一个if代码块,整理格式后如下:

if (superClass) {
    Object.setPrototypeOf
    ? Object.setPrototypeOf(subClass, superClass)
    : subClass.__proto__ = superClass;
}

同样分为两种情况:

  1. 父类为null,if代码块不执行,此时父类的__proto__指向Function.prototype,一个函数对象。
  2. 否则子类的__proto__设为父类构造函数,也是一个函数对象。这是在ES5中没有的操作,是ES6中的规定:子类的__proto__属性指向父类。

原型链一节说过函数对象其实是Function构造函数的实例,这样处理之后构造函数的原型链如下

subClass -> superClass -> Function.prototype -> Object.prototype

而如果superClassnull呢?

subClass -> Function.prototype -> Object.prototype

这特喵的跟一个普通的构造函数没区别啊。

这里贴一张图

这是我整理了ECMA-262 6th Edition中class解析的一部分内容。结合这张图应该能将原型链的处理过程看的很清楚了。

可以看到流程图是从有没有父类开始的,也就是说是从直接定义一个class还是通过extend继承一个父类开始的。

构造函数本身

function Student(name, grade) {
    _classCallCheck(this, Student);

    var _this2 = _possibleConstructorReturn(this, (Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name));

    _this2.grade = grade;
    return _this2;
}

_classCallCheck在上一篇文章分析过了。

重要的是_possibleConstructorReturn这个函数

function _possibleConstructorReturn(self, call) {
    if (!self) {
        throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
    }
    return call && (typeof call === "object" || typeof call === "function") ? call : self;
}

函数签名似乎并没有给我们太多的信息,结合它的调用方式一起看:

  1. self:很简单,就是调用构造函数时内部的this对象
  2. call:(Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name)

来分析一下call是什么鬼

(Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name)

上文中我们已经知道了子类的__proto__被设置成了父类的构造函数或者Function.prototype,因此这里就是我们在ES5中做的“在子类中调用父类的构造函数”。

我们知道,一般的构造函数是不会有明确的返回值的,因为通过new关键字调用时如果不明确指定返回值的话默认会返回this,而这些构造函数直接调用时会返回undefined

_possibleConstructorReturn从名字就可以知道,如果父类的构造函数有明确的返回值(必须得是一个对象),那么就返回它,否则就返回当前的this对象。

那么问题来了,为什么有这种行为?

从new关键字说起

要搞清楚这个问题得先知道new关键字做了什么,查阅ES标准:

可以看到new返回的是Construct(constructor, argList)

请留意这个函数,后文中多次提到。

再来看Construct做了什么,标准在这里,感兴趣的可以自己去看。

由于本文并不想多涉及环境变量的知识,因此尽量简略的解释Construct的行为。

每个函数内部都有一个[[ConstructorKind]]属性,当函数是generator或者继承了某个类的构造函数时(比如我们的Student),kind的值为“derived”,否则为“base”。

Construct又调用了constructor.[[construct]]

kind为“base”,[[construct]]做了下面这些事:

  1. 新建一个对象并处理原型链,使新建的对象成为构造函数的实例(特别注意:这个构造函数是指new.target指向的那个函数)
  2. 将当前环境中的this值绑定为这个新建的对象
  3. 执行函数体(此时我们的代码开始执行)
  4. 如果函数有明确的返回值并且返回值是一个object,返回它
  5. 否则返回this

kind为“derived”,[[construct]]做了下面这些事:

  1. 执行函数体(此时我们的代码开始执行,小tip:super也在这里才开始执行)
  2. 如果函数有明确的返回值并且返回值是一个object,返回它
  3. 否则返回this

看到区别了吗,第二种情况下没有给当前环境绑定this值,因此如果此时使用this值会报错:

这里就扯到了super关键字了

super作为函数调用

ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。

上面这段话出自阮一峰老师ECMAScript 6 入门

emmmmm,反正我第一眼看是不太理解的。

反正,从我们上面的分析来看,想必是super()绑定了this喽,继续看super的行为:

简化后的流程图:

图中:

  • newTargetnew.target指向的函数
  • func是父类的构造函数
  • 继续用这些参数调用Construct
  • 调用Construct是递归的,直到当kind为“base”或者某一函数有明确的返回值才会终止

所以说所谓的“先创造父类的实例对象this”描述不太准确,它确实是在父类中被创建,但是被创建的其实还是子类的实例(它的__proto__指向子类的prototype),因为new.target并没有改变,始终是new关键字后跟的那个函数。

emmmm,在PY这种面向对象的语言中,实例由哪个类创建就是那个类的实例,但是在JS的原型式继承中似乎有点歧义。。。

ES5继承存在的问题

扯远了,回到_possibleConstructorReturn函数。

设想如下场景,在ES5中,继承Array构造函数。

那么在我们的ES5实现中,我们调用Array.call(this, args),然而,这样调用不会对this作出任何修改,因为Array会忽略传入的this,这样我们new出来的就是一个空对象。

function MyArr() {
    Array.call(this, arguments)
}

MyArr.prototype = Object.create(Array.prototype, {
    constructor: {
        value: MyArr,
        writable: true,
        configurable: true,         enumerable: true,
    },
})

const arr = new MyArr()

arr[0] = 'JS'

console.log(arr.length) // 0

当我们想用ES5继承一个内置类型如Array类型时我们会丢失一些特殊的行为,例如length属性与数组中存储的元素个数的绑定,因为我们的实例并不是使用Array创建的。

但是我们也知道,ES5中这两种调用方式其实是一样的。

Array(5) // [empty × 5]
new Array(5) // [empty × 5]

也就是说Array忽略传入的this但是会明确返回一个Array对象。

而在上一节对new的分析中我们知道构造函数其实可以指定自己的返回值的,所以当遇到这种有明确返回值的父类,babel的做法是用父类的返回值替换子类的实例,这样就避免了出现上文所说的丢失特殊行为的现象。

但是这样我们的继承就无效了,因为返回的不是this,因此子类原型链上的东西全部丢失了。

ES6如何解决这一问题

函数对象之所以被称为函数对象,是因为在内部有[[call]][[construct]]两个方法,在我们ES5的做法中,对父类构造函数其实调用的是[[call]],而在ES6中,如果你仔细看了前文,就会发现super关键字调用的是[[construct]]

这两个调用的最显著区别就是new.target的值(与环境变量有关)

  • 通过[[call]]调用构造函数,构造函数内部的new.targetundefined
  • 通过[[construct]]调用构造函数,构造函数内部的new.targetnew关键字调用的那个函数

上文也说了创建的实例的__proto__是根据new.target确认的,所以当以ES6的方式(super)来继承Array时,在我们碰不到的Array构造函数内部完成了原型链的处理,而这是polyfill无法做到的事。。。

此时,再回想一下_possibleConstructorReturn这个函数的行为,实在是一种无奈之举啊。。。

后记

水平有限,文中难免有错误,欢迎指正。

关于super的其他方面本文没有涉及。

个人还是比较喜欢语法糖的,毕竟用着爽。

真是拖了好久才写出来。。。

毕设加实习着实有点忙。。

不过好歹是怼出来了。