阅读 1961

深入理解JS的原型和原型链

前言

「 本文共 6893 字,预计阅读全文需要 23 分钟 」

JS的世界丰富多彩,但是你真的理解JS世界了吗?

本文将从上帝角度讲解JS的世界,在这个过程中,大家就能完全理解JS的原型和原型链是什么,之后还会基于原型和原型链知识拓展一些相关知识。

阅读本文前可以思考下面三个问题:

  • 你理解中的原型和原型链是什么?
  • 你能完全理解并画出原型和原型链的关系图吗?
  • 基于原型和原型链拓展的相关知识你了解多少?

本文会由浅入深的解答这三个问题

深入理解JS系列

本文为《深入理解JS系列》第二更,敬请关注!
下一更预告:《深入理解JS的事件循环》 (2020年03月02日已更
将会详细讲解JS事件循环以及如何手撕标准promiseA+

经典图

大家接触原型和原型链时应该都看到过下面这张图。刚开始了解的时候,看到这个图大都不太明白,甚至一脸懵,这里先留个坑。

先祭图,让暴风雨来得更猛烈些!

下面开始讲解JS世界是如何一步步诞生的,看完你也就完全明白这张神图啦。

无中生有

起初,上帝JS掌控的世界什么都没有。
上帝JS说:没有东西本身也是一种东西啊,于是就有了null

现在我们要造点儿东西出来。但是没有原料怎么办?
有一个声音说:现在不是有null了嘛?
上帝说:那就无中生有吧!

JavaScript中的1号对象产生了,不妨把它叫做机器1号。
这个机器1号可不得了,它是JS世界的第一个对象,它是真正的万物始祖。它拥有的性质,是所有的对象都有的。 __proto__是什么呢?是“生”的意思,或者换成专业点的叫法“继承”。

有中生有

刚开始造物,上帝当然想继续下去啦,既然已经有了一个始祖级的机器,剩下就好办了,因为一生二,二生三,三生万物嘛。

不过上帝很懒,他不想一个一个地亲手制造对象。于是他做了一台能够制造新对象的东西:

他给这个东西起了一个名字:Object。

但是这个Object制造对象时候,需要有一个模版,现在只有机器1号,它就取了机器1号当模版。图中的prototype就代表模板对象。

如何启动制造呢?通过new命令。你按下“new”按钮,新的对象就造出来了。

把这个过程写成代码就是:

var obj = new Object();
复制代码

轰轰烈烈的造物运动开始了……

有生万物

有一天,上帝JS去看了上帝Java造的世界,发现上帝Java的世界好精彩,可不仅仅有Object对象,还有String对象、Number对象、Boolean对象等等。

于是上帝就思考了:那我可以多让机器造一些对象啊。

但是上帝觉得把这些工作都交给机器1号的话,机器1号太累了,不如让机器1号造一个机器2号来做这些工作。

重点说明下“这些工作”指的是:总体负责制造所有的对象,包含Object、String、Number、Boolean、Array,甚至还有之后的Function。当然它只是负责制造,并不一定会亲手去制造对象,可以通过制造对应的机器来帮助它制造对象

于是就有了机器2号:

(注:__proto__写起来麻烦,我们之后用[p]来代替)

可能有的小伙伴注意到啦,Object也指向了机器2号,这是因为机器2号是负责造对象的,当然也负责造Object对象啦。

接下来,既然机器2号是由机器1号造出来的,而String、Number、Boolean、Array这些对象是由机器2号造出来的,所以它们其实和Object一样,也自带了new命令:你按下“new”按钮,新的对象就造出来了。

但是Object有自己的模板:机器1号。而String、Number、Boolean、Array它们有模板吗?

其实机器2号在创建它们的时候并不是直接创建它们的,而是先创建了对应对象的机器作为模板,然后再由各自的机器来创建它们。

具体我画了String相关的图(其他Number、Boolean、Array等都是同一个道理的):

这样,这张图显示了JS世界中那些最基本的机器本身的原型链,以及它们的模板对象的原型链。

  • 机器1号制造了机器2号,机器2号总体负责各种对象的制造
  • 但是机器2号并不是直接造各种对象,而是通过先建造对应的机器,再由对应的机器来制造对象。如:对于String,机器2号先制造了机器String号,然后由机器String号来制造String,机器2号只负责总体控制
  • 虽然机器2号制造了各种各样的机器,但是因为机器2号是由机器1号制造的,所以这些被制造的机器所属权还是归于机器1号的,毕竟机器1号是始祖级的。
  • 对象和对应的机器,通过prototype来连接。如:对于String,机器2号String通过prototype连接
  • 每个机器都有且只有一个的对象,每个对象也都有且只有一个机器作为模板。

万物缺活力

上帝看着越来越丰富的世界非常高兴,但是总感觉缺点什么?

一个声音说:世界缺少活力呀
上帝说:那就造一个能让世界动起来的对象

上帝给这个新对象的起了个名字叫:Funciton
但是这个制造Function的工作交给谁好呢,让世界动起来当然是非常重要的,那就交给机器2号吧,由机器2号亲手负责Function的制造

于是,Function对象就出现了

让我们来观察一下Function对象:

  • 它是由机器2号亲手制造的,所以它们之间有prototype相连
  • 而机器2号又是制造所有对象的负责者,所以它们之间有__proto__相连

于是我们得到了Function的一个非常特别的性质:

Function.__proto__ === Function.prototype
复制代码

于是JavaScript的世界的变成了下面的样子:

到现在我们能明白啦:

  • 机器1号 = Object.prototype
  • 机器2号 = Function.prototype
  • 机器String号 = String.prototype

世界动起来

自从有了Function,世界就越来越有活力了,有什么事需要做,用new Function()造个新Function来做就行了。

但是刚造出来的Function机器很难用,用法就像下面这个:

let Foo = new Function("name", "console.log(name)");

Foo('dellyoung'); // 控制台打印出:dellyoung
复制代码

你想要造一个Function,无论是输入的内容(参数)还是要做的事情(函数体)都得弄成字符串,才能成功造出来。

上帝用起来难受啊,他就改装了一下这个Function,给他来了个语法糖

function Foo(name) {
    console.log(name);
}

Foo('dellyoung'); // 控制台打印出:dellyoung
复制代码

(注:上面两段代码是完全等价的。)

现在造一个新的Function就舒服多啦!

以造Foo()为例,于是JavaScript的世界的变成了下面的样子:

Function这个对象比较特殊,它new出来后,就是一个全新的对象了,function Foo()(注意:它等价于 let Foo = new Function())和ObjectStringNumber等这些对象一样,都是对象。

既然都是对象,当然function Foo()也是由机器2号来控制制造的,但是机器2号很忙,它没有精力直接制造function Foo(),机器2号是通过制造出一个制造function Foo()的机器来制造function Foo()

咱们称制造function Foo()的机器机器Foo()号

当然既然是机器,所以机器Foo()号也是由机器1号控制的,原因上文讲过:

虽然机器2号制造了各种各样的机器,但是因为机器2号是由机器1号制造的,所以这些被制造的机器所属权还是归于机器1号的,毕竟机器1号是始祖级的。

而且这个function Foo()对象制造出来后,它既然是对象,所以它和Object、String、Number等对象一样,可以通过new Foo()制造出新的对象,模板就是用的机器Foo()号

听起来好像有点绕,咱们看看图就明白啦

上图中:

  • 机器1号 = Object.prototype
  • 机器2号 = Function.prototype
  • 机器String号 = String.prototype
  • 机器Foo()号 = Foo.prototype
  • [p] = __proto__

回到现实

现在我们就能完全理解并完整的画出原型和原型链的关系图啦:

其实可以被用来new的对象或函数,我们都可以称之为构造函数,每个构造函数都和它的机器(也就是XXX.prototype)通过constructor相连,我们来画出构造函数和它们的constructor

为了清晰一些,上图用[con]表示constructor

现在这张图就是完整的原型和原型链的关系图啦

用正式的语言总结一下就是:

  • 子类的__proto__属性,表示构造函数的继承,总是指向父类。

  • 子类prototype属性的__proto__属性,表示方法的继承,总是指向父类的prototype属性。


现在我们再看这张神图:

是不是很简单啦,不过就是把咱们画的图向右旋转了90°。

而且仔细看一遍,咱们的关系图包含的更加的全面。

填坑完毕

知识延伸

现在我们已经完整的理解了JS的原型和原型链啦

接下来我们了解一些基于JS的原型和原型链的相关知识点

instanceof为何物

相信大家都知道instanceof的用法和作用:

MDN:instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。

A instanceof B
复制代码

我们根据这个概念很容易写出一个Polyfill的instanceof函数:

const polyInstanceof = (leftVal, rightVal) => {
    let rightProto = rightVal.prototype; // 取右表达式的 prototype 值
    leftVal = leftVal.__proto__; // 取左表达式的__proto__值
    while (true) {
        if (leftVal === null || leftVal === undefined) {
            return false
        }
        if (leftVal === rightProto) {
            return true
        }
        leftVal = leftVal.__proto__;
    }
};
复制代码
  • 再看一遍MDN:instanceof就是用来检测构造函数的prototype属性是不是在某个实例的原型链上
  • 上面这句话,换成大白话就是:instanceof就是用来检测从A顺着[p](原型链)往上找,能不能找到生产B的机器(B的prototype)
  • 咱们来分析下,A往上一直找,终点是null,当A找到原型链的根节点null了,还没对上生产B的机器(B.prototype),就是说明真的找不到了,返回false就行了,找到当然返回true。

new为何物

那new运算符到底做了什么呢?

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

new 关键字会进行如下的操作:

  • 创建一个空的简单JavaScript对象(即{});
  • 链接该对象(即设置该对象的构造函数)到另一个对象 ;
  • 将步骤1新创建的对象作为this的上下文 ;
  • 如果该函数没有返回对象,则返回this。

咱们根据MDN的定义来写一个polyfill

function polyNew(source, ...arg) {
    // 创建一个空的简单JavaScript对象(即{})
    let newObj = {};
    // 链接该对象(即设置该对象的构造函数)到另一个对象
    Object.setPrototypeOf(newObj, source.prototype); 
    // 将步骤1新创建的对象作为this的上下文 ;
    const resp = source.apply(newObj, arg);
    // 判断该函数返回值是否是对象
    if (Object.prototype.toString.call(resp) === "[object Object]") {
        // 如果该函数返回对象,那用返回的这个对象作为返回值。
        return resp
    } else {
        // 如果该函数没有返回对象,则返回newObj。
        return newObj
    }
}
复制代码

这段代码:

  • Object.setPrototypeOf 相当于是 newObj.__proto__ === source.prototype,我推荐 Object.setPrototypeOf写法,而不是直接拿__proto__赋值
  • apply是用来改变函数执行时的this指向的,相似的还有applybind
  • Object.prototype.toString.call()可以精准的获取类型:每一个引用类型都有toString方法,默认情况下,toString()方法被每个Object对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回 "[object type]",其中type是对象的类型。
  • 但是由于大部分引用类都重写了toString方法,所以我们可以直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到我们想要的效果

看到这我就提出一个新的问题了this你真的了解吗?

this为何物

关于this为何物,我提出了下面几个问题:

  • this存放在哪里?
  • this是如何出现,又是如何消失的?
  • this有什么作用?

要完全明白这些,咱们要先理解JavaScript执行上下文和调用栈是什么

执行上下文

为清晰讲述this综合了《浏览器工作原理与实践》部分内容

咱们先看下这段代码的函数调用过程:

var a = 2
function add(){
    var b = 10
    return  a+b
}
add()
复制代码

这段代码很简单,先是创建了一个 add 函数,接着在代码的最下面又调用了该函数。

在执行到函数 add() 之前,也就是第6行之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量,你可以参考下图:

从图中可以看出,代码中全局变量和函数都保存在全局上下文的变量环境中。

执行上下文准备好之后,便开始执行全局代码,当执行到 add 这儿时,JavaScript 判断这是一个函数调用,那么将执行以下操作:

  • 首先,从全局执行上下文中,取出 add 函数代码。
  • 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。
  • 最后,执行代码,输出结果。

就这样,当执行到 add 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文。

也就是说在执行 JavaScript 时,可能会存在多个执行上下文,那么 JavaScript 引擎是如何管理这些执行上下文的呢?

答案是通过JavaScript调用栈来管理的,接下来咱们来看下什么是JavaScript调用栈。

JavaScript调用栈

咱们知道,JavaScript执行过程中,内存空间主要分为栈空间和堆空间(代码空间先不用管)。

什么是 JavaScript 的调用栈:代码执行过程中,JavaScript 引擎会将执行上下文压入栈空间中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。

接下来我们一步步地分析在下面代码的执行过程中, JavaScript 调用栈的状态变化情况:

var a = 2

function add(b,c){
    return b+c
}

function addAll(b,c){
    var d = 10
    result = add(b,c)
    return  a+result+d
}

addAll(3,6)
复制代码

第一步,创建全局上下文,并将其压入栈底。 如下图所示:

从图中你也可以看出,变量 a、函数 add 和 addAll都保存到了全局执行上下文的变量环境对象中。

全局执行上下文压入到调用栈后,JavaScript 引擎便开始执行全局代码了。首先会执行 a=2 的赋值操作,执行该语句会将全局上下文变量环境中 a 的值设置为 2。

设置后的全局上下文的状态如下图所示:

第二步是调用 addAll 函数。

当调用该函数时,JavaScript 引擎会编译该函数,并为这个函数创建一个执行上下文,最后将该函数的执行上下文压入栈中,如下图所示:

第三步,当执行到 add 函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈,如下图所示:

当 add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9。如下图所示:

紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。最终如下图所示:

至此,整个 JavaScript 流程执行结束了。

好了,现在你应该知道了调用栈是 JavaScript 引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

重看this

相信根据上文内容大家应该已经明白什么是JavaScript执行上下文和调用栈了

我们再来看this,其实它也存放在执行上下文中。

执行上下文包括了:变量环境、词法环境、outer、this。如下图所示:

从图中可以看出,this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this

执行上下文主要分为三种

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文

所以对应的 this 也只有这三种

  • 全局执行上下文中的 this
  • 函数中的 this
  • eval 中的 this(先不讲解此情况)

全局执行上下文中的 this

在控制台中输入

console.log(this) // window

console.log(this === window) // true
复制代码

我们可以看出来:全局执行上下文中的 this 也是指向 window 对象。

函数执行上下文中的 this

执行下面代码:

function foo(){
  console.log(this) // window
}
foo()
复制代码

可以看到输出了window,说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。

可以认为 JavaScript 引擎在执行foo()时,将其转化为了:

function foo(){
  console.log(this) // window
}
window.foo.call(window)
复制代码

显然大家发现了可以通过call来改变this指向。

咱们来列举下设置函数执行上下文中的 this 值的方法:

1.通过函数的 call、apply、bind 方法设置

用法如下:

let bar = {
  myName : "dell",
}
function foo(){
  this.myName = "dellyoung"
}
foo.call(bar)
console.log(bar) // {myName:"dellyoung"}
console.log(myName) // 报错myName未定义
复制代码

执行上面代码,会打印出{myName:"dellyoung"}myName未定义的报错信息,显然执行foo()的时候成功的将其this指向指到了bar,这时候bar就是foo()的this

2.通过对象调用方法设置

尝试执行下面的代码

var myObj = {
  name : "dellyoung", 
  showThis: function(){
    console.log(this)
  }
}
myObj.showThis()
复制代码

打印出了{ name: 'dellyoung', showThis: [Function: showThis] } ,显然现在这个this指向了调用它的myObj。

可以得到结论:使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的。

可以认为 JavaScript 引擎在执行myObject.showThis()时,将其转化为了:

myObj.showThis.call(myObj)
复制代码

3.根据上面两点得出小结论

  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
  • 谁调用函数,函数的this指向谁。对象调用函数自然不必说,全局环境调用其实也可以理解为window来调用,所以当然指向了window

4.通过构造函数中设置

咱们现在再来看一下通过new调用构造函数到底做了什么:

function polyNew(source, ...arg) {
    // 创建一个空的简单JavaScript对象(即{})
    let newObj = {};
    // 链接该对象(即设置该对象的构造函数)到另一个对象
    Object.setPrototypeOf(newObj, source.prototype); 
    // 将步骤1新创建的对象作为this的上下文 ;
    const resp = source.apply(newObj, arg);
    // 判断该函数返回值是否是对象
    if (Object.prototype.toString.call(resp) === "[object Object]") {
        // 如果该函数没有返回对象,则返回this。
        return resp
    } else {
        // 如果该函数返回对象,那用返回的这个对象作为返回值。
        return newObj
    }
}
复制代码

显然我们看到source.apply(newObj, arg),所以构造函数其实也改变了this指向,将this指向从原函数换到了新构造出来的函数。

解疑填坑

  • this存放在哪里?
    • this存放在每个执行上下文中
  • this是如何出现,又是如何消失的?
    • this随着执行上下文出现,当执行上下文被回收后,也随之消失
  • this有什么作用?
    • 全局执行上下文中:this指向了window对象,方便我们来调用全局window对象。
    • 函数执行上下文中:this指向了调用该函数的对象,减少的参数的传递,原来如果需要在函数内部操作被调用对象,当然还需要将对象作为参数传递进去,而有了this,就不需要了,直接拿this就能操作被调用对象的属性。

call、apply、bind为何物

上文用了多次call,想必大家已经明白call做了什么了:

MDN:call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

举个例子:

let bar = {
  myName : "dellyoung",
}
function foo(){
  console.log(this.myName)
}
foo.call(bar) // 打印出 dellyoung
复制代码

也就是说:调用foo函数的时候,通过使用call(),并且传入bar,使得foo函数内部的this指向了bar

实现call

咱们就根据这个结论来实现一下call:

Function.prototype.dellCall = function (context = window,...param) {
    // 判断是函数才能调用call方法
    if (typeof this !== 'function') {
        return new TypeError("类型错误");
    }
    // 将this也就是被调用的函数,通过赋值给传入的对象,来达到将被调用的函数添加到传入的对象上的目的
    context.fun = this;
    // 用传入的对象来调用需要被调用的函数,并保留返回结果
    const resp = context.fun(...param);
    // 删除传入对象上被添加的函数,防止内存泄漏
    Reflect.deleteProperty(context, 'fun');
    // 返回结果
    return resp;
};
复制代码

其实核心很简单,咱们分析一下:

  • 将被调用的函数作为一个属性添加到传入的对象上
  • 从而可以实现在传入的对象上,调用需要被调用的函数
  • 咱们分析完发现核心原理还是:谁调用函数,函数的this指向谁

万变不离其宗:谁调用函数,函数的this指向谁。这句话其实可以帮助我们理解绝大部分this的问题了

实现apply

apply其实和call差不多,只不过传递参数的方式不同:

foo.call(obj,[param1,param2,...,paramN]) // 参数是数组,传入一个数组作为参数

foo.apply(obj,param1,param2,...,paramN) // 参数非数组,可以传一串参数
复制代码

咱们对上面的call稍微改一下就是apply了:

Function.prototype.dellApply = function (context = window, param = []) {
    // 判断是函数才能调用call方法
    if (typeof this !== 'function') {
        return new TypeError("类型错误");
    }
    // 将被调用的函数作为一个属性添加到传入的对象上
    context.fun = this;
    // 在传入的对象上,调用需要被调用的函数
    const resp = context.fun(...param);
    // 删除传入对象上被添加的函数,防止内存泄漏
    Reflect.deleteProperty(context, 'fun');
    // 返回结果
    return resp;
}
复制代码

实现bind

bind目的也一样,改变this,但是它并不是直接调用函数,而是返回改变了内部this值的函数,当需要的时候再调用:

咱们来实现一下:

Function.prototype.dellBind = function (context) {
    // 判断是函数才能调用call方法
    if (typeof this !== 'function') {
        return new TypeError("类型错误")
    }
    // 用that变量保存被调用的函数
    const that = this;
    // 保存传入的参数
    const argArr = [...arguments];
    // 返回一个函数,这样调用这个被返回的函数,内部的that.call()函数才会被执行
    return function F() {
        // 用call来实现改变被调用函数内部this指向
        return that.call(context, [...argArr, ...arguments]);
    }
}
复制代码

咱们来分析一下bind:

  • 其实bind和apply就一点区别,bind返回一个被改变了内部this指向的函数
  • 当调用返回的函数,改变了内部this指向的函数才能运行,运行后返回结果
  • 实现来也很容易,bind返回不再像call那样返回结果,而是返回了一个函数,调用返回的函数当然才能运行内部被改变了this指向的函数

优化:严格的来说这并不是一个合格的bind,因为还需要考虑到把函数当作构造函数调用的情况,当使用new来把函数作为构造函数调用的时候,就不要改变this指向了,直接对被调用函数new一下返回就行了

代码如下:

Function.prototype.dellBind = function (context ) {
    // 判断是函数才能调用call方法
    if (typeof this !== 'function') {
        return new TypeError("类型错误")
    }
    // 用that变量保存被调用的函数
    const that = this;
    // 保存传入的参数
    const argArr = [...arguments];
    // 返回一个函数
    return function F() {
        // 如果用的new,即用的是构造函数
        if (this instanceof F) {
            return new that(...argArr, ...arguments);
        }
        // 用call来实现改变被调用函数内部this指向
        return that.apply(context, [...argArr, ...arguments]);
    }
}
复制代码

分析一下:

  • 通过instanceof分析就可以得到,this是不是由F通过new得到的,new已经讲过啦,new运算内部会更换原型链:A.__proto__ === B.prototype
  • 所以如果F的原型在this的原型链上,那么当然是用的new当作构造函数调用了,咱们也应该new该函数返回即可

原型和原型链的作用

可能大家会想,为什么要有原型和原型链呢?

通过原型和原型链可以节省空间,让多个人可以共用一个场所,也就是多个对象的一些属性可以共用一块内存,减少内存的开销

function Person(name) {
  this.name = name;
}

// 通过构造函数的 Person 的 prototype 属性找到 Person 的原型对象
Person.prototype.eat = function() {
  console.log("吃饭");
}

let p1 = new Person("dell");
let p2 = new Person("dellyoung");

console.log(p1.eat === p2.eat); // true
复制代码

运行后可以发现控制台打印了true,所以两个对象都共用了一个函数eat(),大家共用的是一块空间,因此节约了内存空间,减少了内存空间的开销。

参考

小结

  • 相信现在大家已经能完全理解原型和原型链了
  • 对于thisnew大家也应该理解的有一定深度了
  • 对于call、apply、bind的手撕实现应该也完全没什么问题啦

好啦今天就先写到这啦,之后有时间会补充上继承相关的内容,继承也是属于原型和原型链相关的知识

如果对你有帮助的话,点个赞吧! 有任何疑问随时与我在评论区交流

关注下面的标签,发现更多相似文章
评论