本文已参加「新人创造礼」活动,一同敞开创造之路。

问题布景

前些天做项目练手时,遇到一个需求写类的场景,各个类之间的交互我打算用事情的方式进行,就自然地在父类承继了EventEmitter类。然后在父类对一个详细事情注册了一个默许监听,子类经过注册自己专有的监听细化逻辑。代码逻辑如下:

import EventEmitter from "events";
class People extends EventEmitter {
    constructor() {
        super();
        this.on("say", this.say);
    }
    public say() {
        console.log("I am People Class");
    }
}
class Man extends People {
    constructor() {
        super();
        this.on("say", this.say);
    }
    public say() {
        console.log("I am Man Class");
    }
}
let man = new Man();
man.emit("say");

可是这时遇到一个百思不得其解的问题,当其他部件对子类发出事情时,子类注册监听呼应了,但却呼应了两次,而父类的监听”消失了”!运转上面的代码得到如下成果:

I am Man Class
I am Man Class

为了找出问题,尝试修正Man类的代码,在say办法中显现调用super.say

class Man extends People {
    constructor() {
        super();
        this.on("say", this.say);
    }
    public say() {
        super.say(); // 显现调用父类办法
        console.log("I am Man Class");
    }
}

从头运转代码,得到成果如下:

I am People Class
I am Man Class
I am People Class
I am Man Class

发现父类的办法还是能调用的,可是不管怎么样都是子类办法被调用了两次,而触发函数的当地只要父类和子类注册的两个对say事情的监听。所以我当时猜是注册时调用的this.say中的this关键字引发的问题,使得父类监听调用了被子类重写的办法。

求解进程

从传统面向目标的视点来说这十分让人疑惑,网上找了一圈没发现关于这个问题的相关评论,就自己一点点的去研讨这个问题。从前端视点出发,咱们知道 JS 的面向目标是经过原型链模拟的,首要从头回忆一下 JS 面向目标技术发展进程中的要点。

材料收集

查阅《Javascript高级程序设计(第4版)》,得到知识点如下:

  1. 任何函数只要运用new操作符调用便是结构函数,而不运用new操作符调用的函数便是一般函数。

  2. 运用new操作符,以这种方式调用结构函数会履行如下操作:

  1. 在内存中创立一个新目标
  2. 这个新目标内部的[[Prototype]]特性被赋值为结构函数的 prototype 特点
  3. 结构函数内部的 this 被赋值为这个新目标(即 this 指向新目标)
  4. 履行结构函数内部的代码(给新目标添加特点)
  5. 假如结构函数回来非空目标,则回来该目标;不然,回来刚创立的新目标
  1. 每个函数都会创立一个 prototype 特点,这个特点是一个目标,包括应该由特定引用类型的实例共享的特点和办法。

  2. 在创立一个结构函数(创立一个函数,只在用 new 调用一个函数时,这个函数才是一个结构函数)时,原型目标默许只会取得 constructor 特点,指回与之相关的结构函数,其他的所有办法都承继自 Object。每次调用结构函数创立一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为结构函数的原型目标。

  3. 结构函数和结构函数的原型目标之间循环引用。

  4. 能够经过浏览器(详细完成)暴露在实例上的__proto__特点访问一个实例内部的[[Prototype]]。

  5. 同一个结构函数创立的两个实例,共享同一个原型目标。

  6. 原型上查找值的进程是动态的,所以即使实例在修正原型之前已经存在,任何时候对原型目标所做的修正也会在实例上反映出来。

  7. 在读取实例上的特点时,首要会在实例上查找这个特点。假如没找到,则会承继查找实例的原型。

原书顶用一张图片总结了上述关系:

Javascript 面向目标的缺点,父类能调用被子类重写后的办法

上面提到的几个知识点,会在以下问题的评论进程中反复体现。

考虑原因

将咱们的 ES6 类代码转为 ES5 的代码(运用 tsc):

