这是一篇关于JavaScript原型知识的还债帖

1,792 阅读13分钟

前言

本人从事前端开发的工作三年有余,我要向你坦白,时至今日我对JS原型仍然是一知半解,当年的校招面试关于JS原型都是“临时抱佛脚”,死记硬背混过去鸟~ ~。

在日后工作中,我已熟练的使用Function去封装类,使用mixin去丰富类,使用new去实例化我钟意的对象(单身狗的悲哀),然而却忘了它们背后蕴含的原理。

痛定思痛,本文算作是还债帖。

两种数据类型

在JS里,分了两种数据类型,分别为基本数据类型引用数据类型

目前基本数据类型已有:number string boolean undefined null symbol bigint

它们的区分点在存储位置的不同。基本数据类型是直接存储在栈内存中的,而引用数据类型则存储在堆内存里的。

《每天都在写的JS判断语句,你真的了解吗?》一文中提到我们可以使用typeof运算符去识别变量的数据类型。如下图:

在其中,typeof null === 'object'是个例外,它是唯一一个无法通过typeof识别出来的基本数据类型。

计算机都是以二进制方式来存储数据,JS中如果二进制前三位都是0时会判定为object类型,而null的二进制前三位恰好都是0,所以返回object

除了null这个特例,我们发现所有引用数据类型又分为了两个阵营:functionobject。它们是否有内在联系呢?

Function和Object是好基友👬

先抛出个问题,什么是对象? 我会给出这样的答案:所谓对象,都有自己的属性和方法

这是我自己的认识,不一定正确,欢迎你评论,说出你的想法。

那么,函数是对象吗?是!因为它符合上面的定义:

function Foo(){}
Foo.name = 'Foo'
Foo.getName = function(){return Foo.name}
Foo.getName()  // Foo

在JS中,对象是“第一等公民”,那么怎样才算“第一等公民”?

  • 可以被动态创建;
  • 可以赋值给变量;
  • 可以作为函数的入参或出参;
  • 可以包含自己的属性或方法;

显然,函数符合这四个条件,所以函数也是“第一等公民”。所以,函数即对象

事实上,在ECMA标准中,已直接将函数划归到 Sandard Built-in ECMAScript Objects

既然“函数即对象”,JS又怎样具体区分出函数与对象呢?ECMA给出了答案: 如果一个对象有内部属性[[Call]],则它是一个函数; 如果一个对象没有内部属性[[Call]],则它是一个普通对象;

函数肩负了两项职责:

  1. 逻辑函数:用于封装业务逻辑,处理事务;
  2. 构造函数:用于对象实例化,此时必须用new操作符调用函数;
function Person(name){
    this.name = name
}
let p = new Person()
typeof Person  // function
typeof p  // object

在JS中,已经为我们准备好很多的内置构造函数,比如Function Object Array Date RegExp等。当你使用typeof操作符识别它们,它们都会返回function

typeof Function  // function
typeof Array  // function

你有没有发现,new Date()Date() 你都可以得到一个 Date 实例化对象,你知道怎么做到的吗?欢迎评论 。

至此我们有了基本认知,首先函数拥有对象相同的能力(函数即对象),同时函数还能实例化对象。在Function和Object背后一定有着“隐蔽”的内在联系。

原型对象prototype

计算机执行任何逻辑都需要成本:时间成本和内存成本。同样一件事两种做法,显然我们会选择成本更低的那种做法。同样的,JS也“不笨”。

先看如下示例代码:

let dog1 = {
    name: '旺财',
    species: '犬',
    bark: function(){
        console.log('汪汪!')
    }
}

let dog2 = {
    name: '大黄',
    species: '犬',
    bark: function(){
        console.log('汪汪!')
    }
} 

狗生艰难,都要背负属性species和方法bark,任重而道远。于是旺财和大黄商议,能不能有个”代理“的机制,将共性部分的属性都交给这个代理,由代理全权负责,自己只保留不同的部分(name)。于是有了这个”代理“:

let agent = {
    species: '犬',
    bark: function(){
        console.log('汪汪!')
    }
}
let dog1 = {
    name: '旺财',
    __agent__: agent
}
let dog2 = {
    name: '大黄',
    __agent__: agent
}

当访问到一个属性,自己身上没有的,就去”代理“那里找。如果找到了,皆大欢喜;未果,万分抱歉。

”代理“就如同制作陶瓷碗的模具,它记录了陶瓷碗的高度、碗口大小等,这样保证制作出每个碗都是同种规格。而各个碗又可以涂上不同的色彩,保留了它的个性。

在JS中,这个”代理“就是原型prototype。原型保存了同类型对象的共享属性和方法,实质上就是为了节省内存空间。从上面的示例中看到agent是一个对象,同样JS中prototype也是一个对象,即原型对象

那么,这个prototype对象应该放在哪里最合适呢?(小蝌蚪开始找妈妈了)

我们从上一节中知道,对象可以由函数实例化,即用new操作符运行函数,相当于同类型的对象也都是由同样的函数实例化的,此时函数变身为构造函数。-->(同规格的碗一定是从一个模子里倒出来的)

