likes
comments
collection
share

拒绝死记硬背,从核心需求理解 JS 原型

作者站长头像
站长
· 阅读数 10

前言

相信很多同学在学习 js 的时候都在原型和原型链上栽过跟头,知识点众多,逻辑相互交织,每次都是记了后面忘了前面,下面这张图想必大家都不陌生:

拒绝死记硬背,从核心需求理解 JS 原型

网上也不乏各种对原型 / 原型链的讲解,但大多是对知识点的无脑列举,看的时候感觉讲的很透彻,看完之后啥也没记住。最后面试讲不清,做题全不对。哪怕是背到吐,过几天不看立马忘个精光。

特别是上面这张图,有很多人尝试从图里找到原型的本质来加以理解,这实际上是不对的,这张图更像是一个省去了证明过程的答案,你可以快速的找到某个结论对不对,但是如果想从这个答案入手反推,那反而更容易陷入死记硬背的怪圈。

导致这种情况实际上还是知识没有形成体系,无法逻辑自洽相互印证。本篇文章就从最核心的需求出发,一步步推理,一生二、二生三、三生万物,最终掌握 JS 原型的全貌。

JS 原型的诞生

放轻松,不会一上来就会列举各种烧脑的名词和结构。咱们先来讲讲故事,聊一下原型这玩意是怎么来的。

时间拨回到 1995 年,当时互联网才刚刚兴起,当时的 Netscape 0.9 才刚刚支持超文本的浏览,但就算如此,一经发布依旧疯狂席卷了市场。而我们的主角,JS 的创造者,非常热爱 Scheme 的 Brendan Eich 被网景以 “在浏览器里写 Scheme” 为诱饵打动而跳槽了。

而 Brendan Eich 在四月份加入网景之后,发现现状并不如他想象的那般美好,当时的网景刚刚因拒绝微软的低价收购而直面微软的 「拥抱,扩展,灭绝」战略,所以网景急切的想跟 Sun 达成合作,将当时刚刚更名的 Java 引入浏览器来实现动态化,由此来抵抗微软的进攻。

因此在 1995 年 5 月 Java 发布会上,网景飞速宣布了他们授权 Java 技术在浏览器中使用的意向。但是一方面当时的 Java 并不适合新手使用,另一方面对面微软宣布他们提供以 Visual C++ 为主,以 Visual Basic 为辅的开发包,让那些没有经验的新手也可以进行开发。

所以网景最后决定在引入 Java 的同时,发明一个轻量级、易于使用、类似于 Java 的胶水语言来满足业余人员的开发需求。但是又不能和 Java 过于类似,毕竟 Java 是友商的东西,搞不好后面还会有扯皮的风险。这个动作还要快,毕竟当时网景刚刚成立还没多久,需要证明自己有开发新语言的能力。而这个任务就交给了当时刚刚入职的 Brendan Eich。

故事讲到这里,我们就能梳理出 JS(原型)诞生前的三个创世要素:

  • 看起来像 Java,但是实现原理尽量不一致
  • 要易用、要轻、要尽可能的节省内存
  • 动作要快,实现时间越短越好

关于这个故事后续有兴趣的可以看文末参考第一条。

核心需求:原型是为了解决什么问题?

现在我们回归主题,什么是核心需求?核心需求就是:要实现一门看起来像是 Java,但原理不同且用起来更简单的语言。

JS(当时还叫 Mocha)早期开发的几乎所有特性,包括原型,都是为了这个目标服务的,这也是我们本文的“一”。虽然听起来一点都不高大上,好像就是我们这些苦逼程序员接到了一个产品需求一样。不过事实就是这样,接下来我们就从这个需求出发,一步步推导出完整的原型模型来。

如何实现继承

要想仿造 Java,那其 OOP 自然是躲不过的一道坎,特别是 OOP 的核心:继承。

我们先看一下继承的本质是什么:子类自动拥有父类中的属性和方法,并可以添加新的属性和方法,或者对部分属性和方法进行重写。

现在如果让你来实现继承,你会怎么做呢?其实很简单,最基础的设计只需要两个东西:

  • 一片空间,用来存放这些可以被继承的内容(方法属性)。
  • 一个指针,用来指向这片空间。

拒绝死记硬背,从核心需求理解 JS 原型

这很好理解对吧。无论“被继承的东西”存到哪,最终肯定是要有地方存的。而且 不能跟自己独有的内容存到一起,不然就没办法区分哪些可以继承的,哪些不能继承了。

被继承的内容存好之后,我们还需要有个指针指向这片空间,这个指针应该附加在“继承者”之上,因为继承者需要知道自己要去哪找这些“共享的继承内容”。

