JS 中 new 一个函数的背后发生了什么?(构造函数、原型与原型链)

2,023 阅读8分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情

构造函数定义

构造函数也称为构造器(constructor),我们先来看看百度百科对构造函数的定义:

构造函数,是一种特殊的方法。主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值,总与 new 运算符一起使用在创建对象的语句中。

js 中,只要一个函数被 new 操作符调用了,那么它就是构造函数(箭头函数除外)。比如我们可以定义一个普通的函数 Fn 如下(如果一个函数为构造函数,它的函数名首字母一般为大写,不然天知道这么普普通通的函数竟是构造函数):

// 例 1
function Fn() {
  console.log('今天是周四')
}

想要调用 Fn,我们可以直接 Fn() 执行。也可以通过 new Fn(),函数一样会执行。通过 new 调用时,如果不需要对函数传入参数,函数名后面的括号都可以省略,直接 new Fn

new 一个函数的背后

当我们通过 new 去调用一个函数时,这个函数就是一个构造函数,背后到底发生了什么呢?其实 js 会在背后悄咪咪地做下面这些事:

  1. 在内存中创建一个新的空对象;

我们可以 new 例 1 声明的 Fn 来做个测试:

console.log(new Fn())

打印结果如下图,可以看到返回的是一个类型为 Fn 的空对象 :

image.png

  1. 构造函数的 prototype 的值会被赋给第 1 步创建的对象的 [[Prototype]] 属性;
  2. 构造函数内部的 this,也会指向第 1 步创建的对象;
  3. 执行函数的内部代码(函数体);
  4. 如果构造函数没有返回非空对象,则返回第 1 步创建的对象(其实也就是 this)。

下面对第 2 步的内容做些解释:

对象的 [[Prototype]] 属性(隐式原型)

[[Prototype]] 是在 ECMAScript 标准里,对象的原型属性的表示。比如在 Standard ECMA-262 6th Edition 中就有这么一句:

All ordinary objects have an internal slot called [[Prototype]].

在浏览器的实现中,其实就是 __proto__,一般称之为隐式原型,因为我们一般不会直接使用它。比如我们可以新建一个空对象 obj,然后打印它的 __proto__

image.png

但因为 __proto__ 是浏览器实现的,所以在生产环境中我们最好不要直接使用它,而是使用 es5 规范里提供的 Object.getPrototypeOf() 方法来查看对象的 [[Prototype]] 属性。 在谷歌浏览器中直接打印一个对象时,也能看到 [[Prototype]]:

image.png

对象的 [[Prototype]] 属性的值,可以看成是一个空对象。事实上此处 obj.__proto__指向的是 Object.prototype,所以看上面截图返回的对象里有很多内容,但请注意,里面的属性都是浅色的,因为它们的 enumerable 特性都为 false。在 Node.js 中打印 Object.getPrototypeOf(obj)返回的就是一个空对象:

image.png

隐式原型的作用

当我们获取一个对象的某个属性时,会触发 [[Get]] 操作:首先会在对象本身的属性当中寻找,如果没有,就会去该对象的原型(隐式原型)/原型链中继续查找。

// 例 2
const obj = {}
obj.__proto__.name = 'Jay'
console.log(obj.name) // Jay

原型链

在上面这个例子中,如果 obj 对象的隐式原型中还是没有找到 name,因为 obj.__proto__ 本身也是个对象,所以它也有 [[Prototype]] 属性,我们让它指向另一个空对象,该对象依然有 [[Prototype]] 属性,可以再次指向另一个对象,由此形成原型链。可以先画个简单的示意图如下:

image.png 换成代码如下:

const obj = {}
obj.__proto__ = {}
obj.__proto__.__proto__.name = 'Jay'
console.log(obj.name) // Jay

注意,我不是直接 obj.__proto__.__proto__.name = 'Jay' 而是在第 2 行先将 obj.__proto__ 赋值了个空对象,否则会报错“TypeError: Cannot set property 'name' of null”。这是因为原型链有个终点的原因,不然就愚公移山子子孙孙无穷尽也了,这个原型链的顶层原型对象,就是 Object 的原型对象 Object.prototype

Object 的原型对象

除了通过字面量创建对象,我们还可以通过

// 例 3
const obj = new Object()

的方式创建,两种方法本质上可以看成是一样的。那么请注意,上文中我们提到,new 一个函数后会有一系列背后的操作,其中第 2 步,就是将构造函数的 prototype 的值,赋给第 1 步创建的对象的 [[Prototype]] 属性。此处即为

obj.__proto__ = Object.prototype

同理,例 2 中通过字面量对象创建的 obj 的原型对象 obj.__proto__指向的也是Object.prototype,而该对象的 [[Prototype]] 属性值为 null,即

console.log(Object.prototype.__proto__ === null) // true

