继承与原型链
我的一点个人理解
继承是复用代码的一种常用手段。js 的继承机制采用原型链,个人感觉这可能是为了方便,可以直接“草率”的写下一个对象“面量”,而不用 Java 那样什么都经过精心的绘制对象的蓝图(类),然后才能“实例化”。它的哲学貌似是直接以某个对象为蓝图。我不知道这样做是好事坏!对于遍地需要大型前端项目的今天,基于原型的继承貌似有点承受不住了!
JavaScript 的继承数据结构——原型链,需要关注的是 原型 二字,而不是 链 这个字。因为就我个人所知,继承机制都是采用“类链式”结构,包括Java。这点很好理解,因为继承是有“先来后到”的,继承经常需要“纠结”的就是顺序问题。链式结构自然的描述了这种先来后到的关系。
原型链的数据结构
原型链有点像数据结构中的链表,总体而言它包含两部分:
- 本节点: 承载属于对象自身的属性、方法
__proto__
: 指向对象自己的原型(非公开接口)
当我们对一个对象进行属性访问的时候,如: obj.name
,js 的解析器会先从 obj 的“本节点”中找是否有 name 这个属性,如果没有则会通过 __proto__
向上查找,直到找到这个属性或者 __proto__
指向 null 为止。
需要说明的是,__proto__
不是一个公开接口,从 es6 之后我们可以通过 Object.getPrototypeOf
与 Object.setPrototypeOf
来取代 __proto__
。
es6 还提供了 Object.getOwnPropertyNames
这个方法,它可以获取一个对象自身的部分属性。结合这个方法,我们可以打印某个对象的整条原型链,看看每个链上的节点都给这个对象赋予了什么属性。
我们可以写段代码来实验下:
const $p = document.querySelector('p');
function recursionProtoNode(curr, idx) {
if (curr === null) {
return;
}
const myProperties = Object.getOwnPropertyNames(curr);
// console.log(`Node: ${idx}, properties count: ${myProperties.length} properties: ${myProperties.join(',')}`)
// DOM节点的属性实在太多了,我们就只打印出数量,不打印具体属性吧!
console.log(`Node: ${idx}, properties count: ${myProperties.length}`)
const myPrototype = Object.getPrototypeOf(curr);
recursionProtoNode(myPrototype, idx + 1);
}
recursionProtoNode($p, 0);
到浏览器的控制台里执行下:
Node: 0, properties count: 1
Node: 1, properties count: 2
Node: 2, properties count: 136
Node: 3, properties count: 134
Node: 4, properties count: 48
Node: 5, properties count: 4
Node: 6, properties count: 11
可以看到,一个 p 元素对象的原型链上6个节点(0是这个 p 元素自己),每个节点都赋予(继承给)了这个 p 元素一些属性,2号跟3号节点赋予的属性比较多。
写时“复制”
在JS里,原型只是提供“代码”的复用,也就是“读”。但是在“写”的时候,就没有原型链什么事了。
const a = Object.create({"name": "hehe"}); // 以 {"name": "hehe"} 这个面量对象为原型,创建出一个对象 a
a.name; // 通过原型链,a 能访问到 name 这个属性,"hehe"
a.hasOwnProperty("name"); // 但是,这个属性显然不是 a 自己的,所以这里返回 false
a.name = "haha"; // 在“写”的时候,创建a自己的"name"属性
a.hasOwnProperty("name"); // --> true
JS的这种写时“复制”(新建)的机制,符合原型与实例隔离的需求。“写时复制"的思想,经常出现在计算机体系当中。
继承蓝图
最简单的继承方式当然是 Object.create()
了,这似乎也符合基于原型继承的初衷,但是实际上我们很少这么使用。我们更多的还是像 Java 那样描述一幅“蓝图”,然后再生产实例。对于 JS 来说,构造函数及其 prototype 属性就是那幅“蓝图”
通常来说,JS 的函数对象会有一个 prototype
属性。当这个函数用 new
调用的时候,这个 prototype
会成为 new
出来的对象的原型(即通过 Object.getPrototypeOf 获取的对象)。
这当中还有一些特殊情况,这里不展开说。比如:箭头函数、Generator 函数等,更具体的见 Function.prototype。
我们把准备用来 new 对象的函数称为构造函数 constructor
,一般用大驼峰命名。举个简单例子:
// Animal 函数准备用作构造函数,所以用大驼峰命名
function Animal() {}
// 函数自带 prototype 属性,可以看到这个 prototype
// 对象还有一个 constructor 属性指向 Animal 自身
console.log(Animal.prototype); // -> true
const a = new Animal();
// new 出来的对象 a 的原型就是 Animal.prototype
console.log(Object.getPrototypeOf(a) === Animal.prototype); // -> true
// a 有一个属性 constructor 指向 Animal。其实这个属性是原型 Animal.prototype
// 上的属性,并非 a 自身的。
console.log(a.constructor === Animal); // -> true
console.log(a.constructor === Animal.prototype.constructor); // -> true
那么如何使用 JS 的继承"蓝图”?一般这样使用:
- 可以直接初始化的属性、公共的函数定义在 prototype 上;
- 需要外部参数参与的初始化,在构造函数里定义;
继续以 Animal 为例:
// 造物主,给每个动物指定了年龄上限。
function Animal(maxAge) {
this.maxAge = maxAge;
}
// 初始年龄为0
Animal.prototype.age = 0;
// 初始体重为1
Animal.prototype.weight = 1;
// 每年增加1岁,增加随机体重
Animal.prototype.growUp = function () {
this.age = this.age + 1;
if (this.age > this.maxAge) {
console.log('This animal died!')
return;
}
const deltaWeight = Math.random() * 10;
this.weight = this.weight + deltaWeight;
console.log(`This animal age is ${this.age}, weight is ${this.weight}`)
}
// 一般约定,把直接定义在构造函数上的属性,当中其它语言的静态属性来使用。
Animal.kindName = "Animal";
// 蓝图设计好了,实例化一个看看
const a = new Animal(60);
a.growUp();
// 静态属性不存在于实例上
console.log(a.kindName); // -> undefined
上面逐步给 Animal.prototype
添加属性的步骤最好不要取巧写成:
Animal.prototype = {
age: 1,
weight: 1,
growUp () {
// ... 省略
}
}
我犯过这样的错误。这样会把 Animal.prototype 对象覆盖掉,constructor 属性就丢了。虽然暂时不知道这样做会有什么后果。
接下来开始继承。再以 Cat 继承自 Animal 为例:
// 多一个颜色属性,并且猫咪一出生就确定了颜色
function Cat(maxAge, color) {
// 调用父类来帮助我们初始化属性
// 这是 function constructor 跟 es6 class 最重要的区别:
// 以此为例,this 对象是在 Cat 里实例化的,你要何时调用
// Animal.call(this, maxAge) 都是随你意,甚至不调用都可以。
// 而 es6 class,this 对象其实是 Animal 实例化的,并且强制
// 你要先调用父类构造函数。
Animal.call(this, maxAge);
this.color = color;
}
// 将 Cat.prototype 的原型指向 Animal.prototype
Object.setPrototypeOf(Cat.prototype, Animal.prototype);
// 定义 Cat 新增的函数,抓老鼠
Cat.prototype.catchMouse = function () {
console.log('我在抓老鼠');
}
var cat = new Cat(20, 'orange');
// cat 通过原型链(cat -> Cat.prototype -> Animal.prototype)继承了 Animal 的属性与方法
console.log(cat.age); // -> 0
console.log(cat.weight); // -> 1
console.log(cat.growUp === Animal.prototype.growUp); // -> true
cat.growUp();
cat.catchMouse();
实现继承的关键在于指向好原型链,通过 Object.setPrototypeOf 来让子"类"(这姑且称为"类"吧)构造函数的 prototype 指向父类构造函数的 prototype。
吐槽 Function.prototype 的命名
这里不得不吐槽一下这个属性名字——prototype(原型),实在容易把人绕晕。辨析一下:
- 假设有一个 function 函数名为 Foo,请问 Foo 的原型是 Foo.prototype 还是 Object.getPrototypeOf(Foo) ?
- 假设这个用这个 Foo new 出一个对象 a,请问存不存在 a.prototype 这个属性? a 的原型又是什么?如何获取?
吐槽归吐槽,也得理解下,毕竟据说第一版 JS 10天就写出来了。
实现一个instanceof()
对于 a instanceof B
, instanceof 这个操作符的本质是,确认 B.prototype 在不在 a 的原型链上。
function myInstanceOf(obj, constructor) {
while(obj) {
if (obj === constructor.prototype) {
return true;
} else {
obj = Object.getPrototypeOf(obj);
}
}
// 跳出循环,说明已经找到了原型链的顶端还是没有找到。
return false;
}
console.log(myInstanceOf(cat, Cat)); // -> true
console.log(myInstanceOf(cat, Animal)); // -> true
// Object 估计是所有 js 对象的原型链上的一环
console.log(myInstanceOf(cat, Object)); // -> true
console.log(myInstanceOf(cat, Array)); // -> false
感想
回头再看看,现在已经很少用这种方式去写继承了,基本就是用更方便的 es6 class。两种的差别没有去细究。感觉日常编程用到 class 也只是很浅的去使用,更多的是当做一个逻辑聚合工具来使用,很少真的去用继承。