前言

起因是一道面试题:

es6 class 与普通构造函数的区别?

有点好奇别人会怎么答,网上随便搜了个答案:

1.类的内部所有定义的方法,都是不可枚举的(但是在es5中prototype的方法是可以进行枚举的)

2.类的构造函数,不使用new是没法调用的,会报错。这是它跟普通构造函数的一个主要区别,后者不用new也可以执行

3.Class不存在变量提升(hoist),这一点与ES5完全不同

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

1、2 点说的倒是没有错误,从第 3 点开始就离谱……

于是本着技术人的探究精神,查阅了很多资料,总结出了这么一篇文章。

Class 的”变量提升”

实际上,无论是 class 声明还是 class 表达式,都是存在变量提升的。

letconst类似,使用class声明的类也会被提升,然后这个类声明会被保存在词法环境中但处于未初始化的状态,直到 runtime 执行到变量赋值那一行代码,才会被初始化。另外,class 声明的类一样存在**暂时性死区(TDZ)**。看例子:

var Person = function () {
  this.name = "noName";
};
{
  let peter = new Person("Peter", 25); //Uncaught ReferenceError: Cannot access 'Person' before initialization
  console.log(peter);
  class Person {
    constructor(name, age) {
      this.name = name;
      this.age = age;
    }
  }
}

如果没有提升,Peter 会是块作用域外的Person实例。但是由于提升的关系,块作用域内的Person遮蔽了外层的同名函数。

当然会这么理解完全是 JavaScript 不严谨的锅。直到 ECMAScript® 2015 Language Specification 之前的JavaScript文档中找不到变量提升(Hoisting)这个词。甚至 letconst 有没有提升都争论了很久。

在 Javascript 中所有声明的变量(var,const,let,function,class)都存在变量提升的情况。使用var声明的变量,在词法环境中会被初始化为undefined,但用letconst声明的变量并不会被初始化。

实际上变量和函数声明在代码里的位置是不会动的,而是在编译阶段被放入内存中,更准确的说,是词法环境中。变量提升是现象,而不是原因。

es5 VS es6

b 话不多说,上手就是干。

es6

先上个测试用的代码:

let b = [];
class Parent {
  constructor(name) {
    debugger
    this.name = name;
    debugger
    b.push(this);
  }
  getName() {
    console.log(this.name);
  }
}
class Child extends Parent {
  constructor(name, age) {
    debugger
    super(name);
    debugger
    this.age = age;
  }
  getAge() {
    console.log(this.age);
  }
}
var child1 = new Child("a", 1);

debugger
console.log(b[0] === child1); // true

好戏开始:

image-20210905202050828

意料之中,在调用 super 前,子类构造函数中的 thisundefined

在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性 [[ConstructorKind]]:"derived"。这是一个特殊的内部标签。

该标签会影响它的 new 行为:

  • 当通过 new 执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给 this
  • 但是当继承的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作。

继续:

image-20210905202956124

this 突然有值了,而且还是 Child 的实例。

image-20210905203136821

name 属性成功修改。

现在应该回到子类的构造函数了:

image-20210905203324285

原本是 undefinedthis 变成了这个实例对象。看来 super 方法将 this 传递了下来。

new 结束后的情况:

image-20210905212851640

console.log(b[0] === child1); // true

child1 与整个流程中 this 指代的实例对象引用了同一个内存空间。(new 返回的就是 this 指代的这个对象,跟 class 没关系)

所以,child1确确实实就是我们需要的 Child 实例,沿着原型链可以访问子类和父类中的属性和方法。class 在实现的结果上的确是无可挑剔。


es5

都说 class 是语法糖,本质上 class 应该是通过 es5 也能实现的,那么 es5 中又是怎么做的?

上 es5 版本:

let b = []
function Parent2(name) {
  debugger
  this.name = name;
  b.push(this)
}
Parent2.prototype.getName = function () {
  console.log(this.name);
};

function Child2(name, age) {
  debugger
  Parent2.call(this, name);
  this.age = age;
}
Child2.prototype = new Parent2();
debugger
Child2.prototype.constructor = Child2;
Child2.prototype.getAge = function () {
  console.log(this.age);
};
debugger
var child2 = new Child2("b", 2);

debugger;
console.log(b[0] === child2.__proto__); // true

为方便观察,new 一个 Parent2 实例,以作为 Child2 子类的原型对象。

image-20210905213226432

Parent2 的实例上还什么都没有。(getName方法是属于 Parent2 的原型对象的)

image-20210905214057569