Object.prototype 就是原型链的顶层对象。我们可以打印查看该对象的所有自有属性对应的属性描述符:

console.log(Object.getOwnPropertyDescriptors(Object.prototype))

结果如下图所示,可以看到该对象身上有很多不可枚举的属性:

image.png

现在,我们来画一下例 3 的代码在内存中的表现: image.png
现在就比较好理解为何在例 2 中,如果直接让 obj.__proto__.__proto__.name = 'Jay' 会报错了,因为 obj.__proto__.__proto__null,自然不能给它设置 name 属性 。所以我们需要在第 3 行,让 obj.__proto__ 指向一个新的空对象,该对象为字面量创建的,其 __proto__ 默认依旧指向 Object.prototype
image.png
如果某个属性沿着原型链一直找到了 Object.prototype 仍然没有找到,则会返回 undefined

函数的 prototype(显式原型)

首先,函数也是对象,所以函数也会有隐式原型,其指向为 Function.prototype 此处不细说。此外,每个函数还会有一个显式原型属性 prototypenew 一个函数后的第 2 步,以下面这段代码为例,相当于执行了 f.__proto__ = Fn.prototype

// 例 4 
function Fn() {}
const f = new Fn()
console.log(f.__proto__ === Fn.prototype) // true

内存结构图

例 4 的前 2 句代码的内存示意图如下: image.png

f 就是 new 函数 Fn 后创建并返回的空对象,它的 [[Prototype]] 属性,也就是隐式原型 __proto__会被赋值为其构造函数 Fn 的显式原型 Fn.prototyp。Fn 函数的原型对象中的 constructor 属性指向的是 Fn 函数对象本身,这是怎么回事呢?下面就来解释下原因。

显示原型对象的属性

例 4 中,如果通过 Node.js 执行 console.log(Fn.prototype),返回会是一个空对象。但在浏览器中执行则可以看到如下图所示:
image.png
会有一个浅色的属性 constructor,其值为构造函数(例 4 中也就是 Fn)本身。函数其实有个 name 属性,所以打印

console.log(Fn.prototype.constructor.name) // Fn

得到的会是 Fn。我们还可以打印查看 Fn.prototype 的所有自身属性:

console.log(Reflect.ownKeys(Fn.prototype)) // ['constructor']

返回的结果为 ['constructor'],证明函数的显示原型对象中确实有 constructor 属性。之所以在 node 中打印 Fn.prototype 看不到 constructor,或者在浏览器中显示的是浅色的,其原因为 constructor 的属性描述符中, enumerable 为 false,是不可枚举的。我们可以打印查看 Fn.prototype 的所有属性的属性描述符:

console.log(Object.getOwnPropertyDescriptors(Fn.prototype))

结果如下图:
image.png

显式原型的作用

我们在创建构造函数的时候,将通用的方法,定义到函数的显式原型对象上。比如构造函数 Fn 需要有个 study方法,就可以:

function Fn() {}
Fn.prototype.study = function () {
  console.log('又是在掘金学习的一天')
}

这样,我们创建 Fn 的实例 f 时,虽然其自身并没有 study 方法,但是通过 __proto__ 找到 Fn 的 prototype 对象,也可以调用 study 方法了:

const f1 = new Fn()
const f2 = new Fn()
f1.study() // 又是在掘金学习的一天
console.log(f1.study === f2.study) // true

这样做,比起直接在 Fn 中定义 study 方法,好处在于不会每次创建实例对象时,都需要在内存中创建一个新的 study 函数对象,浪费内存空间:

function Fn() {
  this.study = function () {
    console.log('今天不学习,明天变垃圾')
  }
}
const f1 = new Fn()
const f2 = new Fn()
console.log(f1.study === f2.study) // false

箭头函数没有显示原型

箭头函数中除了没有 this,没有 arguments,也没有显示原型:

const fn = () => {}
console.log(fn.prototype) // undefined

这就意味着箭头函数无法作为构造函数用 new 调用,因为 new 一个函数需要将构造函数的 prototype 的值赋给实例对象的 [[Prototype]] 属性。

实例对象、构造函数和原型对象之间的关系

我们最后总结一下实例对象、构造函数和原型对象之间的关系。函数也是对象,所以也会有__proto__属性,当我们通过 function foo() 定义一个(构造)函数时,相当于是 const foo = new Function(),所以所有函数对象的 __proto__默认都是一样的,指向的是 Function.prototype,包括 Function 函数本身。我自己结合自身理解参照网络资料画了张示意图如下: image.png 比较有意思的地方在于构造函数 Function 和构造函数 Object 之间的关系,一方面,Object 是由 new Function() 产生的;另一方面,Function 又是继承自 Object,因为 Object.prototype 是在 Function 的原型链上的 —— console.log(Function instanceof Object) // true

感谢.gif 点赞.png