问题背景
前些天做项目练手时,遇到一个需要写类的场景,各个类之间的交互我打算用事件的方式进行,就自然地在父类继承了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版)》,得到知识点如下:
-
任何函数只要使用
new
操作符调用就是构造函数,而不使用new
操作符调用的函数就是普通函数。 -
使用
new
操作符,以这种方式调用构造函数会执行如下操作:
- 在内存中创建一个新对象
- 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
- 执行构造函数内部的代码(给新对象添加属性)
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
-
每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。
-
在创建一个构造函数(创建一个函数,只在用
new
调用一个函数时,这个函数才是一个构造函数)时,原型对象默认只会获得 constructor 属性,指回与之关联的构造函数,其他的所有方法都继承自 Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。 -
构造函数和构造函数的原型对象之间循环引用。
-
可以通过浏览器(具体实现)暴露在实例上的__proto__属性访问一个实例内部的[[Prototype]]。
-
同一个构造函数创建的两个实例,共享同一个原型对象。
-
原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。
-
在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。
原书中用一张图片总结了上述关系:
上面提到的几个知识点,会在以下问题的讨论过程中反复体现。
思考原因
将我们的 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.prototype
,constructor
属性赋值为Man
。
通过这种方式实现的继承,既可以通过Man.prototype
访问到匿名函数实例,从而注册公共方法。又能借助原型链访问到Person
类的原型对象,使得Man
的实例可以调用Person
的公共方法。并且这里的继承实现过程中也使用了“盗用构造函数”的技术,可以让一份实例同时具有Man
和Person
的实例属性。
而后Person
类对EventEmitter
类的继承也是同样过程,只是要把Person.prototype
更改为另一个匿名实例,继承后的情况如下:
再多的继承也是按照这个基本思路解析,那么基于这个继承逻辑,我们创建一个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 大致相同,注意Man
将say
方法注册在自己的原型对象上:
这时问题来了,由于需要盗用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
方法:
结论
这就是答案了,为了盗用构造函数,需要让同一份实例在所有父类的构造函数中“游走”,导致在当前实例上不存在say
之前,就通过_this.say
去访问它,从而启动了原型链查找机制,使得Person
构造函数中注册的监听是Man
原型对象上的say
。
最终Man
和Person
对say
事件注册的监听都为同一个函数,这样就造成了父类调用被子类重写后的方法的结果。
尾声
起初遇到这个问题,和组里的老大讨论后,只能模糊的知道是原型继承的问题,但是没有深入地去剖析它。后来我在网上发帖求解,也很少有人和我讨论。无奈之下就只能自己去看书籍找材料来解答。最终能把这篇博文完成我也是很开心的,整个问题的解析过程让我收益良多,希望也能为阅读博文的各位带来帮助。
感谢大家看到这里。