tsconfig.json
{
  "compilerOptions": {
    ...
    "target": "es5",
    ...
  },
  "files": ["test.ts"]
} 
"use strict";
var __extends = (this && this.__extends) || (function () {
    var extendStatics = function (d, b) {
        extendStatics = Object.setPrototypeOf ||
            ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
            function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
        return extendStatics(d, b);
    };
    return function (d, b) {
        if (typeof b !== "function" && b !== null)
            throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
        extendStatics(d, b);
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var events_1 = __importDefault(require("events"));
var People = /** @class */ (function (_super) {
    __extends(People, _super);
    function People() {
        var _this = _super.call(this) || this;
        _this.on("say", _this.say);
        return _this;
    }
    People.prototype.say = function () {
        console.log("I am People Class");
    };
    return People;
}(events_1.default));
var Man = /** @class */ (function (_super) {
    __extends(Man, _super);
    function Man() {
        var _this = _super.call(this) || this;
        _this.on("say", _this.say);
        return _this;
    }
    Man.prototype.say = function () {
        _super.prototype.say.call(this);
        console.log("I am Man Class");
    };
    return Man;
}(People));
var man = new Man();
man.emit("say");

其实假如对 JS 原型链和承继的完成十分了解的话,上面的代码已经把答案写清楚了。ES6 extends 关键字转换成的__extends函数运用了类似寄生式组合承继方式去承继指定父类的公共办法,而咱们又在类结构进程中经过this关键字去注册监听函数,两者中存在的问题交错引起了咱们开篇提到的问题。

问题探究

下面逐步解析承继进程,一同来调查问题是怎么出现的。

首要从 Man 子类下手,该类定义(ES5)如下:

var Man = /** @class */ (function (_super) {
    __extends(Man, _super);
    function Man() {
        var _this = _super.call(this) || this;
        _this.on("say", _this.say);
        return _this;
    }
    Man.prototype.say = function () {
        _super.prototype.say.call(this);
        console.log("I am Man Class");
    };
    return Man;
}(People));

咱们知道 js 里面会进行函数声明提高,所以第一行效果的代码是:

__extends(Man, _super);

_super便是传入的People,也便是首要履行的代码为

__extends(Man, People);

解析 __extends() 完成的经典承继

__extends办法的源码在前面已经贴出,这儿直接点明该函数的承继效果。

当调用__extends(Man, People)时,__extends创立了一个匿名结构函数,并结构出一个匿名实例, 并显式地将该实例的[[Prototype]]特点赋值为People.prototypeconstructor特点赋值为Man

Javascript 面向目标的缺点,父类能调用被子类重写后的办法
图1.1 Man 承继 Person 后

经过这种方式完成的承继,既能够经过Man.prototype访问到匿名函数实例,从而注册公共办法。又能借助原型链访问到Person类的原型目标,使得Man的实例能够调用Person的公共办法。并且这儿的承继完成进程中也运用了“盗用结构函数”的技术,能够让一份实例一起具有ManPerson的实例特点。

而后Person类对EventEmitter类的承继也是同样进程,仅仅要把Person.prototype更改为另一个匿名实例,承继后的状况如下:

Javascript 面向目标的缺点,父类能调用被子类重写后的办法
图1.2 Person 承继 EventEmitter 后

再多的承继也是依照这个基本思路解析,那么根据这个承继逻辑,咱们创立一个Man的实例的状况如下:

Javascript 面向目标的缺点,父类能调用被子类重写后的办法
图1.3 创立一个 Man 实例

这儿运用了“盗用结构函数”技术,Person结构函数中的实例特点也是注册在同一份Man实例上。

解析结构函数履行进程

重申一下本文评论的问题:为什么Person类接收到say事情时,触发的是子类Man注册的回调函数?

一起强调一个知识点:

在读取实例上的特点时,首要会在实例上查找这个特点。假如没找到,则会承继查找实例的原型。

进入评论之前,留意Man结构函数中的语句履行顺序为:

function Man() { ... };
__extends(Man, People);
Man.prototype.say = function() { ... };
return Man;

带着这些,咱们来解析一下Man的结构函数:

var Man = /** @class */ (function (_super) {
    __extends(Man, _super);                     // 让内部函数 Man 承继 Person
    function Man() {                            // 声明内部函数 Man
        var _this = _super.call(this) || this;  // 将创立的实例传给 Person,盗用 Person 的实例特点
        _this.on("say", _this.say);             // 在含有 Person 和 EventEmitter 实例特点和原型办法的实例上
                                                // 调用 on 办法,将此时实例中的 say 函数注册为 "say" 事情的监听
        return _this;                           // 回来创立的实例
    }
    Man.prototype.say = function () {           // 在 Man 的原型目标上注册自己的 "say" 办法
        _super.prototype.say.call(this);        
        console.log("I am Man Class");
    };
    return Man;                                 // 回来内部函数 Man
}(People));

单从注释还不能直观地看出问题,咱们根据前面绘制的原型图来继续评论。

  • 当外部调用了new Man(),此时让咱们的履行进程停在:
function Man() {
    var _this = _super.call(this) || this;  // 将创立的实例传给 Person,盗用 Person 的实例特点
    ...
}

此时的原型链状况和图 1.3 大致相同,留意Mansay办法注册在自己的原型目标上:

Javascript 面向目标的缺点,父类能调用被子类重写后的办法
图1.4 刚创立 Man 实例时

这时问题来了,由于需求盗用Person的结构函数来注册实例特点,当将this传给Person.call时,Person内部履行了如下代码:

function People() {
    var _this = _super.call(this) || this;
    _this.on("say", _this.say);
    return _this;
}

咱们知道此时的_this便是刚创立的Man 实例,那么Person此时将_this.say注册为监听函数,而此时_this上并没有say特点或者办法,那么顺着原型链,_this.say找到了Man.prototype.say,也便是图中第一个匿名函数实例上的say办法:

Javascript 面向目标的缺点,父类能调用被子类重写后的办法
图1.5 查找 _this 实例上的 say 办法

定论

这便是答案了,为了盗用结构函数,需求让同一份实例在所有父类的结构函数中“游走”,导致在当前实例上不存在say之前,就经过_this.say去访问它,从而启动了原型链查找机制,使得Person结构函数中注册的监听是Man原型目标上的say

终究ManPersonsay事情注册的监听都为同一个函数,这样就造成了父类调用被子类重写后的办法的成果。

尾声

起先遇到这个问题,和组里的老大评论后,只能模糊的知道是原型承继的问题,可是没有深化地去剖析它。后来我在网上发帖求解,也很少有人和我评论。无奈之下就只能自己去看书籍找材料来回答。终究能把这篇博文完成我也是很开心的,整个问题的解析进程让我收益良多,希望也能为阅览博文的各位带来协助。

感谢我们看到这儿。