此时 new结束,Child2 的原型对象被指定为 Parent2实例。这样就依靠原型链完成了继承。不过 constructor属性还没修改,这里通过Child2.prototype.constructor = Child2修正。

image-20210905214957728

image-20210905214554560

此时 Child2 的原型对象还什么都没挂载。

image-20210905211138703

new 一个 Child2 实例之前看看发生了什么:

image-20210905221059344

Child2 的原型对象也跟着改变了,挂载了新方法 getAge(),并且其 constructor 属性指向子类 Child2

继续,new 一个 Child2 实例:

image-20210905211350542

class不同的是,虽然同为子类,但在Child2构造函数中this 一开始就是有值的,而且是 Child2 的实例。

调用 call 方法进入父类的构造函数:

image-20210905211612193

new 结束:

image-20210905211804622

console.log(b[0] === child2.__proto__); // true

因为 new 出来的实例的__proto__指向构造函数的 prototype(之前 child2 的构造函数的原型指向已经被我们修改了:Child2.prototype = new Parent2())

也就是说,child2 的原型指向我们之前 new 出来的 Parent2 的实例(它就像一个中间人,是子类Child2的原型对象同时也是父类Parent2的实例,从而实现了子类和父类的链接)。所以child2也可以沿着原型链找到父类的属性和方法。

揭开迷雾

回到答案,找找看有什么错误:

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

上面这段话其实出自阮一峰老师的ECMAScript 6 入门。emmmmm,反正我第一眼看是不太理解的。(也可能是阮一峰老师表达上有误)

this?

在es5 中,在子类的构造函数中 this 是子类的实例对象,之后使用 call 调用父类构造函数,这一步是没问题的。但是,父类的方法(更准确的说是父类原型上的方法,比如上个例子中的 getName())是属于父类的原型对象的。在Child2.prototype = new Parent2()执行后就已经在子类的原型链上可访问了。子类传递的 this实际上只做了添加属性的工作(用父类构造函数初始化了 name属性),也就是说,继承和 this是没有直接关系的(这里能继承到父类的属性完全是 call 的作用)。

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

super?

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

image-20210905231222394

简化后的流程图:

image-20210905231301267

newTargetnew.target指向的函数

func是父类的构造函数

继续用这些参数调用Construct

调用Construct是递归的,直到当kindbase或者某一函数有明确的返回值才会终止。

基于这点我们再来分析一波:

子类的构造函数的 this 是不会赋值为子类的实例对象的(一开始是空对象),而是到父类的构造函数中做这一步。super 在这个过程中起到很大作用(当然super 还有其他的魔法)。super 实际上只是递归地调用父类构造函数,这个过程中会将最终得到的实例(这个实例始终是 new关键字后的类的实例)赋值给 this 然后 return 出来。

es6 的实现没有黑魔法,不能凭空变出来一个继承父类的子类实例。那么es6 的继承是靠的什么?

不得不说的 new

new 做了什么?

  1. 创建了一个全新的对象。
  2. 这个对象会被执行[[Prototype]](也就是__proto__)链接。
  3. 生成的新对象会绑定到函数调用的this。(如果是 es6 子类则不会)
  4. 通过new创建的每个对象将最终被[[Prototype]]链接到这个函数的prototype对象上。
  5. 如果函数没有返回对象类型Object(包含Functoin, Array, Date, RegExg, Error),那么new表达式中的函数调用会自动返回这个新的对象。

new 类似的:

ES5提供的 Object.create

Object.create(proto, [propertiesObject]) 方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。 它接收两个参数,不过第二个可选参数是属性描述符(不常用,默认是undefined)。

对于不支持ES5的浏览器,MDN上提供了ployfill方案。 MDN Object.create()

// 简版:也正是应用了new会设置__proto__链接的原理。
if(typeof Object.create !== 'function'){
    Object.create = function(proto){
        function F() {}
        F.prototype = proto;
        return new F();
    }
}

ES6提供的 Object.setPrototypeOf

Object.setPrototypeOf() 方法设置一个指定的对象的原型 ( 即内部[[Prototype]]属性)到另一个对象或 nullObject.setPrototypeOf(obj, prototype)

`ployfill`
// 仅适用于Chrome和FireFox,在IE中不工作:
Object.setPrototypeOf = Object.setPrototypeOf || function (obj, proto) {
  obj.__proto__ = proto;
  return obj;
}

nodejs源码就是利用这个实现继承的工具函数的。 nodejs utils inherits

