..

继承与原型链

我的一点个人理解

继承是复用代码的一种常用手段。js 的继承机制采用原型链,个人感觉这可能是为了方便,可以直接“草率”的写下一个对象“面量”,而不用 Java 那样什么都经过精心的绘制对象的蓝图(类),然后才能“实例化”。它的哲学貌似是直接以某个对象为蓝图。我不知道这样做是好事坏!对于遍地需要大型前端项目的今天,基于原型的继承貌似有点承受不住了!

JavaScript 的继承数据结构——原型链,需要关注的是 原型 二字,而不是 这个字。因为就我个人所知,继承机制都是采用“类链式”结构,包括Java。这点很好理解,因为继承是有“先来后到”的,继承经常需要“纠结”的就是顺序问题。链式结构自然的描述了这种先来后到的关系。

原型链的数据结构

原型链有点像数据结构中的链表,总体而言它包含两部分:

  • 本节点: 承载属于对象自身的属性、方法
  • __proto__: 指向对象自己的原型(非公开接口)

当我们对一个对象进行属性访问的时候,如: obj.name,js 的解析器会先从 obj 的“本节点”中找是否有 name 这个属性,如果没有则会通过 __proto__ 向上查找,直到找到这个属性或者 __proto__ 指向 null 为止。

需要说明的是,__proto__ 不是一个公开接口,从 es6 之后我们可以通过 Object.getPrototypeOfObject.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 也只是很浅的去使用,更多的是当做一个逻辑聚合工具来使用,很少真的去用继承。