原型链和继承 -- Javascript基础探究篇(9)

587 阅读10分钟

js不同于传统的面向对象的语言,它并没有类的概念(即使ES6中已经有class关键字)。所以js的面向对象编程采用了一种特殊的方式,这种方式就是原型链。原型和原型链是js的核心,保证了函数或对象中的方法或属性能够被子类复用。

原型链

我们先从一个简单的例子看看什么是原型链,我们在浏览器直接输出对象a(假设此时a = {name: "xxx"}):

object-prototype

可以看到对象a有一个__proto__属性,该属性所指向的对象具有一些如hasOwnProperty等属性。所以当我们使用a.hasOwnProperty("name")能正确运行。这是因为虽然a在自己的属性中找不到hasOwnProperty属性,但是它能通过__proto__属性继续查找,最后就能找到hasOwnProperty属性。

这种通过__proto__所连接起来的搜索轨迹就是原型链。

__proto__是很多浏览器实现但非标准的属性,我们通常不会直接操作这个属性,而是通过js的一些内置方法来操作,后续会讲到这些方法。

get操作

当在获取对象某个属性时,如果无法在对象本身找到该属性,会访问对象的原型链继续查找,直到找到匹配的属性并返回;或者查找完整的原型链还未找到,此时会返回undefined。这就是原型链的get操作。

const obj1 = { a: 1, b: 2 };
const obj2 = Object.create(obj1);
obj2.c = 3;
console.log(obj2.a, obj2.b, obj2.c); // 1, 2, 3

其中Object.create是用于创建一个新对象,并将该对象的原型链关联到obj1。此时obj2的结构如下:

create

obj2获取a或者b属性时,显然它自身并没有这些属性,所以会继续查找它的原型链,首先找原型链关联的最近一层对象,即obj1,显然在obj1中能够找到这些属性。

for...in...循环会遍历完整的原型链输出所有的属性,所以如果只想要遍历对象本身的属性,需要使用hasOwnProperty过滤。in操作符同样也会检测某个属性是否存在于对象中或者对象的原型链中:

// 接上述例子
for (const key in obj2) {
  console.log(key); // a, b, c 
}

for (const key in obj2) {
  if (obj2.hasOwnProperty(key)) {
    console.log(key);  // c
  }
}

console.log("c" in obj2); // true
console.log("a" in obj2); // true

与作用域链类型,js在原型链上也只会找第一个匹配到的属性;如果原型链后续层级中还有同名属性,会被屏蔽。

const obj1 = { a: 1, b: 2 };
const obj2 = Object.create(obj1);
const obj3 = Object.create(obj1);

obj2.a = 3;
obj3.c = 3;

console.log(obj2.a, obj3.a); // 3, 1

使用isPrototypeof可以判断两个对象之间的关系,即一个对象是否出现在另一个对象的原型链中:

console.log(obj1.isPrototypeOf(obj2));  // true

set操作