拒绝死记硬背,从核心需求理解 JS 原型

这片空间,就是 prototype,这个指针,就是 __proto__,这也就是本文的“二”。也是我们唯一需要背下来的东西,所以现在请大家再看一遍上面这句话,能背下来再好不过。

至此,本文最难理解的部分就结束了。本文开始那张图里出现的知识点,我们都可以通过这个简单到令人发指的模型推导出来。

由于需要“看起来像 Java,但是实现原理尽量不一致”,所以要复刻其 OOP 功能。由于需要“易用、要轻、要尽可能的节省内存”,所以选择用原型链复刻。由于需要“动作快,实现时间越短越好”,所以选择了这种最简单的设计。

事实上,由于 Brendan Eich 很喜欢 Scheme,所以几乎没有任何犹豫的就选择了 Scheme 的原型链来做 js。

真的假的?回答些问题试试

为了验证上面这两句话的可靠性,接下来我们挑一些常见的原型链问题分析一下。注意!下面的解析都不需要背,只要记住了上面两句话,下面的内容完全可以当场推导出来。

1、__proto__ 指向了谁?

回忆刚才所说的,__proto__ 就是指针,而这个指针指向了用于保存“可继承”内容的空间,也就是 prototype。

所以说,所有的 __proto__ 都指向了某个 prototype。这是必然的,因为这就是它存在的意义。

拒绝死记硬背,从核心需求理解 JS 原型

2、原型链是什么?

现在我们有一个指针,指向了一片空间。那就算从字面意思上理解,怎么构成一个“链”?

很简单,这片空间上也有一个指针,指向了下一片空间。翻译过来就是,__proto__ 指向了某个 prototype,而这个 prototype 也有自己的 __proto__,指向了自己所依赖的 prototype。由此构成一个链条,从而可以不断的往上追溯,追溯什么?追溯这个模型的本质:“可被继承的内容”。

3、const obj = new Foo(),那么 obj.__proto__ 指向谁?

首先我们知道,obj.__proto__ 就是指向了被 obj 这个对象继承的那部分内容。而从第一个问题中我们可以得知,__proto__ 指向了 prototype,那么现在回头看看,obj 继承自谁?

很明显,是 Foo 函数,因为 obj 是从 Foo 实例化而来的,所以肯定会继承 Foo 上面存在的东西,所以说 obj.__proto__ 就指向了 Foo.prototype。

拒绝死记硬背,从核心需求理解 JS 原型

4、const obj = new Object(),那么 obj.__proto__ 指向谁?

和上一道题类似,obj.__proto__ 指向了距离自己最近的可继承空间(也就是某个 .prototype)。同样的,由于 obj 是从 Object 实例化而来的,所以肯定会继承 Object 上面存在的东西。即 obj.__proto__ 就指向了 Object.prototype

5、Foo 的 __proto__ 指向了谁?

现在复杂了一点,问的不是从 Foo 实例化出来的对象,而是 Foo 本身。

不过不用急,还是按相同的方法分析一下,Foo 有需要继承的东西么?很明显有的,普通函数(非箭头函数)上都有 bind、call、apply 这些方法,也就是说 Foo.__proto__ 一定也是指向了某个 .prototype。

所以可以想一下,Foo 函数是从谁实例化而来的?答案也很明显,Foo 函数由 Function 函数实例化而来。可以说,js 里所有的普通函数都是由 Function 函数实例化而来的。

至此答案就呼之欲出了:Foo.__proto__ 指向了 Function.prototype

6、Foo.prototype.__proto__ 指向了谁?

更复杂了一点,不过还是按部就班,我们可以发现,这次找 __proto__ 的目标换成了 Foo.prototype。那么分析一下,Foo.prototype 上有继承自其他人的内容么?

确实是有的,虽然 Foo 是一个函数,但是 Foo 也是有 toString、valueOf 这种方法的,这些方法是哪来的?是从对象上来的。

好了,现在整个流程里是“对象”的目标只剩下两个了,一个是由 Foo 函数实例化而来的对象(比如 obj),另一个则是 Object 对象。那么答案是哪一个呢?

这不用选吧,肯定不是实例化出来的 obj,不然原型链就成个环了。所以答案就是 Object.prototype

7、为什么 Function.__proto__ 指向了 Object.prototype 而不是反过来?

答案很简单,因为 js 里一切皆对象。

好吧,稍微解释一下就是,因为几乎所有的变量(包含函数)都要继承对象相关的属性(Object.prototype),而只有函数上会继承函数相关的属性(Function.prototype)。也就是说,继承 Object.prototype 的变量实际上是继承 Function.prototype 变量的一个超集。

