PS:前置知识:原型链
碎碎念
说到继承,第一印象就是红宝书里的各种方法,原型式继承、组合继承、寄生继承balabala。
初学JS的时候看到继承内心是崩溃的,人家py、java一个关键字就搞定了的东西你怎么能玩出这么多花样的。
其实这是语言设计层面的问题,JS并没有像java那种严格的面向对象系统,但是。。。
我们知道面向对象系统其实是可以用数据+过程的方式实现的(没错我说的就是SICP的第三章),那么JS提供了什么原材料让我们来实现面向对象呢?
JavaScript是一门基于原型、函数先行的语言,是一门多范式的语言,它支持面向对象编程,命令式编程,以及函数式编程(来自wiki)。
其实就是用原型啦,具体来说是原型链。
原型链
快速理解
我们通过一道面试题来理解原型链(前方啰嗦预警。。。
Object.prototype.a = 'a'
Function.prototype.b = 'b'
function Animal() {}
const obj = new Animal()
console.log(obj.a) // 'a'
console.log(obj.b) // undefined
PS:太长不看
- 新建的
obj
对象是构造函数Animal
的实例 - 那么
obj
的__proto__
属性指向Animal.prototype
Animal.prototype
是一个对象,因此它的__proto__
属性指向Object.prototype
- 在
obj
中查找a
属性而没有找到 - 去
obj.__proto__
继续查找,没有找到 - 去
obj.__proto__.__proto__
继续查找 - 找到属性
a
,返回它的值'a'
- 在
obj
中查找b
属性而没有找到 - 去
obj.__proto__
继续查找,没有找到 - 去
obj.__proto__.__proto__
继续查找,没有找到 obj.__proto__.__proto__.__proto__
为null
,查找结束,没有找到b
属性,返回undefined
这个xxx.__proto__.__proto__.__proto__
就是原型链,其实是一系列关联起来的对象。
obj -> Animal.prototype -> Object.prototype
总结:原型链就是一系列关联起来的对象。
小疑问
那么我们的b
属性在哪里呢?
其实在Animal
构造函数上,或者说在所有的函数对象上。
我们知道函数字面量其实与下面这种写法是一样的(其实有区别,详见MDN):
const Animal = new Function()
此时的原型链:
// 一个构造函数的原型链
Animal -> Function.prototype -> Object.prototype
所以可以通过原型链取到b
,甚至可以取到a
。
PS: 尤其需要注意的是:
Function.prototype
是一个函数对象。
ES5的继承
// 父类
function Person(name) {
this.name = name
}
Person.prototype.sayHi = function () {
console.log('Hi, this is ', this.name)
}
// 子类
function Student(name, grade) {
// 继承父类的属性
Person.call(this, name)
this.grade = grade
}
// 继承父类的方法
Student.prototype = Object.create(Person.prototype, {
constructor: {
value: Student,
writable: true,
configurable: true,
enumerable: false,
},
})
// 子类自己的方法
Student.prototype.doHomework = function () {
console.log('so much homework!')
}
可以看到,与之前一篇文章一样,构造函数本身和构造函数的prototype
属性仍然是我们关注的重点。
完成继承的步骤:
- 通过
new
关键字调用,新建一个子类的实例对象this
(自动完成) - 构造函数中调用父类的构造函数,来给
this
对象添加属性,子类实例就有了父类中定义的属性 - 修改构造函数的原型,主要是添加丢失的
constructor
属性,并将原型的__prop__
属性设为父类的原型对象(处理原型链,通过Object.create
完成)
Object.create
返回一个新的对象,将第一个参数设置为返回的对象的__proto__
看一下效果:
const p = new Person('Joel')
const s = new Student('Bill', 3)
console.log(p, s)
console.log(s instanceof Student, s instanceof Person)
emmmm,行为符合预期,就是太啰嗦了。
而且如上文所说,这只是实现继承的其中一个范式,这意味着不同的人来写的话会有多种继承的写法,很杂乱。或者可以求助于工具库。
extend
关键字的出现就是为了解决上述两个问题。
what the hell does extend do
继续使用上篇文章的例子
class Person {
constructor(name) {
this.name = name
}
sayHi() {
console.log('Hi, this is ', this.name)
}
waveArm = () => {
console.log(this.name, 'is waving arm')
}
}
class Student extends Person {
constructor(name, grade) {
super(name)
this.grade = grade
}
doHomework() {
console.log('so much homework!')
}
}
经过babel编译后的代码如下(上篇文章中出现过的函数不具体列出):
'use strict';
var _createClass = function () {
...
}();
function _possibleConstructorReturn(self, call) {
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call && (typeof call === "object" || typeof call === "function") ? call : self;
}
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}
function _classCallCheck(instance, Constructor) {
...
}
var Person = function () {
function Person(name) {
var _this = this;
_classCallCheck(this, Person);
this.waveArm = function () {
console.log(_this.name, 'is waving arm');
};
this.name = name;
}
_createClass(Person, [{
key: 'sayHi',
value: function sayHi() {
console.log('Hi, this is ', this.name);
}
}]);
return Person;
}();
var Student = function (_Person) {
_inherits(Student, _Person);
function Student(name, grade) {
_classCallCheck(this, Student);
var _this2 = _possibleConstructorReturn(this, (Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name));
_this2.grade = grade;
return _this2;
}
_createClass(Student, [{
key: 'doHomework',
value: function doHomework() {
console.log('so much homework!');
}
}]);
return Student;
}(Person);
可以看到多了两个函数,分别是:
- _inherits
- _possibleConstructorReturn
我们依旧从最下面开始看。
Person
和Student
两个变量分别是我们的父类和子类,Student
变量保存的依旧是一个IIFE返回的函数。先看这个IIFE(调整了代码顺序,手动把函数声明提升了)。
var Student = function (_Person) {
function Student(name, grade) {
_classCallCheck(this, Student);
var _this2 = _possibleConstructorReturn(this, (Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name));
_this2.grade = grade;
return _this2;
}
_inherits(Student, _Person);
_createClass(Student, [{
key: 'doHomework',
value: function doHomework() {
console.log('so much homework!');
}
}]);
return Student;
}(Person);
原型链的处理
原型链的处理就看_inherits
和_createClass
两个函数。
_createClass
的详细内容在上一篇文章。
IIFE内部的Student
函数就是子类的构造函数,先不看他,先关注_inherits
函数。
function _inherits(subClass, superClass) {
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass;
}
_inherits
函数的签名很好理解
- subClass:子类
- superClass:父类
再来看开头的if
代码块。
if (typeof superClass !== "function" && superClass !== null) {
throw new TypeError("Super expression must either be null or a function, not " + typeof superClass);
}
它要求父类必须是个函数或者null
。
看到这儿你大概会疑惑:父类还能是
null
???是null
的话继承个什么东西???这似乎与我们在ES5中的经验很不一样。因为ES5中父类肯定是一个函数。别着急,继续往下看。
关注一下测试不通过时抛出的错误信息,这里所谓的”Super expression”其实就是父类的构造函数。
接下来就是我们喜闻乐见的处理原型链环节。与ES5中的区别仅仅在于父类为null
时的处理。
subClass.prototype = Object.create(superClass && superClass.prototype, {
constructor: {
value: subClass,
enumerable: false,
writable: true,
configurable: true
}
});
两个分支:
- 如果父类是
null
的话Object.create
返回的就是一个没有__proto__
属性的对象,也就是说原型链在此中断了。 - 否则与我们在ES5中的所做的一样。
最后一个if代码块,整理格式后如下:
if (superClass) {
Object.setPrototypeOf
? Object.setPrototypeOf(subClass, superClass)
: subClass.__proto__ = superClass;
}
同样分为两种情况:
- 父类为
null
,if代码块不执行,此时父类的__proto__
指向Function.prototype
,一个函数对象。 - 否则子类的
__proto__
设为父类构造函数,也是一个函数对象。这是在ES5中没有的操作,是ES6中的规定:子类的__proto__
属性指向父类。
原型链一节说过函数对象其实是Function
构造函数的实例,这样处理之后构造函数的原型链如下
subClass -> superClass -> Function.prototype -> Object.prototype
而如果superClass
是null
呢?
subClass -> Function.prototype -> Object.prototype
这特喵的跟一个普通的构造函数没区别啊。
这里贴一张图
这是我整理了ECMA-262 6th Edition中class
解析的一部分内容。结合这张图应该能将原型链的处理过程看的很清楚了。
可以看到流程图是从有没有父类开始的,也就是说是从直接定义一个
class
还是通过extend
继承一个父类开始的。
构造函数本身
function Student(name, grade) {
_classCallCheck(this, Student);
var _this2 = _possibleConstructorReturn(this, (Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name));
_this2.grade = grade;
return _this2;
}
_classCallCheck
在上一篇文章分析过了。
重要的是_possibleConstructorReturn
这个函数
function _possibleConstructorReturn(self, call) {
if (!self) {
throw new ReferenceError("this hasn't been initialised - super() hasn't been called");
}
return call && (typeof call === "object" || typeof call === "function") ? call : self;
}
函数签名似乎并没有给我们太多的信息,结合它的调用方式一起看:
- self:很简单,就是调用构造函数时内部的
this
对象 - call:
(Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name)
来分析一下call
是什么鬼
(Student.__proto__ || Object.getPrototypeOf(Student)).call(this, name)
上文中我们已经知道了子类的__proto__
被设置成了父类的构造函数或者Function.prototype
,因此这里就是我们在ES5中做的“在子类中调用父类的构造函数”。
我们知道,一般的构造函数是不会有明确的返回值的,因为通过new
关键字调用时如果不明确指定返回值的话默认会返回this
,而这些构造函数直接调用时会返回undefined
,
_possibleConstructorReturn
从名字就可以知道,如果父类的构造函数有明确的返回值(必须得是一个对象),那么就返回它,否则就返回当前的this
对象。
那么问题来了,为什么有这种行为?
从new关键字说起
要搞清楚这个问题得先知道new
关键字做了什么,查阅ES标准:
可以看到new
返回的是Construct(constructor, argList)
请留意这个函数,后文中多次提到。
再来看Construct
做了什么,标准在这里,感兴趣的可以自己去看。
由于本文并不想多涉及环境变量的知识,因此尽量简略的解释Construct
的行为。
每个函数内部都有一个[[ConstructorKind]]
属性,当函数是generator
或者继承了某个类的构造函数时(比如我们的Student
),kind
的值为“derived”,否则为“base”。
Construct
又调用了constructor.[[construct]]
当kind
为“base”,[[construct]]
做了下面这些事:
- 新建一个对象并处理原型链,使新建的对象成为构造函数的实例(特别注意:这个构造函数是指
new.target
指向的那个函数) - 将当前环境中的
this
值绑定为这个新建的对象 - 执行函数体(此时我们的代码开始执行)
- 如果函数有明确的返回值并且返回值是一个
object
,返回它 - 否则返回
this
当kind
为“derived”,[[construct]]
做了下面这些事:
- 执行函数体(此时我们的代码开始执行,小tip:
super
也在这里才开始执行) - 如果函数有明确的返回值并且返回值是一个
object
,返回它 - 否则返回
this
看到区别了吗,第二种情况下没有给当前环境绑定this
值,因此如果此时使用this
值会报错:
这里就扯到了super
关键字了
super作为函数调用
ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先创造父类的实例对象this(所以必须先调用super方法),然后再用子类的构造函数修改this。
上面这段话出自阮一峰老师ECMAScript 6 入门。
emmmmm,反正我第一眼看是不太理解的。
反正,从我们上面的分析来看,想必是super()
绑定了this
喽,继续看super
的行为:
简化后的流程图:
图中:
newTarget
是new.target
指向的函数func
是父类的构造函数- 继续用这些参数调用
Construct
- 调用
Construct
是递归的,直到当kind
为“base”或者某一函数有明确的返回值才会终止
所以说所谓的“先创造父类的实例对象this”描述不太准确,它确实是在父类中被创建,但是被创建的其实还是子类的实例(它的__proto__
指向子类的prototype
),因为new.target
并没有改变,始终是new
关键字后跟的那个函数。
emmmm,在PY这种面向对象的语言中,实例由哪个类创建就是那个类的实例,但是在JS的原型式继承中似乎有点歧义。。。
ES5继承存在的问题
扯远了,回到_possibleConstructorReturn
函数。
设想如下场景,在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]
也就是说Array
忽略传入的this
但是会明确返回一个Array
对象。
而在上一节对new
的分析中我们知道构造函数其实可以指定自己的返回值的,所以当遇到这种有明确返回值的父类,babel
的做法是用父类的返回值替换子类的实例,这样就避免了出现上文所说的丢失特殊行为的现象。
但是这样我们的继承就无效了,因为返回的不是this
,因此子类原型链上的东西全部丢失了。
ES6如何解决这一问题
函数对象之所以被称为函数对象,是因为在内部有[[call]]
和[[construct]]
两个方法,在我们ES5的做法中,对父类构造函数其实调用的是[[call]]
,而在ES6中,如果你仔细看了前文,就会发现super
关键字调用的是[[construct]]
。
这两个调用的最显著区别就是new.target
的值(与环境变量有关)
- 通过
[[call]]
调用构造函数,构造函数内部的new.target
为undefined
- 通过
[[construct]]
调用构造函数,构造函数内部的new.target
为new
关键字调用的那个函数
上文也说了创建的实例的__proto__
是根据new.target
确认的,所以当以ES6的方式(super
)来继承Array
时,在我们碰不到的Array
构造函数内部完成了原型链的处理,而这是polyfill
无法做到的事。。。
此时,再回想一下_possibleConstructorReturn
这个函数的行为,实在是一种无奈之举啊。。。
后记
水平有限,文中难免有错误,欢迎指正。
关于super
的其他方面本文没有涉及。
个人还是比较喜欢语法糖的,毕竟用着爽。
真是拖了好久才写出来。。。
毕设加实习着实有点忙。。
不过好歹是怼出来了。