ES6-class继承与super关键词深入探索

7次阅读

共计 6680 个字符,预计需要花费 17 分钟才能阅读完成。

ES6 class

在 ES6 版本之前,JavaScript 语言并没有传统面向对象语言的 class 写法,ES6 发布之后,Babel 迅速跟进,广大开发者也很快喜欢上 ES6 带来的新的编程体验。
当然,在这门“混乱”而又精妙的语言中,许多每天出现我们视野中的东西却常常被我们忽略。
对于 ES6 语法,考虑到浏览器的兼容性问题,我们还是要把代码转换为 ES5 版本运行。然而,之前的 ES 版本为什么能模仿 ES6 的诸多特性, 比如 class 与继承,super,static?JavaScript 又做了哪些改变以应对这些新角色?本文将对 class 实例构造,class 继承关系,super 关键字,static 关键字的运行机制进行探索。
水平有限,文中若有引起困惑或错误之处,还望指出。

class 实例构造

class 基本样例

基本而言,ES6 class 形式如下:

 class Whatever{}

当然,你也可以省略 constructor 方法。

class Whatever{constructor(){this.name = 'hahaha';} 
 }

请看 ES5 对应版本:

function Whatever{this.name = 'hahaha';}

new 干了什么

可知,constructor 相当于以前在构造函数里的行为。而对于 ES5 构造函数而言,我们在执行 new 的时候,大体上进行了下面四步:

  1. 新建对象 var _this = {};
  2. this 的 [[prototype]] 指向构造函数的 prototype, 即_this.__proto_ = Constructor.prototype
  3. 改变 Constructor 的 this 到_this 并执行 Constructor,即 Constructor.apply(_this,agrs); 得到构造好的_this 对象
  4. 判断 Constructor 的返回值,若返回值不为引用类型,则返回_this, 否则返回改引用对象

所以,构造函数的实例会继承挂载在 prototype 上的方法,在 ES6 calss 中,我们这样写会把方法挂载在 class 的 prototype:

class Whatever{
    //...
    methodA(){//...}          
}

对应 ES5 写法:

Whatever.prototype = function methodA(){//...}

class 继承关系

原型语言基本特点

在基于原型的语言中,实例对象有以下特点:

  1. 一切皆为对象(js 中除了对象还有基本类型,函数式第一等对象)
  2. 对象皆是从其他对象复制而来(在 JS 对象世界中,万物始于 Object.prototype 这颗蛋)
  3. 对象会记住它的原型(在 JS 中对象的__proto__属性指向它的原型)
  4. 调用对象本身没有的属性 / 方法时,对象会尝试委托它的原型

看到这,大家应该明白了,为什么挂载在 Constructor.prototype 的方法会被实例“继承”!

当箭头函数与 class 碰撞

ES6 的箭头函数,一出身便深受众人喜爱,因为它解决了令人头疼的函数执行时动态 this 指向的“问题”(为什么加引号?因为有时候气门确实需要动态 this 带来的巨大便利)。箭头函数中 this 绑定在词法作用域,即它定义的地方:

//ES6:
const funcArrow = () => {//your code}
//ES5:
var _this = this;
var funcArrow = function(){
    this = _this;
    //your code
}

有的童鞋可能会想到了,既然 js 中继承和 this 的关系这么大,在 calss 中采用词法绑定 this 的箭头函数, 会有怎么样呢?
我们来瞧瞧。

class WhateverArrow{
        //
        methodArrow = () => {//...}          
    }

这种写法会与上文中写法有何区别?

class WhateverNormal{
        //
        methodNormal() {//...}          
    }
    

我们在 chrome 环境下运行一下,看看这两种构造函数的 prototype 有何区别:

WhateverArrow.prototype 打印结果:constructor: class Whatever1
__proto__: Object
WhateverNormal.prototype 打印结果:constructor: class Whatever2
methodNormal: ƒ methodNormal()
__proto__: Object

结合上文中关于原型的论述,仔细品味这两者的差别,最好手动尝试一下。

方法与函数类型属性

我们称 func(){}的形式为“方法”,而 methodArrow = () =>:any 为属性!方法会被挂载在 prototype,在属性不会。箭头函数 methodArrow 属性会在构造函数里赋值给 this:

this.methodArrow = function methodArrow(){
    this = _this;
    //any code
}

在实例调用 methodArrow 时,调用的是自己的 methodArrow,而非委托 calss WhateverArrow.prototype 上的方法,而这个箭头函数中 this 的指向,Babel 或许能给我们一些启示:


 var WhateverArrow = function WhateverArrow() {
  var _this = this;

  _classCallCheck(this, WhateverArrow);

  _defineProperty(this, "methodArrow", function () {consoe.log(_this);
  });
};

遇见 extends,super 与[[HomeObject]]

让我们 extends 一下

当我们谈论继承时,往往指两种:

  1. 对象实例继承自一个类(构造函数)
  2. 子类继承父类

上文中我们探讨了第一种,现在,请把注意力转向第二种。
考虑下方代码:

class Parent {constructor(){
        this.tag = 'A';
        this.name = 'parent name'
    }
    methodA(){console.log('methodA in Parent')
    }
    methodB(){console.log(this.name);
    }
}

class Child extends Parent{constructor(){super();        
        // 调用 super()之后才用引用 this
        this.name = 'child name'
    }
    methodA(){super.methodA();
        console.log('methodA in Child')
    }
}
const c1 = new Child();
c1.methodA();//methodA in Parent // methodA in Child

我们通过 extends 连接了两个 class, 标明他们是“父子关系”的类, 子类中方法会屏蔽掉父类中同名方法,与 Java 中多态特性不同,这里的方法参数数量并不影响“是否同一种方法”的判定。
在 Child 的 constructor 中,必须在调用 super()之后才能调用 this, 否则将会因 this 为 undefined 而报错。其中缘由,简单来说就是执行 new 操作时,Child 的_this 来自于调用 Parent 的 constructor,若不调用 super(),_this 将为 undefined。对这个问题感兴趣的同学可以自行操作试试,并结合 Babel 的转换结果,进行思考。

super 来自何方?[[HomeObject]]为何物?

super 干了什么

super 可以让我们在子类中借用父类的属性和方法。

 methodA(){super.methodA();
            console.log('methodA in Child')
        }
        

super 关键词真是一个增进父子情的天才创意!
值得注意的是,子类中 methodA 调用 super.methodA()时候,super.methodA 中的 this 绑定到了子类实例。

super 来自何方?如何请到 super 这位大仙?

用的舒服之后,我们有必要想一想,Child.prototype.methodA 中的 super 是如何找到 Parent.prototype.methodA 的?
我们知道:

Child.prototype.__proto__ === Parent.prototype;
cs.__proto__ === Child.prototype;
c1.methodA();

当 c1.methodA()执行时,methodA 中 this 指向 c1,难道通过多少人爱就有多少人恨的 this?
仔细想想,如果是这样(通过 this 找),考虑如下代码:

// 以下代码删除了当前话题无关行
class GrandFather{methodA(){console.log('methodA in GrandFather')
    }  
}
class Parent extends GrandFather{methodA(){super.methodA();
        console.log('methodA in Parent')
    }       
}
    
class Child extends Parent{methodA(){super.methodA();
        console.log('methodA in Child')
    }
}

想想我们现在是执行引擎, 我们通过 this 找到了 c1,然后通过原型找到了 Child.prototype.methodA;
在 Child.prototype.methodA 中我们遇见了 super.methodA();
现在我们要去找 super, 即 Parent。
我们通过 this.__proto__.__proto__methodA 找到了 Parent.prototype.methodA;
对于 Parent.prototype.methodA 来说,也要像对待 c1 一样走这个方式找,即在 Parent..prototype.methodA 中通过 this 找其原型。
这时候问题来了,运行到 Parent.prototype.methodA 时,该方法中的 this 指向的还是 c1。
这岂不是死循环了?
显然,想通过 this 找 super,只会鬼打墙。

[[HomeObject]]横空出世

为了应对 super,js 引擎干脆就让方法 (注意,是方法,不是属性) 在创建时硬绑定上 [[HomeObject]] 属性,指向它所属的对象!
显然,Child 中 methodA 的 [[HomeObject]] 绑定了 Child.prototype,Parent 中 methodA 的 [[HomeObject]] 绑定了 Parent.prototype。
这时候,根据 [[HomeObject]],可以准确无误地找到 super!
而在 Babel 转为 ES5 时,是通过硬编码的形式,解决了对 super 的引用,思路也一样,硬绑定当前方法所属对象(对象或者函数):

//babel 转码 ES5 节选
_createClass(Parent, [{
    key: "methodA",
    value: function methodA() {// 此处就是对 super.methodA()所做的转换,同样是硬绑定思路
      _get(_getPrototypeOf(Parent.prototype), "methodA", this).call(this);    
      console.log('methodA in Parent');
    }
  }]);
  

注意属性与方法的差别:

var obj1 = {
    __proto__:SomePrototype,
    methodQ(){ //methodQ 绑定了[[HomeObject]]->obj1,调用 super
        super.someMethod();}
}

var obj2 = {
    __proto__:SomePrototype,
    methodQ:function(){ //methodQ 不绑定任何[[HomeObject]]
        super.someMethod();//Syntax Eroor!语法错误,super 不允许在对象的非方法中调用}
}

箭头函数再袭 super

结合前文中关于 class 内部箭头函数的谈论,有个问题不得不引起我们思考:class 中的箭头函数里的 super 指向哪里?
考虑如下代码:

class Parent{methodA(){console.log('methodA in Parent')
    }       
}
    
class Child extends Parent{methodA = () => {super.methodA();
       console.log('methodA in Child')
    }
}

const c1 = new Child();
c1.methodA();

输出为:

methodA in Parent
methodA in Child

似乎没什么意外。我们需要更新异步,把 Parent 的 methodA 方法改为箭头函数:

class Parent{methodA = () => {console.log('methodA in Parent')
    }       
}
    
class Child extends Parent{methodA = () => {super.methodA();
       console.log('methodA in Child')
    }
}

const c1 = new Child();
c1.methodA();

很抱歉, 人见人恨得异常发生了:

Uncaught TypeError: (intermediate value).methodA is not a function
    at Child.methodA 
    

如何把 Child 中的 methodA 改为普通方法函数呢?

class Parent{methodA = () => {console.log('methodA in Parent')
    }       
}
    
class Child extends Parent{methodA () {super.methodA();
       console.log('methodA in Child')
    }
}

const c1 = new Child();
c1.methodA();

输出:

methodA in Parent
// 并没有打印 methodA in Child

以上几种结果产生的原因请结合前几章节细致品味,你会有所收获的。

不容忽视的 static

static 的表现

简单来说,static 关键词标志了一个挂载在 class 本身的属性或方法,我们可以通过 ClassName.staticMethod 访问到。

class Child{
    static name = '7788';    
    static methodA () {console.log('static methodA in Child')
    }
}
Child.name;//7788;
Child.methodA();//static methodA in Child

static 如何传给子类

因为 Child 本身的 [[prototype]] 指向了 Parent,即 Child.__proto__===Parent 所以,static 可以被子类继承:

class Parent{static methodA () {console.log('static methodA in Parent')
    }     
}
    
class Child extends Parent{ }

Child.methodA();//static methodA in Parent

static 方法中访问 super

class Parent{static methodA () {console.log('static methodA in Parent')
    }     
}
    
class Child extends Parent{static methodA () {super.methodA()    
       console.log('static methodA in Child')
    }  
}

Child.methodA();
// 输出://static methodA in Parent
// static methodA in Child


结语

JS 是门神奇的语言,神奇到很多人往往会用 JS 但是不会 JS(…hh)。作为一门热门且不断改进中的语言,由于跟随时代和历史遗留等方面的因素,它有很多令人迷惑的地方。
在我们每天面对的一些特性中,我们很容易忽视其中机理。就算哪天觉得自己明白了,过一段时间可能又遇到别的问题,突然觉得自己懂得还是太少 (还是太年轻)。然后刨根问底的搞明白,过一段时间可能又。。。或者研究 JS 的历程就是这样螺旋式的进步吧。
感谢 Babel,她真的对我们理解 JS 一些特性的运行机理非常有用,因为 Babel 对 JS 吃的真的很透彻 (…)。她对 ES6 的“翻译”,可以帮助我们对 ES6 新特性以及往前版本的 JS 的理解。
行文匆忙,难免有错漏之处,欢迎指出。
祝大家身体健康,BUG 越来越少。

正文完
 0