深入理解 JavaScript 原型

276 阅读6分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

背景

JavaScript的原型可谓是这门编程语言的核心,比如JavaScript的继承就是基于原型链进行实现的,那么让我们一起深入理解一下JavaScript原型究竟是什么。

数据类型

首先,我们知道在JavaScript中有字符串(string)、数值(number)和布尔(boolean)这样这样的基本类型,也有数组(array)、函数(function)和对象(object)这样的复杂类型。其中对象函数是我们了解原型的两大核心。

原型推导

对象类型

我们先创建一个对象如下

var person = {
  name: 'wilson',
  hobby: 'writing',
  greet: function() {
    console.log(`Hello everyone, I am ${this.name}, I like ${this.hobby}`);
  }
}

person.greet();

我们执行这个文件会输出

Hello everyone, I am wilson, I like writing

Node.js环境

但这和原型有什么关系呢,让我们调试一下上面这个脚本

image.png

这是我们在Node.js环境下调试该脚本所得到的,我们会发现在person这个对象内,除了我们声明的name、age和greet属性,还有一个叫做__proto__的东西,并且它的类型是对象。让我们看一下这个东西都有什么

image.png

浏览器环境

再来看一下浏览器中的情况。我们在浏览器内创建一个同样的脚本并打开调试

image.png

这和Node.js环境中有一些相似的属性,不过对于__proto__,二者的命名不太一样,可见这是一个环境相关的属性,也就是说不同的环境对其的实现可能不一样。

可以发现,在两种环境下__proto__这个对象内部都是一系列属性,由后面的f可知,这些属性的类型都是函数类型。其中我们只看constructor这个属性,它的值是Object(),名叫构造函数

构造函数

构造函数是一种特殊的函数,用于创建对象实例,比如我们定义的person对象,其实就是一个通过Object()函数创建的对象,就像下面这样

var person = new Object({
  name: 'wilson',
  hobby: 'writing',
  greet: function() {
    console.log(`Hello everyone, I am ${this.name}, I like ${this.hobby}`);
  }
})

person.greet();

可以发现,构造函数不同于普通函数的调用方式,就是要通过new关键字进行调用,其含义为调用函数内部的constructor方法,来创建一个实例。所以会存在以下的关系

Object.getPrototypeOf(person).constructor === Object

Object.getPrototypeOf是JavaScript提供给我们访问对象实例原型的方法,因此我们通过调用Object.getPrototypeOf(person)获取到的就是浏览器中看到的__proto__。所以再调用其内部的constructor属性便是Object。

函数类型

这是对于普通对象的现象,我们再来看一下函数

function person(name, age) {
  console.log(`Hello everyone, I am ${this.name}, I like ${this.hobby}`);
}
person('wilson', 'writing');

image.png

image.png

可以发现,person作为函数,相比普通对象,在[[Prototype]]基础上还多了一个prototype属性,其类型是对象,这个对象有只有两个属性——constructor和[[Prototype]]。

其constructor属性是一个和我们声明的函数同名的函数,它的作用是使得我们声明的函数不仅可以通过普通调用的方式使用,还可以作为构造函数进行调用,所以会存在如下关系

person.prototype.constructor === person

函数调用方式

person函数的两种使用方式分别为

function person(name, age) {
  this.name = name;
  this.hobby = hobby;
  console.log(`Hello everyone, I am ${this.name}, I like ${this.hobby}`);
}

// 普通函数调用
const person1 = person('wilson', 'writing');
// 作为构造函数调用
const person2 = new person('tom', 'sleeping');

image.png

可以发现构造函数会有默认返回值,而普通函数的调用是否有返回决定于函数内部是否使用return关键字显式定义。而这里构造函数返回的对象就成为实例,它会有一个[[Prototype]]属性,其内部有两个属性——constructor和[[Prototype]]。

其中constructor就是我们创建person2所使用的person函数,在使用new调用它时,本质上就是在调用该函数。而[[Prototype]]则是person函数的原型,也就是说——函数的原型也是对象

原型添加属性

既然函数的原型是对象,那么我们是否可以向其添加一下属性呢,尝试如下

function person(name, hobby) {
  this.name = name;
  this.hobby = hobby;
  console.log(new Date(), `Hello everyone, I am ${this.name}, I like ${this.hobby}`);
}

const person2 = new person('tom', 'sleeping');
console.log('person2.alive', person2.alive)

person.prototype.alive = true;

const person3 = new person('jerry', 'eating');
console.log('person2.alive', person3.alive)
console.log('person2.alive', person2.alive)

输出如下

Wed Aug 25 2021 22:39:18 GMT+0800 (中国标准时间) "Hello everyone, I am undefined, I like undefined"
person2.alive undefined
Wed Aug 25 2021 22:39:18 GMT+0800 (中国标准时间) "Hello everyone, I am undefined, I like undefined"
person2.alive true
person2.alive true

我们从控制台看一下person函数、person2对象和person3对象的情况如下

image.png

image.png

原型添加函数

可见,当我们向一个函数的原型上添加任意属性后,所有通过该函数的构造函数生成的实例对象会共享函数原型上的属性。同样,由于函数原型是一个对象,所以我们既可以添加属性,也可以添加函数。比如

function person(name, hobby) {
    this.name = name;
    this.hobby = hobby;
    console.log(new Date(), `Hello everyone, I am ${this.name}, I like ${this.hobby}`);
}

person.prototype.greet = function(msg) {
    console.log(msg);
    console.log(`I am ${this.name}`);
}

const person4 = new person('bob', 'singing');
person4.greet('Hi');

输出如下

Wed Aug 25 2021 22:45:59 GMT+0800 (中国标准时间) "Hello everyone, I am bob, I like singing"
Hi
I am bob

这里我们在person函数原型上添加了greet方法,其内部访问了this.name,这里的this在person被new调用时,就会是通过new调用函数返回的实例对象,也就是person4。

这里打印出I am bob,也就意味着是由person4.greet('Hi')调用输出的,但person4为什么会有greet方法呢,就像我们上面所说,通过new调用构造函数得到的实例是会共享函数原型上的属性的。

原型链

那么这里是如何进行共享的呢,就是基于JavaScript内部存在的机制,即当访问一个对象的属性时,如果访问不到,就会查看对象原型上是否有该属性,如果没有则继续往上寻找,直到最终发现,所有的对象的根都是空对象null,而在这个寻找过程中,如果发现有所访问的属性,则停止查找,这就是JavaScript的原型链

上面的寻找过程如下图

image.png

反之,如果访问一个原型链上不存在的属性,则会在寻找到原型链终点后由于没有找到而返回undefined

console.log(person4.wow)

undefined

image.png

总结

本文通过实际的对象定义、函数声明、构造函数调用和原型拓展逐渐揭开JavaScript原型的本质,希望可以借此加深理解原型、构造函数、原型链之间的关系。