携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第4天,点击查看活动详情
构造函数定义
构造函数也称为构造器(constructor),我们先来看看百度百科对构造函数的定义:
构造函数,是一种特殊的方法。主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值,总与 new 运算符一起使用在创建对象的语句中。
js 中,只要一个函数被 new
操作符调用了,那么它就是构造函数(箭头函数除外)。比如我们可以定义一个普通的函数 Fn 如下(如果一个函数为构造函数,它的函数名首字母一般为大写,不然天知道这么普普通通的函数竟是构造函数):
// 例 1
function Fn() {
console.log('今天是周四')
}
想要调用 Fn,我们可以直接 Fn()
执行。也可以通过 new Fn()
,函数一样会执行。通过 new
调用时,如果不需要对函数传入参数,函数名后面的括号都可以省略,直接 new Fn
。
new 一个函数的背后
当我们通过 new
去调用一个函数时,这个函数就是一个构造函数,背后到底发生了什么呢?其实 js 会在背后悄咪咪地做下面这些事:
- 在内存中创建一个新的空对象;
我们可以 new
例 1 声明的 Fn 来做个测试:
console.log(new Fn())
打印结果如下图,可以看到返回的是一个类型为 Fn 的空对象 :
- 构造函数的
prototype
的值会被赋给第 1 步创建的对象的 [[Prototype]] 属性; - 构造函数内部的
this
,也会指向第 1 步创建的对象; - 执行函数的内部代码(函数体);
- 如果构造函数没有返回非空对象,则返回第 1 步创建的对象(其实也就是
this
)。
下面对第 2 步的内容做些解释:
对象的 [[Prototype]] 属性(隐式原型)
[[Prototype]] 是在 ECMAScript 标准里,对象的原型属性的表示。比如在 Standard ECMA-262 6th Edition 中就有这么一句:
All ordinary objects have an internal slot called [[Prototype]].
在浏览器的实现中,其实就是 __proto__
,一般称之为隐式原型,因为我们一般不会直接使用它。比如我们可以新建一个空对象 obj,然后打印它的 __proto__
:
但因为 __proto__
是浏览器实现的,所以在生产环境中我们最好不要直接使用它,而是使用 es5 规范里提供的 Object.getPrototypeOf()
方法来查看对象的 [[Prototype]] 属性。
在谷歌浏览器中直接打印一个对象时,也能看到 [[Prototype]]:
对象的 [[Prototype]] 属性的值,可以看成是一个空对象。事实上此处 obj.__proto__
指向的是 Object.prototype
,所以看上面截图返回的对象里有很多内容,但请注意,里面的属性都是浅色的,因为它们的 enumerable 特性都为 false。在 Node.js 中打印 Object.getPrototypeOf(obj)
返回的就是一个空对象:
隐式原型的作用
当我们获取一个对象的某个属性时,会触发 [[Get]] 操作:首先会在对象本身的属性当中寻找,如果没有,就会去该对象的原型(隐式原型)/原型链中继续查找。
// 例 2
const obj = {}
obj.__proto__.name = 'Jay'
console.log(obj.name) // Jay
原型链
在上面这个例子中,如果 obj
对象的隐式原型中还是没有找到 name
,因为 obj.__proto__
本身也是个对象,所以它也有 [[Prototype]] 属性,我们让它指向另一个空对象,该对象依然有 [[Prototype]] 属性,可以再次指向另一个对象,由此形成原型链。可以先画个简单的示意图如下:
换成代码如下:
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))
结果如下图所示,可以看到该对象身上有很多不可枚举的属性:
现在,我们来画一下例 3 的代码在内存中的表现:
现在就比较好理解为何在例 2 中,如果直接让 obj.__proto__.__proto__.name = 'Jay'
会报错了,因为 obj.__proto__.__proto__
为 null
,自然不能给它设置 name
属性 。所以我们需要在第 3 行,让 obj.__proto__
指向一个新的空对象,该对象为字面量创建的,其 __proto__
默认依旧指向 Object.prototype
:
如果某个属性沿着原型链一直找到了 Object.prototype
仍然没有找到,则会返回 undefined
。
函数的 prototype(显式原型)
首先,函数也是对象,所以函数也会有隐式原型,其指向为 Function.prototype
此处不细说。此外,每个函数还会有一个显式原型属性 prototype
。 new
一个函数后的第 2 步,以下面这段代码为例,相当于执行了 f.__proto__ = Fn.prototype
:
// 例 4
function Fn() {}
const f = new Fn()
console.log(f.__proto__ === Fn.prototype) // true
内存结构图
例 4 的前 2 句代码的内存示意图如下:
f 就是 new
函数 Fn 后创建并返回的空对象,它的 [[Prototype]] 属性,也就是隐式原型 __proto__
会被赋值为其构造函数 Fn 的显式原型 Fn.prototyp
。Fn 函数的原型对象中的 constructor 属性指向的是 Fn 函数对象本身,这是怎么回事呢?下面就来解释下原因。
显示原型对象的属性
例 4 中,如果通过 Node.js 执行 console.log(Fn.prototype)
,返回会是一个空对象。但在浏览器中执行则可以看到如下图所示:
会有一个浅色的属性 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))
结果如下图:
显式原型的作用
我们在创建构造函数的时候,将通用的方法,定义到函数的显式原型对象上。比如构造函数 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 函数本身。我自己结合自身理解参照网络资料画了张示意图如下:
比较有意思的地方在于构造函数
Function
和构造函数 Object
之间的关系,一方面,Object
是由 new Function()
产生的;另一方面,Function
又是继承自 Object
,因为 Object.prototype
是在 Function
的原型链上的 —— console.log(Function instanceof Object) // true
。