一文彻底理解JavaScript原型与原型链

前言

JavaScript中有许多内置对象,如:Object, Math, Date等。我们通常会这样使用它们:

// 创建一个JavaScript Date实例
const date = new Date();
// 调用getFullYear方法,返回日期对象对应的年份
date.getFullYear();
// 调用Date的now方法
// 返回自1970-1-1 00:00:00 UTC(世界标准时间)至今所经过的毫秒数
Date.now()

当然,我们也可以自己创建自定义对象:

function Person() {
    this.name = '张三';
    this.age = 18;
}
Person.prototype.say = function() {
    console.log('say');
}
const person = new Person();
person.name; // 张三
person.say(); // say

看到这些代码,不知道你是否有这些疑问:

  • new关键执行函数和普通函数执行有什么区别吗?
  • 对象的实例为什么可以调用构造函数的原型方法,它们之间有什么关系吗?

接下来,让我们带着这些问题一步步深入学习。

new对函数做了什么?

当我们使用new关键字执行一个函数时,除了具有函数直接执行的所有特性之外,new还帮我们做了如下的事情:

  • 创建一个空的简单JavaScript对象(即{})
  • 将空对象的__proto__连接到(赋值为)该函数的prototype
  • 将函数的this指向新创建的对象
  • 函数中如果没有返回对象的话,将this作为返回值

用代码表示大概是这样:

// 1. 创建空的简单js对象
const plainObject = {};
// 2. 将空对象的__proto__连接到该函数的prototype
plainObject.__proto__ = function.prototype;
// 3. 将函数的this指向新创建的对象
this = plainObject;
// 4. 返回this
return this

可以看到,当我们使用new执行函数的时候,new会帮我们在函数内部加工this,最终将this作为实例返回给我们,可以方便我们调用其中的属性和方法。

下面,我们尝试实现一下new:

function _new (Constructor, ...args) {
  // const plainObject = {};
  // plainObject.__proto__ = constructor.prototype;
  // __proto__在有些浏览器中不支持,而且JavaScript也不推荐直接使用该属性
  // Object.create: 创建一个新对象,使用现有的对象提供新创建的对象的__proto__
  const plainObject = Object.create(Constructor.prototype);
  // 将this指向新创建的对象
  const result = Constructor.call(plainObject, ...args);
  const isObject = result !== null && typeof result === 'object' || typeof result === 'function';
  // 如果返回值不是对象的话,返回this(这里是plainObject)
  return isObject ? result : plainObject;
}

简单用一下我们实现的_new方法:

function Animal (name) {
  this.name = name;
  this.age = 2;
}

Animal.prototype.say = function () {
  console.log('say');
};

const animal = new Animal('Panda');
console.log(animal.name); // Panda
animal.say(); // say

在介绍new的时候,我们提到了prototype,__proto__这些属性。你可能会疑惑这些属性的具体用途,别急,我们马上进行介绍!

原型和原型链

在学习原型和原型链之前,我们需要首先掌握以下三个属性:

  • prototype: 每一个函数都有一个特殊的属性,叫做原型(prototype)
  • constructor: 相比于普通对象的属性,prototype属性本身会有一个属性constructor,该属性的值为prototype所在的函数
  • __proto__: 每一个对象都有一个__proto__属性,该属性指向对象(实例)所属构造函数(类)的原型prototype

以上的解释只针对于JavaScript语言

我们再来看下边的一个例子:

function Fn () {
  this.x = 100;
  this.y = 200;
  this.getX = function () {
    console.log(this.x);
  };
}

Fn.prototype.getX = function () {
  console.log(this.x);
};

Fn.prototype.getY = function () {
  console.log(this.y);
};

const fn = new Fn()

我们画图来描述一下上边代码中实例、构造函数、以及prototype__proto__之间的关系:

一文彻底理解JavaScript原型与原型链_第1张图片

我们再来看一下FunctionObject以及其原型之间的关系:

一文彻底理解JavaScript原型与原型链_第2张图片

