前言: 在参加工作的面试过程中,我搬出了我的个人掘金账号写在了简历里,面试官很感兴趣,他不仅关注了我的账号,还想让我写一篇《原型链》的见解,由于老早就想总结一篇关于原型的文章,奈何自己刚开始的知识储备不足,导致无从下手。今天正好回顾一下基础知识,本文也就此诞生。
今天我就以一个新手过来者的角度去谈一谈这两个对于 JS 来说十分重要的概念的理解,我向来不喜欢刻板地背概念似的介绍某一个知识点,我会以最小的代码量,最少的专业名词试着让你自己去理解这个概念。
如果你对原型和构造函数的概念很模糊,或者说理解的不是那么的清楚,又或者说能说上个一二三,但是让你完整表达出来却又无从下嘴,那么本文将非常适合你阅读。
读完本文你会深刻理解 new 关键词背后发生的故事,接下来的内容可能会推翻你之前对于构造函数的认知。
一. 开胃小菜(一个简单的思考题)
- 首先我们不考虑什么是原型,我们先来定义一个对象。
可以很清晰的看到,这个名叫 hzf 的对象身上有三个属性,其中有一个叫 say 的属性,它的值是一个函数。现在我想让你调取这个 say 函数,你怎么办呢? - “简单,我反手就是一个
hzf.say()
”。恭喜你,你现在距离领悟原型又近了一步。是的,我没和你开玩笑。 - ok,难度增加。我们现在定义了一个数组,内容很简单,只是简单的三个数字组成。
- ok,现在的需求是,分别在控制台输出数组中的每一个元素。
- “这还不简单?用 forEach 啊!“你几乎是条件反射的想法写了下面的代码。
你看着控制台上的输出开始怀疑这篇文章到底在讲什么。 - 你还记得刚刚我们如何调用对象 hzf 身上的 say 方法吗?
hzf.say()
。对!你不觉得我们调用 forEach 的方式和它十分相似吗? - 在完成上面的需求时,我们几乎是一瞬间就写下
arr.forEach
这样的调用方式,但是你不觉得很奇怪吗? - 这里有一个非常重要的前提是:
say
这个属性对我们来说是十分清晰的,什么意思呢?因为say
是我自己亲手定义在 hzf 对象身上的方法,是我敲下键盘上的一个字母一个字母拼出来say
这个单词,然后定义在对象hzf
上面的,所以我可以很理所当然的hzf.sya()
这样来调用对吧?。 - 那么问题来了,你什么时候给 arr 身上加过叫
forEach
的方法吗?我记得应该没有吧?那好奇怪啊!你为什么可以调用一个你从来没定义过的方法呢? - 下面引出我们标题要讲的 js 的核心概念之一----原型。
二. 初识原型 prototype
- 首先我们不要质疑为什么原型叫 prototype 这个单词,因为这个单词本身就是原型的意思。
- 回到上面的 arr,我们先在控制台输出一下 arr。
- 看着这个输出结果,你就算让我拼命想,我也真想不到 forEach 这个单词和现在的 arr 到底有什么关系。
- 等等,我好像注意到了什么,为什么这下面还有个 prototype 属性?我得点开看看。
我们不仅发现了 forEach,并且好像还发现了很多很多我们压根没在arr
身上定义的方法。 - 好奇怪啊?我明明记得我没定义过 prototype 这个属性啊?等等,我好像想起来了,NDN 上介绍的写法是,Array.prototype。那这个 prototype 和 arr 现在的这个 prototype 有没有什么联系呢?
- 咱们一步一步来。接着再来看一张我在 MDN 上截的一张图。
- 不知道大家最开始和我有没有相同的疑问🤔,这作者到底重复写这么多遍这个 “prototype” 干啥啊?我知道你想告诉我,这是数组身上的一些方法,那你可以直接写 Array.forEach 不就完事了吗?不禁让我想起了在写大学本科论文时拼命凑字数的场景。难道网站作者也是在疯狂凑字数?(不是 XD
- MDN 它甚至在自己举例子的时候都没加 forEach 之前的这一串 Array.prototype。
- 能想到这里,说明你还是很棒的,经过上面的介绍,我们知道了存在 prototype 这样一个东西,但是要想了解原型的全部,我们就必须先了解什么是构造函数。
三. 构造函数
- 我们先来理解为什么会有所谓的构造函数,要想深刻理解它,你就得知道为什么有它?它的出现是为了解决什么问题? 在编程领域,任何一段代码或者任何一个名词都有它存在的意义。
- 我们来模拟一个场景需求: 假设现在给你一个名单,上面有1个人的 name、age 信息,现在让你把这个信息用 对象 object 的形式表示出来。
- “这还不简单?”于是你得意满满地写下了下面这段代码。
- ok,现在增加一点难度,现在有3个人的信息,你还是要把这3个人的信息表示出来。
- 你皱了皱眉头,但还是一行代码一行代码敲着。
你或许只是仅仅简单地 ctrl C+ V 了一下,然后改了名字就完成了这个需求。嗯,不错,很聪明~ - 那我们再加一点点难度,现在需要你创建10个用户的信息。来吧,展示~👋
- 难道你还要再去 ctrl C+ V 10 份,然后一个一个改名字?No,No,No. 程序员都是很懒的,我们有没有更好的方法去实现这个需求呢?啊~要是有一个可以帮我写代码,呸,说错了。啊~要是有一个可以重复生成对象,并自动帮我们填充信息,然后把生产的东西给我们就行了的东西就好了。
- 有这样的一个东西吗?还真有!那就是 JS 一等公民-----函数。
- 知道该使用函数以后,我们很轻易的就可以写出这样的代码。
我们设计了一个名叫 personInfo 的函数,它可以接收两个参数,一个 name,一个 age。我们只需要传递参数给它,然后拿到它的返回值就行了。不仅代码行数减少了,而且排版更加清爽简洁。 - 先别着急下结论,这还不是一个构造函数,我们接着往下走。
四. new 关键词背后做了什么
- 在上面我们了解到,我们可以制造一个函数来专门生产对象返回,其实 JS 还存在另外一种更加简洁的方法。那就是使用 new 关键词。
- 这里我们对 personInfo 不进行任何的改动。
这是控制台的结果: 你可能会想,这不是一模一样?耶~我会用 new 了。很不幸的告诉你,如果这样使用的话,你浪费了很多在调用 new 关键词时帮你自动做的事情。说白了,这里的 new 完全被你浪费了。
那 new 到底帮我们做了什么呢?我们继续往下看。
- 让我们稍微改造一下,替换成一个当你想使用 new 关键词生成对象时,函数 persionInfo 正确的写法。
你甚至连返回值都不需要写,这些脏活累活都交给了 new 来做,你只需要专心传递正确你的数据信息即可。 - 接下来我们来测试一下看看是否可行。
嗯~很好,代码又少写了两行,领导直夸我内行。 让我们对比一下,这是之前的 personInfo 函数。
这是后来的函数:
从对比中你就不知不觉领悟了 new 背后干的事情。- 帮我们自动生成了一个空对象。
- 帮我们自动将 this 指向了上一步创建的那个空对象。
- 帮我们自动返回了这个空对象。
看到这里,你可能都开始抢答了,“啊,我知道了,我知道了,这个用 this 的 personInfo 就是构造函数!”。等等,接下来我说的话可能要推翻你之前的认知。
其实在 JS 中压根不存在所谓的构造函数。这个名词用更精确的术语的来讲应该是函数的构造式 调用。
- 别急,我知道你目前还持怀疑态度,接下来我们就去验证这一点。我们动手写一个空函数,我函数体内什么也不干。我就纯 new 一个东西出来,我要看看这个 kong 到底是什么?
- 如果按照之前的构造函数的说法,那这个 test 也叫构造函数了。你能说它没构造什么东西吗?这不是下面放着了吗?一个空对象 kong。(至于这里面为什么会有一个用双括号包裹【【prototype】】属性,我会在下面讲解到,请仔细阅读。)
等等,我记得创建空对象这个事情不是 new 帮我们做的事情吗?关你这个 test 函数什么事啊?是啊,关你函数什么事情啊?你回忆回忆,就开始我们学习 js 的时候,对构造函数很简单地就下了定义,new 后面的函数,就是构造函数。 - 还有你注意到了吗?我所有的函数都并没有以大写开头,我是故意这样写的,为了就是让我们清楚一点,所谓构造函数大写开头是人们约定俗成的规则,并不是 JS 层面的语法要求。
是的,没错,刚刚我们其实验证了两个事情。一个是 new 关键词确实帮我们生成了一个空对象。第二个是如果存在构造函数,那么所有函数都可以叫做构造函数。所以我们得出结论:
js 里没有所谓真正意义上的构造函数这样一种特殊函数,而是只存在函数的构造式调用,也就是使用 new 关键词去调用一个函数。 我们只是为了方便去表达这样的使用方式,而将这种专门用来被 new 关键词调用的函数称为了构造函数。
- 上面讲解的函数的构造式调用只是让读者有一个思想上的认知,目的并不是让你觉得构造函数这种叫法就是错误的,也不必非得纠结于哪种叫法更优,相反构造函数这样的叫法往往会更容易被大众接受,所以在下面的内容里为了方便,我还是会将这种创建方式直接称为构造函数来讲解。
五. 构造函数的不足
- OK,现在我们已经了解了什么是构造函数。
- 我们现在有了一个新的需求,现在要求 personInfo 函数制造的每个人都会喊自己的名字,也就是每个人都有一个 say 的方法,你该怎么做?
- 经过上面的学习,soeasy,很自然的就可以写出下面的代码。
控制台的效果: - 现在我再把张三叫过来,于是我 new 了一个 zs。
- 我们分析一下上面的代码,我们很可以轻松的想到,hzf 和 zs 两个人确实年龄和名字应该不同,所以它们的 name 属性和 age 属性不相等是很正常的,因为即使是两个空对象,它们是不相等的,更别说属性不同了。
- 但是!这个 say 方法都是喊出各自的名字而已,它没有什么特别之处,也不像 name 和 age 通过参数来显示出自己独一无二。
- 我简单对述上面代码在内存中的关系做一个描述:我们假设一个属性占一份内存,hzf 有三个属性,所以它占了三份。zs 也有三个属性,所以他也占了三份。可以吗?非常可以,我也觉得很行,他俩各自都不影响,挺好的。
但是你考虑过内存的感受吗?这还只是两个数据,数据量大的时候,内存空间是非常宝贵的,我们需要节省着点用。 可能上面有点抽象,让我们抛开代码,我们类比一下现实生活,假如现在你需要一把剪刀,但是目前你没有自己的剪刀。
- 1.你首先想到的是找自己的爸爸要,你跑到了爸爸面前,爸爸看了看柜子,说自己也没有。
- 2.于是你又去找爷爷要,爷爷一看,自己有一把剪刀,于是递给了你。
- 上面的场景中,我,爸爸,爷爷,三个人都有一把剪刀可以吗?怎么不可以?非常可以!但是有必要吗?没有必要。因为剪刀不是常用的东西,我们也不知道谁会再次使用,所以我们就可以先把剪刀放到爷爷那里,爷爷自己用的时候,可以拿出来用;爸爸想用的时候,可以找爷爷要;我想用的时候,也可以去找爷爷要到就行了(这里其实需要再找爸爸要一次),我们三个人共用一个剪刀完全可以满足需求。
- 通过这种思路,我们可以设计出这样的模式:我们把一些通用的东西放到内存上同一个地址里,谁用的时候找我要就行了。
一个很简单的行为,我们就从占用6份空间降低到了5份。 - 你也许会吐槽,“不就节约了一份吗,有这么抠抠搜搜吗?” 这个想法没有任何问题,在我们数据量小的时候完全没有任何错误,但是假如这个函数我需要执行100次呢?按照之前的3个属性3份内存来讲。我就需要占用300份内存。但是如果我换成第二种方法,我就仅仅只需要 201份内存。数据量越大,节约的内存空间也是呈指数级增长的。
- 那 JS 里有充当上面 “爷爷” 角色的东西吗?你别说,还真有。它就是我们的 prototype 这个属性,它是专门用来管理一些我们需要重复使用,但是使用方法完全相同的一些属性。
六. 函数的 prototype 属性
- 我们依旧从
personInfo
函数讲起。 - 我们先来打印一下 personInfo 这个函数的信息,看看它身上有哪些属性。
我们暂时先忽略上面的 caller,arguments 等属性,本文重点不是它们,感兴趣的读者可以自行查阅。
我们可以看到一个特殊的属性,prototype。 - 注意!函数的 prototype 并不是 [[prototype]] 带两个括号的写法
[[]]
,这个小小的区别至关重要!我们回过头看一眼空对象 kong 的 prototype 是带着[[]]
的。 - 带
[[]]
的原因是浏览器所设定的,浏览器想让它用【⭐️prototype⭐️】五角星包裹,它也可以用五角星包裹。和 JS 的语法没有任何关系。这些带双括号的属性是JS引擎用到的,(你可以暂时这样理解) 它能在控制台上打印出来的原因完全是为了方便我们调试和观察,你在 JS 层面是无法访问到的。你可以回忆一下老朋友 Promise 三种状态在控制台的表现形式,也是[[]]
。 - 但是人家函数 personInfo 的 prototype 属性可是是货真价实存在的属性,你可以直接使用点语法看看效果。
- 但是请注意 kong 的
[[prototype]]
是无法访问的。 - 回到我们 personInfo,我们可以看到 prototype 属性压根就没啥特别的,它就是一个普通对象而已,这个对象最开始只有一个 constructor 属性,仅此而已。不要怀疑,它真的没什么特别的,它就是一个普通对象。
- 并且如果你观察地仔细的话,这个默认就有的 constructor 的属性值,其实就是这个 personInfor 本身。我们验证一下:
- 你也许会好奇有什么用?这里我来解释一下,对于函数本身来讲,它就只是静静躺在那里,这个属性值任何一丁点作用都没有,但是一旦你把它当作构造函数使用的时候,它的意义就不同了。
七. object 的 "\_proto_\" 属性
- 让我们重新调用 new 关键词来初始化一个 hzf。
你会发现在控制台上看到我们上面所说的,除了无法获取的 【【prototype】 属性以外,其实就没别的属性了。 - 其实不然,在对象身上还存在一个隐藏的属性
__proto__
。别担心,你在实际开发中也不会真正去调用这个属性,并且该属性已经被弃用了,但是我们今天为了深入理解原型,我们还就得重新把它拎出来讲一讲 。 - 说干就干,我们来打印一下
hzf.__prototype__
属性,看看它到底是什么。
不对劲,怎么感觉这么熟悉呢?你没看错,它就是我们刚刚到函数personInfo
函数身上的 prototype 属性,它们两个其实是同一个对象,也就是说它们两个其实指向的是内存上同一块地址。我们赶紧验证一下: - 在这里我想问,你写过类似于
hzf__proto__=personInfo.Prototype
的赋值语句吗?我想应该没有吧? - 哪有什么岁月静好,只不过是有人在帮你负重前行罢了。这件事又是谁干的?其实就是我们的老朋友---- new 关键词,脏活累活它都替干了。其实在调用 new 关键词的时候,还有一个十分关键的步骤,就是将
hzf
的__proto__
属性指向personInfo.prototype
属性,结合上面的几个步骤,至此我们讲解完了 new 关键词的调用后,背后发生的所有事情。
八. 改造 personInfo
- 所有前置知识我们都了解了,接下来就是让我们完成我们最初的需求。我们只需要将 personInfo 的 say 方法放到 personInfo 的 prototype 属性就ok了。
- 你要知道 prototype 就是一个普通对象,普通对象怎么赋值?直接点语法就完事啦~。就是这么简单
然后我们看一看调用的结果: - 我们再去看一眼,personInfo 的 prototype 属性,果然对象多了一个 say 属性。
- 再看一眼
hzf.__proto__
属性。是的,一模一样,它俩就是同一个对象。 - 在这里我们并没有为
hzf
单独设置 say 属性,然而它却可以调取 personInfo 函数 prototype 的属性。 当有一天
hzf
有了自己的剪刀(say方法)时,它就不需要向他爸爸(personInfo.prototype)询问了。
当我定义了hzf
自己的 say 方法以后,控制台的效果:这种对象的
__proto__
属性和构造函数 prototype 属性好像一根线互相连接在一起的表现形式,就构成了我们俗称原型链。对象还有一个特性,那就是当对象调用某个方法时,如果自身没有,那么它会自动顺着原型链一级一级向上寻找。
九. 回归 Array.prototype.forEach
- 现在让我们重新审视 MDN 上的这些方法,你是不是明白了作者为什么每一个都加上了 Array.prototype 呢?
- 来看我们最初的代码,反应过来了吗?
- 没反应过来,没关系,假如我这样写呢?我调用老朋友 new 去生成这个数组呢?
- 回顾一下 new 关键词发生了什么,然后思考一下 arr 身上有 forEach 这个属性吗?没有的话去谁身上找呢?
- 如果还是没反应过来,没关系,再顺着文章多读几遍。
总结:
不得不说,《费曼学习法》是完全正确的,当你能把你学到的知识用最朴素无华的语言表达出来以后,你才是真正理解了它。
写本篇文章的目的不仅仅想传播这个知识点,也想通过这种方式巩固了自己对原型链的理解,经过梳理,自己之前的知识碎片今天都瞬间融合到了一起,如梦初醒。希望大家可以真正理解 new 之后发生了什么,这其实是本文的核心关键所在。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。