function inherits(ctor, superCtor) {
  if (ctor === undefined || ctor === null)
    throw new ERR_INVALID_ARG_TYPE('ctor', 'Function', ctor);

  if (superCtor === undefined || superCtor === null)
    throw new ERR_INVALID_ARG_TYPE('superCtor', 'Function', superCtor);

  if (superCtor.prototype === undefined) {
    throw new ERR_INVALID_ARG_TYPE('superCtor.prototype',
                                   'Object', superCtor.prototype);
  }
  Object.defineProperty(ctor, 'super_', {
    value: superCtor,
    writable: true,
    configurable: true
  });
  Object.setPrototypeOf(ctor.prototype, superCtor.prototype);
}

关键的 extends

ES6 的 extends 继承做了什么操作?

我们先看看这段包含静态方法的ES6继承代码:

// ES6
class Parent{
    constructor(name){
        this.name = name;
    }
    static sayHello(){
        console.log('hello');
    }
    sayName(){
        console.log('my name is ' + this.name);
        return this.name;
    }
}
class Child extends Parent{
    constructor(name, age){
        super(name);
        this.age = age;
    }
    sayAge(){
        console.log('my age is ' + this.age);
        return this.age;
    }
}
let parent = new Parent('Parent');
let child = new Child('Child', 18);
console.log('parent: ', parent); // parent:  Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child:  Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18

其实这段代码里有两条原型链。

// 1、构造器原型链
Child.__proto__ === Parent; // true
Parent.__proto__ === Function.prototype; // true
Function.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true
// 2、实例原型链
child.__proto__ === Child.prototype; // true
Child.prototype.__proto__ === Parent.prototype; // true
Parent.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true

一图胜千言:

image-20210906002108761

结合代码和图可以知道。 ES6 extends 继承,主要就是:

  • 把子类构造函数(Child)的原型(__proto__)指向了父类构造函数(Parent),**(所以 super能找到父类构造函数)**
  • 把子类实例child的原型对象(Child.prototype) 的原型(__proto__)指向了父类parent的原型对象(Parent.prototype)。
//和之前实验中的继承方式比较
child2.__proto__ === Child2.prototype // true
new Parent2().__proto__ === Parent2.prototype // true
Child2.prototype = new Parent2(); // 在上图中相当于把Child.prototype和实例(Parent{})连起来
//在效果上等同于下面的方式,不过更耗内存,一般情况不推荐。
//es6 extends的继承方式
Child2.prototype.__proto__ = Parent2.prototype; //在上图中相当于直接把Child.prototype和Parent.prototype连起来

extends的 ES5 版本实现

知道了ES6 extends 继承做了什么操作和设置__proto__的知识点后,把上面ES6例子的用ES5就比较容易实现了,也就是说实现寄生组合式继承,简版代码就是:

// ES5 实现ES6 extends的例子
function Parent(name){
    this.name = name;
}
Parent.sayHello = function(){
    console.log('hello');
}
Parent.prototype.sayName = function(){
    console.log('my name is ' + this.name);
    return this.name;
}

function Child(name, age){
    // 相当于super
    Parent.call(this, name);
    this.age = age;
}
// new
function object(){
    function F() {}
    F.prototype = proto;
    return new F();
}
function _inherits(Child, Parent){
    // Object.create
    Child.prototype = Object.create(Parent.prototype);
    // __proto__
    // Child.prototype.__proto__ = Parent.prototype;
    Child.prototype.constructor = Child;
    // ES6
    // Object.setPrototypeOf(Child, Parent);
    // __proto__
    Child.__proto__ = Parent;
}
_inherits(Child,  Parent);
Child.prototype.sayAge = function(){
    console.log('my age is ' + this.age);
    return this.age;
}
var parent = new Parent('Parent');
var child = new Child('Child', 18);
console.log('parent: ', parent); // parent:  Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child:  Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18

我们完全可以把上述ES6的例子通过babeljs 转码成ES5来查看,更严谨的实现。