由于FunctionObject都是函数,因此它们的所属类为Function,它们的__proto__都指向Function.prototype。而Function.prototype.__proto__又指向Object.prototype,所以它们既可以调用函数原型上的方法,也可以调用对象原型上的方法。

当我们需要获取实例上的某个属性时:

上例中:
  • 实例:fn
  • 实例所属类: Fn
  • 首先会从自身的私有属性上进行查找
  • 如果没有找到,会到自身的__proto__上进行查找,而实例的__proto__指向其所属类的prototype,即会在类的prototype上进行查找
  • 如果还没有找到,继续到类的prototype__proto__中查找,即Object.prototype
  • 如果在Object.prototype中依旧没有找到,那么返回null

上述查找过程便形成了JavaScript中的原型链。

在理解了原型链和原型的指向关系后,我们看看以下代码会输出什么:

const f1 = new Fn();
const f2 = new Fn();
console.log(f1.getX === f2.getX);
console.log(f1.getY === f2.getY);

console.log(f1.__proto__.getY === Fn.prototype.getY);
console.log(f1.__proto__.getX === f2.getX);
console.log(f1.getX === Fn.prototype.getX);
console.log(f1.constructor);
console.log(Fn.prototype.__proto__.constructor);

f1.getX();
f1.__proto__.getX();
f2.getY();
Fn.prototype.getY();
// false
// true

// true
// false
// false
// Fn
// Object

// 100
// undefined
// 200
// undefined

到这里,我们已经初步理解了原型和原型链的一些相关概念,下面让我们通过一些实际例子来应用一下吧!

借用原型方法

JavaScript中,我们可以通过call/bind/apply来更改函数中this指向,原型上方法的this也可以通过这些api来进行更改。比如我们要将一个伪数组转换为真实数组,可以这样做:

function fn() {
  return Array.prototype.slice.call(arguments)
}
fn(1,2,3) // [ 1, 2, 3]

这里我们使用arguments调用了数组原型上的slice,这是怎么做到的呢?我们先简单模拟下slice方法的实现:

arguments是一个类似数组的对象,有length属性和从零开始的索引,它可以调用Object.prototype上的方法,但是不能调用Array.prototype上的方法。

Array.prototype.mySlice = function (start = 0, end = this.length) {
  const array = [];
  // 一般会通过Array的实例(数组)调用该方法,所以this指向调用该方法的数组
  // 这里我们将this指向了arguments = {0: 1, 1: 2, 2: 3, length: 3}
  for (let i = 0; i < end; i++) {
    array[i] = this[i];
  }
  return array;
};

function fn () {
  return Array.prototype.mySlice.call(arguments);
}

console.log(fn(1, 2, 3)); // [1, 2, 3]

可能你想直接调用arguments.slice()方法,但是遗憾的是arguments是一个对象,不能调用数组原型上的方法。

当我们将Array.prototype.slice方法的this指向arguments对象时,由于arguments拥有索引属性以及length属性,所以可以像数组一样根据length和索引来进行遍历,从而相当于用arguments调用了数组原型上的方法。

下面是另一个借用原型方法常见的例子:

Object.prototype.toString.call([1,2,3]) // [object Array]
Object.prototype.toString.call(function() {}) // [object Number]

这里将Object.prototype.toStringthis由对象(Object的实例)改为了数组(Array的实例)和函数(Function的实例),相当于为数组和函数调用了对象上的toString方法,而不是调用它们自身的toString方法。

通过借用原型方法,我们可以让变量调用自身以及自己原型上没有的方法,增加了代码的灵活性,也避免了一些不必要的重复工作。

实现构造函数之间的继承

通过JavaScript中的原型和原型链,我们可以实现构造函数的继承关系。假设有如下A,B俩个构造函数:

function A () {
  this.a = 100;
}

A.prototype.getA = function () {
  console.log(this.a);
};

function B () {
  this.b = 200;
}

B.prototype.getB = function () {
  console.log(this.b);
};

方案一