当我们为对象属性设置值时,如obj.a = 10。首先会有一个get操作过程获取该属性所在位置。其过程大致如下:

  • 如果对象本身具有该属性
    • 判断该属性是否是访问描述符(即存在getter或者setter
      • 如果存在setter,则调用setter方法,
      • 否则在非严格模式会静默失败,在严格模式会报错
    • 否则,判断该属性是否可写(即writable = true
      • 如果是,则修改该属性
      • 否则在非严格模式会静默失败,在严格模式会报错
  • 如果对象原型链中具有该属性
    • 判断该属性是否是访问描述符(即存在getter或者setter
      • 如果存在setter,则调用setter方法
      • 否则在非严格模式会静默失败,在严格模式会报错
    • 否则,判断该属性是否可写
      • 如果是,则直接在对象本身创建一个新的属性a,并赋值为10,屏蔽原型链中的同名属性
      • 否则在非严格模式会静默失败,在严格模式会报错
  • 如果该属性不存在于对象及对象原型链中
    • 直接在对象本身创建一个新的属性a,并赋值为10
const obj1 = { a: 1 };
const obj2 = Object.create(obj1);

console.log(obj2.hasOwnProperty("a")); // false

obj2.a = 10;
console.log(obj2.hasOwnProperty("a")); // true, 在obj2中创建一个属性,屏蔽原型链中同名属性

函数原型

在js中,函数也是对象,所以函数允许拥有其他属性,每个函数都有一个特殊的属性prototype

这里讨论的函数是除箭头函数外的函数

如果我们自定义一个函数,那么它的prototype是一个只包含constructor属性的对象(忽略原型链的前提下),且constructor的值就是函数本身。

function Foo() {}
console.log(Foo.prototype); // {constructor: Foo}

foo

js内置函数的prototype包含很多其他属性和方法,如Object.prototype就包含了诸如hasOwnProperty等属性或者方法。

原型链的尽头

我们现在已经知道了js对原型链的操作流程,那么原型链的尽头是什么呢?所有原型链最终都会指向内置的Object.prototype,并且Object.prototype.__proto__值为null。所以js遍历原型链时(即遍历__proto__)会一直到它为null为止,此时js就会认为已经到了尽头。正因为如此它包含了许多js中的通用功能。

原生函数的原型链关系

当我们创建一个字符串类型变量后,在调用它的一些方法时,会自动将它转换为它对应String对象(类似Java的装箱),此时能使用String.prototype中的方法(具体细节查看new到底干了啥)。

类似的,当我们声明其他字面量类型变量(numberboolean)时,当我们在调用某些属性时,js会自动进行封装对象操作。所以此时对象实例的原型链能够关联上构造函数的prototype属性。当我们声明对象类型变量时,也会在声明后自动将其原型链和对应构造函数的prototype属性关联上。

下图就展示常用内置对象的原型链关系(结合继承小节使用更佳):

builtin

其中Function.prototype是一个空函数,Array.prototype是一个空数组,RegExp.prototype是一个空的正则表达式。

面向对象和继承

虽然在js并没有类的概念,但是我们能以一种奇怪的方式模仿类:

// 约定构造函数的首字母需要大写(这不是必须,只是让它看起来更像一个类)
function Foo() {
  this.a = 1;
  this.b = 2;
}

Foo.prototype.getValue = function () {
  return [this.a, this.b];
};
const foo = new Foo();
console.log(foo.getValue()); // [1, 2]

在上述代码中,Foo就相当于传统面向对象语言中的类,而foo就是类的实例。所以foo就能访问到类中一些方法。

new到底干了啥

在继续讲继承之前,我们先来看看new做了什么,使得实例能够访问到构造函数的prototype属性。

new大概做了这几件事:

  1. 创建一个空的临时对象tmp
  2. tmp的原型链关联到构造函数的prototype属性
  3. 绑定构造函数的this指向为tmp,并运行函数
  4. 判断构造函数返回值,如果返回值是对象,则返回这个对象;否则返回tmp
function mockNew(fn, ...args) {
  const tmp = Object.create(fn.prototype);
  const result = fn.call(tmp, ...args);
  return typeof result === "object" ? result : tmp;
}

function Foo(a, b) {
  this.a = a;
  this.b = b;
}

Foo.prototype.getValue = function () {
  return [this.a, this.b];
};

const foo1 = new Foo(1, 2);
const foo2 = mockNew(Foo, 11, 22);
console.log(foo1.getValue()); // [1, 2]
console.log(foo2.getValue()); // [11, 22]

现在我们知道了,当使用了new Foo()后,foo1foo2Foo的关系是:

foo-class

注意:实例foo1foo2的原型链其实是和构造函数的prototype关联上的,而不是关联构造函数本身。

构造函数中prototype.constructor是一个很迷惑人的属性,给人的感受是:该属性是指“由......构造”。但这个属性可被修改,所以请把它就当做一个普通的属性看待。

将上述例子稍加修改:

function Foo(a, b) {
  this.a = a;
  this.b = b;
}

Foo.prototype = {
  getValue() {
    return [this.a, this.b];
  },
};
const foo1 = new Foo(1, 2);
console.log(foo1.getValue()); // [1, 2]
console.log(foo1.constructor); // ???

此时我们为Foo.prototype显示赋值一个新的对象,这个新的对象里面没有constructor属性。那么此时foo1.constructor输出的是什么?我们第一直觉可能是undefined,但实际结果是function Object(即Object构造函数)。虽然新的对象里面不存在constructor属性,但该对象的原型链是关联了Object.prototype,而Object.prototype中具有constructor属性,它指向Object构造函数本身。

甚至,我们还可以修改prototype.constructor的指向:

function Other() {}

function Foo(a, b) {
  this.a = a;
  this.b = b;
}
Foo.prototype.constructor = Other;
Foo.prototype.getValue = function () {
  return [this.a, this.b];
};
const foo1 = new Foo(1, 2);
console.log(foo1.getValue()); // [1, 2]
console.log(foo1.constructor); // function Other

可以看出prototype.constructor是一个非常不可靠并且不安全的引用,虽然它在默认情况下确实有“由......构造”的意思。不要因为它的名字为它赋予过多意义。

继承

我们现在来看看使用原型链如何实现继承,废话不多说,先上代码:

function Foo(a, b) {
  this.a = a;
  this.b = b;
}
Foo.prototype.getValue = function () {
  return [this.a, this.b];
};

function Bar(a, b, c) {
  Foo.call(this, a, b);
  this.c = c;
}
// 创建一个新的Bar.prototype,并将它的原型链关联到Foo.prototype
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.getMoreValue = function () {
  return [this.a, this.b, this.c];
};

const bar = new Bar(1, 2, 3);
console.log(bar.getValue()); // [1, 2]
console.log(bar.getMoreValue()); // [1, 2, 3]

这段代码的关键有两处:

  1. 在调用new Bar(1, 2, 3)时,运行了Foo.call(this, a, b)。保证ab两个变量顺利初始化
  2. 通过Bar.prototype = Object.create(Foo.prototype)Bar.prototypeFoo.prototype关联上,保证bar的原型链关联上Bar.prototypeFoo.prototype。虽然这种方式会丢失默认的Bar.prototype主要影响是新建的Bar.prototype不具备constructor属性
    • 如果希望保留原始prototype属性,可使用Object.setPrototypeOf(Bar.prototype,Foo.prototype),它会把现有的Bar.prototypeFoo.prototype关联起来
    • 一定不要使用Bar.prototype = Foo.prototype,这和我们想要的结果是不一样的,它只是让Bar.prototype直接引用Foo.prototype,结果就是后续Bar.prototype.getMoreValue=......会直接修改Foo.prototype本身

此时的关系图为:

bar-class

构造函数与实例

我们可以使用instanceof操作符判断一个对象实例和构造函数之间的关系。instanceof的作用是判断在对象的整个原型链中是否包含指向构造函数prototype的对象。

instanceof的左边操作数是一个对象,右边是一个函数。以继承的例子为例:

console.log(bar instanceof Bar);  // true
console.log(bar instanceof Foo); // true
console.log(bar instanceof Object); // true

而使用Object.getPrototypeOf时只会获取对象原型链的第一层所指向的对象:

console.log(Object.getPrototypeOf(bar) === Bar.prototype); // true
console.log(Object.getPrototypeOf(bar) === Foo.prototype); // false
console.log(Object.getPrototypeOf(bar) === Object.prototype); // false

来自es6时代的光

可以看到使用原型链的方式实现类和继承十分别扭,所幸es6为我们提供了class关键字(它的本质是原型链版本的语法糖),让我们能够以更接近传统面向对象语言的方式编写类。

关于class的详细的使用不是本章的重点,详解请查阅阮一峰大佬的文章

通过class关键字改写上述例子:

class Foo {
  constructor(a, b) {
    this.a = a;
    this.b = b;
  }

  getValue() {
    return [this.a, this.b];
  }
}

class Bar extends Foo {
  constructor(a, b, c) {
    super(a, b); // 必须放在第一行
    this.c = c;
  }
  getMoreValue() {
    return [this.a, this.b, this.c];
  }
}

const bar = new Bar(1, 2, 3);
console.log(bar.getValue()); // [1, 2]
console.log(bar.getMoreValue()); // [1, 2, 3]

可以看出使用class代码语义性更强,也不需要我们显式的操作原型链。

es6class和es5的原型链在继承时最大的不同:

  • es6classcontructor中,必须先调用super方法。这是因为子类的this对象必须先通过父类构造函数完成塑造,得到父类同样的实例属性和方法,然后在对其加工,加上子类的实例属性和方法。如果不调用super方法,子类就得不到this对象,所以在子类的构造函数中,只有调用super之后,才可以使用this关键字
  • 而在es5的原型链版本中则没有这个限制,所以子类BarFoo.call(this, a, b)没有位置限制。

我们看一下class所编译的es5代码就能明白原因:

function _inheritsLoose(subClass, superClass) {
  subClass.prototype = Object.create(superClass.prototype);
  subClass.prototype.constructor = subClass;
  subClass.__proto__ = superClass;
}

var Foo = /*#__PURE__*/ (function () {
  function Foo(a, b) {
    this.a = a;
    this.b = b;
  }

  var _proto = Foo.prototype;

  _proto.getValue = function getValue() {
    return [this.a, this.b];
  };

  return Foo;
})();

var Bar = /*#__PURE__*/ (function (_Foo) {
  _inheritsLoose(Bar, _Foo); 

  function Bar(a, b, c) {
    var _this;
    // 下面这一行就是super(a, b)的编译结果,试想如果它不是第一行,那么_this就不能被正确的初始化
    _this = _Foo.call(this, a, b) || this;  
    _this.c = c;
    return _this;
  }

  var _proto2 = Bar.prototype;

  _proto2.getMoreValue = function getMoreValue() {
    return [this.a, this.b, this.c];
  };

  return Bar;
})(Foo);

var bar = new Bar(1, 2, 3);
console.log(bar.getValue()); // [1, 2]
console.log(bar.getMoreValue()); // [1, 2, 3]