// 对转换后的代码进行了简要的注释
"use strict";
// 主要是对当前环境支持Symbol和不支持Symbol的typeof处理
function _typeof(obj) {
    if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") {
        _typeof = function _typeof(obj) {
            return typeof obj;
        };
    } else {
        _typeof = function _typeof(obj) {
            return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
        };
    }
    return _typeof(obj);
}
// _possibleConstructorReturn 判断Parent。call(this, name)函数返回值 是否为null或者函数或者对象。
function _possibleConstructorReturn(self, call) {
    if (call && (_typeof(call) === "object" || typeof call === "function")) {
        return call;
    }
    return _assertThisInitialized(self);
}
// 如果 self 是void 0 (undefined) 则报错
function _assertThisInitialized(self) {
    if (self === void 0) {
        throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
    }
    return self;
}
// 获取__proto__
function _getPrototypeOf(o) {
    _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) {
        return o.__proto__ || Object.getPrototypeOf(o);
    };
    return _getPrototypeOf(o);
}
// 寄生组合式继承的核心
function _inherits(subClass, superClass) {
    if (typeof superClass !== "function" && superClass !== null) {
        throw new TypeError("Super expression must either be null or a function");
    }
    // Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。
    // 也就是说执行后 subClass.prototype.__proto__ === superClass.prototype; 这条语句为true
    subClass.prototype = Object.create(superClass && superClass.prototype, {
        constructor: {
            value: subClass,
            writable: true,
            configurable: true
        }
    });
    if (superClass) _setPrototypeOf(subClass, superClass);
}
// 设置__proto__
function _setPrototypeOf(o, p) {
    _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) {
        o.__proto__ = p;
        return o;
    };
    return _setPrototypeOf(o, p);
}
// instanceof操作符包含对Symbol的处理
function _instanceof(left, right) {
    if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) {
        return right[Symbol.hasInstance](left);
    } else {
        return left instanceof right;
    }
}

function _classCallCheck(instance, Constructor) {
    if (!_instanceof(instance, Constructor)) {
        throw new TypeError("Cannot call a class as a function");
    }
}
// 按照它们的属性描述符 把方法和静态属性赋值到构造函数的prototype和构造器函数上
function _defineProperties(target, props) {
    for (var i = 0; i < props.length; i++) {
        var descriptor = props[i];
        descriptor.enumerable = descriptor.enumerable || false;
        descriptor.configurable = true;
        if ("value" in descriptor) descriptor.writable = true;
        Object.defineProperty(target, descriptor.key, descriptor);
    }
}
// 把方法和静态属性赋值到构造函数的prototype和构造器函数上
function _createClass(Constructor, protoProps, staticProps) {
    if (protoProps) _defineProperties(Constructor.prototype, protoProps);
    if (staticProps) _defineProperties(Constructor, staticProps);
    return Constructor;
}

// ES6
var Parent = function () {
    function Parent(name) {
        _classCallCheck(this, Parent);
        this.name = name;
    }
    _createClass(Parent, [{
        key: "sayName",
        value: function sayName() {
            console.log('my name is ' + this.name);
            return this.name;
        }
    }], [{
        key: "sayHello",
        value: function sayHello() {
            console.log('hello');
        }
    }]);
    return Parent;
}();

var Child = function (_Parent) {
    _inherits(Child, _Parent);
    function Child(name, age) {
        var _this;
        _classCallCheck(this, Child);
    // Child.__proto__ => Parent
    // 所以也就是相当于Parent.call(this, name); 是super(name)的一种转换
    // _possibleConstructorReturn 判断Parent.call(this, name)函数返回值 是否为null或者函数或者对象。
        _this = _possibleConstructorReturn(this, _getPrototypeOf(Child).call(this, name));
        _this.age = age;
        return _this;
    }
    _createClass(Child, [{
        key: "sayAge",
        value: function sayAge() {
            console.log('my age is ' + this.age);
            return this.age;
        }
    }]);
    return Child;
}(Parent);

var parent = new Parent('Parent');
var child = new Child('Child', 18);
console.log('parent: ', parent); // parent:  Parent {name: "Parent"}
Parent.sayHello(); // hello
parent.sayName(); // my name is Parent
console.log('child: ', child); // child:  Child {name: "Child", age: 18}
Child.sayHello(); // hello
child.sayName(); // my name is Child
child.sayAge(); // my age is 18

继承的缺陷

ES5 继承存在的问题

回到_possibleConstructorReturn函数。

function _possibleConstructorReturn(self, call) {
    if (call && (_typeof(call) === "object" || typeof call === "function")) {
        return call;
    }
    return _assertThisInitialized(self);
}
// 如果 self 是void 0 (undefined) 则报错
function _assertThisInitialized(self) {
    if (self === void 0) {
        throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
    }
    return self;
}

设想如下场景,在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]

而在上一节对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[[HomeObject]]、实时委托模型等等)

真的很感谢前端领域的大佬们,在查资料的过程中也能明显感觉到自己对知识点有了更深层次的理解,希望以后也能坚持下去。

还有一点体会就是,对于人云亦云的观点,我们也应该有质疑精神,尤其是 IT 领域,没有所谓绝对正确的答案。