这里我们可以让B.prototype成为A的实例,那么B.prototype中就拥有了私有方法a,以及原型对象上的方法B.prototype.__proto__A.prototype上的方法getA。最后记得要修正B.prototypeconstructor属性,因为此时它变成了B.prototype.constructor,也就是B

function A () {
  this.a = 100;
}

A.prototype.getA = function () {
  console.log(this.a);
};
B.prototype = new A();
B.prototype.constructor = B;
function B () {
  this.b = 200;
}

B.prototype.getB = function () {
  console.log(this.b);
};

画图理解一下:

一文彻底理解JavaScript原型与原型链_第3张图片

下面我们创建B的实例,看下是否成功继承了A中的属性和方法。

const b = new B();
console.log('b', b.a);
b.getA();
console.log('b', b.b);
b.getB();
// b 100
// 100
// b 200
// 200

方案二

我们也可以通过将父构造函数当做普通函数来执行,并通过call指定this,从而实现实例自身属性的继承,然后再通过Object.create指定子构造函数的原型对象。

function A () {
  this.a = 100;
}

A.prototype.getA = function () {
  console.log(this.a);
};
// 继承原型方法
// 创建一个新对象,使用一个已经存在的对象作为新创建对象的原型
B.prototype = Object.create(A.prototype);
B.prototype.constructor = B;

function B () {
  // 继承私有方法
  A.call(this); // 如果有参数的话可以在这里传入
  this.b = 200;
}

B.prototype.getB = function () {
  console.log(this.b);
};

这里我们再次通过画图的形式梳理一下逻辑:

一文彻底理解JavaScript原型与原型链_第4张图片

下面我们创建B的实例,看下是否成功继承了A中的属性和方法。

const b = new B();
console.log('b', b.a);
b.getA();
console.log('b', b.b);
b.getB();
// b 100
// 100
// b 200
// 200

class extends实现继承

es6中为开发者提供了extends关键字,可以很方便的实现类之间的继承:

function A () {
  this.a = 100;
}

A.prototype.getA = function () {
  console.log(this.a);
};
// 继承原型方法
// 创建一个新对象,使用一个已经存在的对象作为新创建对象的原型
B.prototype = Object.create(A.prototype);
B.prototype.constructor = B;

function B () {
  // 继承私有方法
  A.call(this); // 如果有参数的话可以在这里传入
  this.b = 200;
}

B.prototype.getB = function () {
  console.log(this.b);
};

下面我们创建B的实例,看下是否成功继承了A中的属性和方法。

const b = new B();
console.log('b', b.a);
b.getA();
console.log('b', b.b);
b.getB();
// b 100
// 100
// b 200
// 200

大家可能会好奇classextends关键字是如何实现继承的呢?下面我们用babel 编译代码,看下其源码中比较重要的几个点:

一文彻底理解JavaScript原型与原型链_第5张图片

看下这俩个方法的实现:

一文彻底理解JavaScript原型与原型链_第6张图片

值得留意的一个地方是:extends将父类的静态方法也继承到了子类中

class A {
  constructor () {
    this.a = 100;
  }

  getA () {
    console.log(this.a);
  }
}

A.say = function () {
  console.log('say');
};

class B extends A {
  constructor () {
    // 继承私有方法
    super();
    this.b = 200;
  }

  getB () {
    console.log(this.b);
  }
}
B.say(); // say

extends的实现类似于方案二:

  • apply方法更改父类this指向,继承私有属性
  • Object.create继承原型属性
  • Object.setPrototypeOf继承静态属性

结语

理解JavaScript的原型原型链可能并不会直接提升你的JavaScrit编程能力,但是它可以帮助我们更好的理解JavaScript中一些知识点,想明白一些之前不太理解的东西。在各个流行库或者框架中也有对于原型或原型链的相关应用,学习这些知识也可以为我们阅读框架源码奠定一些基础。

到此这篇关于一文彻底理解JavaScript原型与原型链的文章就介绍到这了,更多相关JS原型与原型链内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

你可能感兴趣的:(一文彻底理解JavaScript原型与原型链)