另一方面,函数即对象,那么就可以在函数上挂载一些属性。Bingo!把prototype挂载在构造函数上一定是最合适的,因为对象实例化一定会经历构造函数运行。 于是,修改一下上面的示例:

function Dog(name){
    this.name = name
}
Dog.prototype = {
    species: '犬',
    bark: function(){
        console.log('汪汪!')
    }
}
// Oops, 好像丢了什么关键信息?

let dog1 = new Dog('旺财')
let dog2 = new Dog('大黄')
dog1.species === dog2.species  // true

细心的读者在这里应该会纠正我了,给Dog构造函数直接赋值prototype会丢失一些信息。哈,没错,就是contructor属性,暂且不表。先要研究在new的过程中,是怎样操作这个原型的。

new操作符 与 [[Prototype]]

引自MDN中的定义,new运算符创建一个用户定义的对象类型的实例或具有构造函数的内置对象的实例。

管它是啥,我们关心的是new内部的猫腻。 以let dog = new Dog('旺财')为例,new进行了如下操作:

  • Step 1:创建一个空对象 obj = {}
  • Step 2:将obj的内部属性[[Prototype]]指向构造函数Dog的原型,即obj.[[Prototype]] = Dog.prototype
  • Step 3:将构造函数Dogthis指向这个空对象obj
  • Step 4:执行构造函数Dog,函数内部对this的操作等同于操作obj对象;
  • Step 5:如果函数Dog指定了返回值,则正常返回这个返回值;否则,返回obj对象;

以上就是new操作符的黑魔法,它已经帮我们完成了对象实例化后原型的绑定,完成了“代理”指定。 其中,提到了obj空对象的内部属性[[Prototype]],它在new过程中会被指向构造函数的原型,相当于这个空对象obj继承了原型对象。 依据ECMA标准中的描述,每个对象都会有这个[[Prototype]]内部属性,为的就是实现对象继承。

既然是内部属性,我们自然无法直接通过obj.[[Prototype]]访问到。目前有两个方式可以从一个对象obj上获取到它继承的原型对象:

  1. 野路子obj.__proto__
  2. ECMA官方Object.getPrototypeOf(obj)

之所以将obj.__proto__称为野路子是因为对象上的__proto__属性是由各个浏览器自己实现的,目的是为了方便开发者调试代码,ECMA官方可不承认这种方式哦。

在ECMA2015中没有任何关于__proto__的说明,在最新的ECMA标准中你可以在附录中找到关于__proto__说明,但它是可配置的属性,即可能会被任意覆盖。

因此切记:不要在生产环境中使用__proto__,请使用Object.getPrototypeOf(obj),当且仅在开发环境中可以使用__proto__。 如果你实在忍不住,也请做好字段校验 const hasProto = '__proto__' in {}

prototype vs [[Prototype]]
这里势必要把两者拿出来比较一下,毕竟用了同一个单词嘛。请记住以下两句话:

  • 只有函数才有prototype属性,prototype是一个对象(即,原型对象),对象上包含所有实例对象共享的属性或方法;
  • 所有对象都有[[Prototype]]内部属性,它指向创建该对象的构造函数的原型对象;

比较拗口,请多读几遍这两句话,理解其中含义!

依据“函数即对象”的定义,函数会同时拥有prototype[[Prototype]]属性,对象不会有prototype属性,只有[[Prototype]]属性。

至此,函数与对象果然是有“一腿”的,依靠prototype[[Prototype]]产生了“剪不断理还乱”的关系。

constructor属性

每个对象都有构造函数!

上面提到我们可以通过Object.getPrototyoeOf(obj)方法方便的获取到任一对象的原型对象。那么我们也需要方便的从一个对象上面轻松获取到该对象的构造函数。

读取对象obj.constructor属性就可以获得它的构造函数,这里需要注意的是,constructor是挂载在构造函数的原型对象prototype上的。而在实例化对象上读取的constructor属性都是从它的构造函数原型上继承来的。

上节中我们漏掉了对constructor的修正,如果粗暴的直接修改Dog函数的原型,会丢失constructor属性,访问dog1.constructor,得到的是从Object构造函数原型上继承来的constructor,显然是不正确的,需要将constructor指向正确的Dog函数上:

function Dog(name){
    this.name = name
}
Dog.prototype = {
    constructor: Dog,
    species: '犬',
    bark: function(){
        console.log('汪汪!')
    }
}

let dog1 = new Dog('旺财')
dog1.constructor === Dog  // true

有个面试题:问Dog.prototype = {xxx: yyy}Dog.prototype.xxx = yyy的区别在哪? --> 前者需要修正constructor

真的需要修正constructor吗?
平心而论,我在写函数封装类的时候,经常会覆盖它的原型,并且忘记修正constructor属性,但也没有任何异常的事情发生,久而久之,不修正constructor属性成了自然。

没错,不会有什么异常的事情发生,前提是你不会显式的调用constructor

constructor指向构造函数,所以constructor是函数,可以被直接调用。 仍然是上面的示例(不修正constructor),通常而言,我们是直接调用new Dog()去实例化对象,而不会直接调用constructor,那如果直接调用constructor呢?

