原型链的独特之处及其背后的编程哲学
原型链是很特别的设计,它使得 js 的继承机制独树一帜。在 js 的世界里,不存在类继承,只有对象继承。
在 js 诞生之初是没有类这个概念的,只有用来创建对象的构造函数,而函数本身只是一种特殊的对象。即便后来出现了 class,也没有改变本质。js 的 class 和 c++ / java 里面的 class 有本质区别。js 的 class 几乎只是构造函数的语法糖,下面两种写法时等价的:
class Person { constructor(name) { this.name = name } getName() { return this.name } }
function Person(name) { this.name = name } Person.prototype.getName = function() { return this.name }
实际上第二种写法更加本质。定义一个类,其实没有类,其实是定义了两个对象:一个构造函数 Person 加一个原型对象 Person.prototype。当用 new Person('张三') 创造出“张三”这个对象时,“张三”的原型自动指向 Person.prototype,这样它就拥有了 getName()。
prototype vs [[prototype]],Object vs Function
原型链之所以非常让人迷惑,就是因为有这两对东西。
第一对,prototype 不是 [[prototype]],它们是两个不同的指针。[[prototype]] 是对象中真正指向原型的指针,一般是不可见的,需要通过 Object.getPrototypeOf() 获取,但在一些浏览器中可以用 __proto__ 取到。prototype 就是在定义构造函数的时候用到的那个 prototype,它是构造函数的一个属性,指向一个对象,当 new 出来实例时,该对象会成为实例的原型,也就是说,实例的 [[prototype]] 会指向构造函数的 prototype。在上面的例子中,“张三” 的 [[prototype]] = Person.prototype。
第二对,Object 和 Function 分别是对象和函数的最原始的构造函数,但是 Object instanceof Function 的结果是 true,Function instanceof Object 也是 true。好了,到底是先有鸡还是先有蛋呢?谁才是最终的那个造物主呢?
这两对困惑其实是一体两面,背后是同一个东西,也就是下面这张图:
这个图中间有一条虚线,划分为上下两个部分。上面部分是在 js 代码执行之前,就由系统初始化好,存在于全局当中的。下面部分是之后,用户写的 js 代码创建的对象。
上面部分有两个非常特殊的对象,暂且将其称作 AoO (ancestor of object,对象的祖先)和 AoF(ancestor of function, 函数的祖先)。这两个对象就像亚当和夏娃,在一切 js 代码执行之前就被创造出来,承担所有对象祖先的角色。
其中,AoO 里面定义了一些非常通用的,所有对象都会继承到的方法,典型如 toString()。AoO 的 [[prototype]] 指向 null。
AoF 里面定义了所有函数会继承到的方法,典型如 apply()。AoF 的 [[prototype]] 指向 AoO。
然后是 Function,它的特别之处在于它可以创建函数,而且它的 prototype 和 [[prototype]] 都指向 AoF。
再来是 Object,Object 负责创建对象,所以它的 prototype 指向 AoO,这样所有它 new 出来的实例才会继承 AoO。但是有意思的,Object 的 [[prototype]] 指向 AoF,这使得 Object 看起来好像是由 Function new 出来的,但实际上不是。这是系统刻意这样安排,因为,Object 也是一个函数,理论上,Object 应该是 Function 的一个实例。因此,Object instanceof Function 为 true。而 Function instanceof Object 也为 true,因为 AoO 也在 Function 的原型链上,只不过中间隔了一层 AoF。
所以,Object 和 Function 互为彼此的实例,并不是因为它们互相创建出了对方,而是系统刻意这样安排它们的原型链,从而达到这样一种效果。
之后就到了虚线下面的部分。当运行下面的代码:
class Person { constructor(name) { this.name = name } getName() { return this.name } }
时,就创建了 Person 和 Person.prototype。即使不定义 Person.prototype,Person.prototype 也会默认存在。之后再 new,就出现了“张三”、“李四”等等。
接着,再定义一个子类:
class Woman extends Person {
constructor(name) {
super(name)
this.gender = 'female'
}
getGender() {
return this.gender
}
}
就创造出了 Woman 构造函数和 Woman.prototype,并且 Woman 继承自 Person,Woman.prototype 继承自 Person.prototype。然后 Woman 的实例继承自 Woman.prototype。至此,形成了两条互相平行的原型链:
1,王五 -> Woman.prototype -> Person.prototype -> AoO
2,Woman -> Person -> AoF
最终 AoF -> AoO 进行汇合,万物归宗,全部都继承自 AoO。
基因造人 vs 模板造人
c++ / java 的 class 和对象的关系相当于基因和人的关系。class 是基因,由基因产生出来的人,一辈子都摆脱不了这个身份。张三是黄种人,那他永远都是黄种人。黄种人是张三不可分割的个人特征,写在脸上,非常明显。
而 js 造人,是像女娲造人一样,参照着某一个模板把人捏出来。构造函数就是这个模板。张三出生在中国,但是中国人并没有明显地写在他脸上,他一生中可能移民几次,先后变成了美国人、日本人,最后又变回中国人,都有可能。这是因为,可以通过 Object.setPrototypeOf() 来修改对象的原型,从而导致张三的身份是可变的。
可变类型还可以产生另一个效果:可以先创造出对象,再来设计对象的类型。先用一个空的构造函数创造出许多对象,然后根据需要,往构造函数的 prototype 中添加方法。
鸭式辨型
既然 js 中的类型这么变化无常,那么在用 js 编程的时候就要屏弃传统的类型思维。鸭式辨型的意思是:“像鸭子一样走路并且嘎嘎叫的动物就是鸭子”。虽然它可能实际上是一只奇怪的鸡。这颇有一种英雄莫问出处,只看长相的意味。鸭式辨型在 ts 中得到了进一步的支持和发展。ts 的 interface 也很特别,咋看起来它好像和传统语言的 interface 是一样的,但其实其背后的设计思路完全不同。ts 的 interface 是一种对结构的描述,编译器根据这个描述来做类型检查。于是,它并不要求对象显示地实现 interface,只要能通过检查就行。而且,本着“结构描述”这个定位,ts 的 interface 做得比传统的 interface 更强大,比如它还可以描述传入的对象必须是可以通过数组下标的方式去访问的,或者描述传入的对象必须是一个 class 的 constructor 等等。
纯对象的世界
模板造人和鸭式辨型,其实折射出 js 底层是一个没有类的世界。在一个没有类的世界里,一切都是自由的。在提供了巨大的灵活性的同时,也导致了不可控的问题。灵活性是一把双刃剑,高手拿到这把剑削铁如泥,而普通人拿到这把剑则可能伤到自己。而且太灵活也给工程化增加障碍,这也是 ts 出现的原因之一。但是,尽管有这些问题,js 依然是非常有特色的语言,毕竟类的存在意义就是创建实例,而绝大多数时候,人们其实只需要创建一个实例,却不得不为了这个实例去定义类。定义了类,就必须管理这些类的继承关系,同时类型检查也是基于类。这样就变成了面向类型编程,而真正重要的其实是对象。运行时存在的是对象,完成工作的是对象。人们面向类型编程,其实白白增添了很多思维负载。