Javascript 面向对象的缺陷,父类能调用被子类重写后的方法

905 阅读9分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

问题背景

前些天做项目练手时,遇到一个需要写类的场景,各个类之间的交互我打算用事件的方式进行,就自然地在父类继承了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. 在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。

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

原型链2.png

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

思考原因

将我们的 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

Man继承Perso
图1.1 Man 继承 Person 后

通过这种方式实现的继承,既可以通过Man.prototype访问到匿名函数实例,从而注册公共方法。又能借助原型链访问到Person类的原型对象,使得Man的实例可以调用Person的公共方法。并且这里的继承实现过程中也使用了“盗用构造函数”的技术,可以让一份实例同时具有ManPerson的实例属性。

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

Person继承EventEmitter
图1.2 Person 继承 EventEmitter 后

  再多的继承也是按照这个基本思路解析,那么基于这个继承逻辑,我们创建一个Man的实例的情况如下:

创建一个 Man 实例
图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方法注册在自己的原型对象上:

刚创建 Man 实例时
图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方法:

查找 _this 实例上的 say 方法
图1.5 查找 _this 实例上的 say 方法

结论

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

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

尾声

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

感谢大家看到这里。