let dog2 = new dog1.constructor('大黄')
dog2.name  // undefined
dog2 instanceof Dog // false
dog2 instanceof Object // true
dog2 instanceof String // true

为什么dog2String的实例,留给你去思考啦

如果不显式的调用constructor好像也没啥问题啊,那你能保证你使用的某个第三方库就不会显式的调用你传递给它的对象的constructor吗?

养成习惯,及时修正你的constructor

再谈Function 和 Object

从上节中,大致清楚了如何通过new构造函数来实例化一个对象,那么作为普通函数和普通对象的构造函数FunctionObject是怎样的关系?

首先,FunctionObject是内置构造函数,所以它们是函数,毋庸置疑:

typeof Function // function
typeof Object // function

其次既然是函数,那么它们都有prototype对象属性,即Function.prototype Object.prototype。 然后,prototype是对象,就有[[Prototype]]内部属性,指向创建该对象的构造函数的原型对象:

Function.prototype.__proto__ === Object.prototype  // true
Object.prototype.__proto__ === Object.prototype // Oops!糟了,如果这里要是成立的话,就会陷入死循环了

JavaScript为了不陷入死循环,所谓“恩恩怨怨何时了”,总归需要一个终点,不能无休止遍历下去,因此当访问到Object.prototype.__proto__时,将强制返回null,意思是你访问到头了,没有更多内容了,洗洗睡吧。(标准中关于Object Prototype的说明

同时,我们在标准中也获取到如下两条信息:

  1. 每个内置函数或内置构造函数的[[Prototype]]都指向Function.prototype
  2. 每个内置原型对象的[[Prototype]]都指向Object.prototype,除了Object.prototype自身以外;

在前面,我们知道函数的原型应该是对象,即typeof Foo.prototype === 'object'是成立的。但是!Function.prototype却是一个例外typeof Function.prototype === 'function',不是object

我从ECMA2015中找到了关于Function.prototype的描述:

The Function prototype object is itself a Function object (its [[Class]] is "Function") that, when invoked, accepts any arguments and returns undefined. 即 Function.prototype是函数对象 (它的内部属性[[Class]] 是 ‘Function’)

自ECMA2015如此描述后,为了兼容它,以后的所有ECMA版本也都“将错就错”了。

ECMA:你大爷终究是你大爷,哈哈

至此,可以得到Function与Object的关系图如下:

原型链

讨论了这么多,思绪有点乱了,我将所有关系者整合到一张关系图中:

从这张关系图中,我们可以归纳出几个信息点:

  1. 函数必然有prototype属性,原型是一个对象,包含所有实例对象共享的属性或方法;
  2. 无论函数还是对象都有[[Prototype]]内部属性,指向原型对象,其中Object.prototype.[[Prototype]]指向null,以表示原型查找的终点;
  3. prototype对象上挂载了constructor属性,指向构造函数;

那么当我们在谈论原型链的时候,究竟我们在谈论什么?

如果你想真实看到这条“链”,那么它就是关系图中的粉色虚线框所示,但是这里原型链只是宾语,谈论原型链时,不可以丢失主语,即foo对象的原型链。直观的说,原型链就是对象间用[[Prototype]]内部属性串联起来的单向链表

所以,函数Foo的原型链是怎样的,我想你也能在关系图找到。 函数Foo的原型链可以访问到Function.prototype.callFunction.prototype.apply

在JS中,我理解的原型链其实是一种算法,即在对象上查找属性的算法。 以运行foo.someFunc()为例:

  • 首先,查找foo对象上是否已定义someFunc方法,未果,next;
  • 取得foo.[[Prototype]],即Foo.prototype对象,查找对象上是否已定义someFunc方法,未果,next;
  • 取得Foo.prototype.[[Prototype]],即Object.prototype对象,查找对象上是否已定义someFunc方法,未果,next;
  • 取得Object.prototype.[[Prototype]],发现是null,宣告此次查找失败,抛出TypeError错误,结束此次查找,Game over。

总结

码了这么多字,无非想和你达成如下共识:

  • 函数即对象;
  • 只有函数才有prototype属性,prototype是一个对象(即,原型对象),对象上包含所有实例对象共享的属性或方法;
  • 所有对象都有[[Prototype]]内部属性,它指向创建该对象的构造函数的原型对象prototype
  • 一般而言,原型对象上都会有constructor属性,指向原型绑定的构造函数;
  • 原型链是一种在对象上查找属性的算法;

你是否有不同的看法?欢迎评论。

预告
基于原型,JS是如何做到类继承的?ES6中,class A extends B背后藏着什么猫腻?如何通过“混入mixin”来完成多重继承?

最后

码字不易,如果:

  • 这篇文章对你有用,请不要吝啬你的小手为我点赞;
  • 有不懂或者不正确的地方,请评论,我会积极回复或勘误;
  • 期望与我一同持续学习前端技术知识,请关注我吧;
  • 转载请注明出处;

您的支持与关注,是我持续创作的最大动力!