拒绝死记硬背,从核心需求理解 JS 原型

所以,现在让你来设计的话,你会选择谁指向谁呢?

8、为什么 Function 的 __proto__ 指向了自己的原型?

会有这个疑问的一般都是之前的原型链分析文章看的比较多。其他对象的 __proto__ 都没有指向自己的 prototype,为什么 Function 这么特殊呢?

其实很简单,从我们刚才建立的模型来看。__proto__ 存在的目的仅仅是为了找到自己所在的对象可以继承的内容在哪。那 Function 有需要继承的内容么?有,bind、apply 这些。那这些东西是谁共享的,是 Function 自己。

所以 Function.__proto__ 就理所应当的指向了 Function.prototype。只要你理解了这个模型,那就可以发现这个问题跟刚才的第四题是一样的,甚至不算一个特例。

9、为什么 Foo.prototype.__proto__ 指向了 Object.prototype,而不是 Foo.__proto__ 指向 Object.prototype?

首先我们回忆一下刚才提到的“什么是原型链”。其中最核心的就是,在空间上有一个指针指向了下一片空间。也就是说,是 prototype 上有一个 __proto__,才能构成一条链。

如果是 Foo.__proto__ 指向了 Object.prototype 的话,这个链条就断开了。由其实例化出来的对象就无法再向上追溯到对象相关的属性了。

拒绝死记硬背,从核心需求理解 JS 原型

10、为什么 Function.prototype 打印出来是 ƒ () { [native code] }

这个跟原型链没关系,js 代码无法实现或性能不好的时候,就会选择用原生代码来写。

Function.prototype 打印出来是 ƒ () { [native code] } 是因为这里面包含了 callapplybind 等方法的实现,而这些方法需要访问函数的执行上下文和参数列表,这些信息是由 JavaScript 引擎提供的,JavaScript 代码获取不到。


通过上面的题我们可以发现,几乎所有的指向问题都可以通过下面这套流程得出答案:

  • __proto__ 所在的对象有没有继承自其他人的内容?
  • 这个对象从谁实例化而来?(最近的可继承空间是谁的)
  • 答案就是被实例化者的 .prototype 属性。

这也就是本文的“三”,通过这三个步骤,你可以解决 js 里繁琐复杂的任何原型链问题。

对比其他文章中需要记忆的概念

现在我们来看一下其他介绍原型链文章里的那些“重要概念”,并对比一下为什么其他文章看完就会忘。

1、__proto__ 是显式原型,prototype 是隐式原型

因为 __proto__ 的作用就是为了找到自己的原型,想访问原型要先通过它,所以它被称为“显式”。而 prototype 作用是保存可以被继承的内容,它是隐藏在指针背后的一片空间,所以被称为“隐式”。

事实上,这个称呼很具有迷惑性,会让新手以为这两者是类型相同的一体双面。

2、__proto__是每个对象都具有的属性

这句话听起来没有错,因为广义上 js 里万物都是对象。但是容易让人理解成“被 new 出来的对象才有 __proto__”,从而造成困惑。

真正的说法应该是,所有需要从其他地方继承内容的东西上都会有 __proto__,甭管你是什么妖魔鬼怪,只要你需要继承其他的东西,那你必定有 __proto__

3、prototype 是 Function 独有的属性

这句话也是浮于表面,正确但是难以记忆。实际上应该是,提供“被继承内容”的东西上都会有 prototype

而推导“prototype 是 Function 独有的属性”这句话的三段论是这样的:因为提供“被继承内容”的东西上都会有 prototype。并且 js 里可以通过 Function 继承内容。所以 Function 上会包含 prototype。

知道为什么容易忘了吧,这句话本来就是一个推论。

总结

其实本文的核心内容非常简单:

实现原型链需要一片空间(.prototype)和一个指针(.__proto__,空间用来存放这些可以被继承的内容。指针用来指向这片空间。

记住了这句话之后,关于原型链相关的核心知识就可以自然而然的推导出来而无需死记硬背。觉得有用的可以点个喜欢,就算你忘了,点开本文之后稍微一看就能回忆起来。并且,得益于这个模型的极度简单。完全可以在面试原型相关的问题时直接把推导过程讲出来。这不加分我是不信的。

并且,如果你了解其他 js 的核心知识的话,比如一切皆对象、函数是 js 的一等公民。结合原型链的这两点核心要素,你会发现很多地方都是相辅相成,互相对应的,这些是一个融洽的体系,各个特性的存在都是有意义的,而不仅仅是一句口号。感兴趣的话我们后面可以开文讲一下。

参考