理解了JS 中的function调用的小秘密, this, apply(),call(),bind()原来很简单啊

5,182 阅读10分钟

前言:

我希望在开始读这篇文章之前,你了解过函数调用、this指向、call()apply()bind(),当然,只要了解过就好,因为本文就是为了让你更好的理解它们。(转载请注明出处---掘金果酱淋)

开篇:

我将说明几个你在阅读下文时可能会觉得困惑的概念,当你觉得疑惑时,可以回到这里来看看。

  1. “指向”、“指针”怎么理解?你可能会读到类似“这只是对象的指针,虽然删除了指向对象的指针,但对象依旧占用着内存”的语句。

打个比方:我们上学时(小学),老师会给学生们安排一个固定的座位号,目的是为了方便老师让学生回答问题时不用记住学生姓名,直接喊号,提高效率。那么,每个学生对应着一个座位号,如1,2,3分别代表小明,小红,小三,这里需要知道,座位号1,2,3和小明,小红,小三并不是完全相同的事物,前者是一个具有代表性的数字(宾语是数字),后者是实实在在的人(对象),但是两者又存在一一对应的关系,这时候,我们可以这样说,数字1指向小明,数字2指向小红,数字3指向小三(“指向”是动词),那么用图解的方式画出来就是1->小明,2->小红,3->小三。看看,数字与对象之间的箭头,就是指针(名词)。也就是说,“指针”就是数字与对象之间的关系的一种名词性的说法。

  1. “执行环境”怎么理解?你可能会读到类似“函数在被调用的时候,会被推入到它的执行环境中,函数的执行环境中存在哪些变量”的语句

打个比方:ECMAScript是一个导演,对象是演员(就那么几个如ObjectArrayMath等),变量是道具。运行JavaScript代码相当于“导演让演员按照剧本借助一定的道具在舞台上演绎出一部话剧”,我们分析这句话,ECMAScript、对象、变量都有对应的喻体了,那剧本和舞台又是什么呢?剧本就是ECMAScript中制定的规则,舞台就是我们说的“执行环境”!一场话剧,在不同的阶段,需要上场的演员和需要使用的道具是不同的,所谓“你方唱罢我登场”,“执行环境”在不同阶段也是不同对象的表演舞台,存放的变量道具也不同。

  1. call(), apply()方法几乎一样,只是传入参数的方式不一样,后者第二个参数是一个数组,那为什么要存在着两个几乎一样的东西?这不是重复造轮子么?

这需要介绍它们使用场景来告诉你原因:

Math对象有个max() 方法: 可以返回传入数字中的最大者:


 var max = Math.max(1, 8, 3, 15, 4, 5); // 调用Math的max()取得传入的最大参数并赋值给max
 console.log(max); // 打印出15
 

上面的例子可以写成这样,效果与上面的完全一样:


  var max = Math.max.call(Math, 1, 8, 3, 15, 4, 5); // 调用Math的max()取得传入的最大参数并赋值给max(函数调用的小动作)
 console.log(max); // 打印出15
 

那么接下来我换个需求,我想要让你结合max()方法,找出一个数字数组中最大的数字。你可能会说,简单啊,然后给出了这些方案!


var arr = [1, 8, 3, 15, 4, 5]; //声明数组表达式
var max = Math.max.call(Math, ...arr); // ES6解构语法======(这里有疑问看正文后再回来消化下)
console.log(max); // 打印15

可以的,很机智地实现了需求。但是你回想一下apply() 的第二个参数是什么?数组!看代码


var arr = [1, 8, 3, 15, 4, 5]; //声明数组表达式
var max = Math.max.apply(Math, arr); // apply方法
console.log(max); // 打印15

有没有那种 “我正好需要,你正好专业” 的感觉~!


正文:function调用的“小秘密”

不要被开篇的东西吓到,本文的正文很简单的。就是告诉你function调用时你不知道的“小动作”(跟this相关的)。

先要知道: 函数中,thisarguments这两个函数的属性,只要在函数执行的时候才会知道它们分别是指向谁(好好琢磨一下这话)。

1. 一般函数的调用(全局环境中函数的调用)

首先,一般地,我们在全局环境中声明函数和执行函数的过程如下:


function hello(someone) {
    console.log(this + "你好啊 " + someone);
} // 函数声明

hello("掘金果酱淋"); // 函数调用,打印出 //[object Window]你好啊 掘金果酱淋

其实,函数在内部执行的时候,还做了个小动作,也就是我们要说的小秘密


function hello(someone) {
    console.log(this + "你好啊 " + someone);
} // 函数声明

hello.call(window, "掘金果酱淋"); // 函数调用,打印出 //[object Window]你好啊 掘金果酱淋

对比一下,发现重点了么?函数在执行的时候,自动将this指向了window这个全局对象(注意node环境下全局对象是global),与我们手动让this指向window打印出来的结果一样!

那你可能还会反驳,书上不是说,在严格模式下(“use strict”)时,this时指向了undefinded,那是因为在严格模式下,函数调用的小动作是这样的:


function hello(someone) {
    'use strict';
    console.log(this + "你好啊 " + someone);
} // 函数声明

hello("掘金果酱淋"); // 函数调用,打印出 //undefined你好啊 掘金果酱淋
hello.call(undefined, "掘金果酱淋"); // 打印出 //undefined你好啊 掘金果酱淋

怎么样,有没有豁然开朗的感觉?

2. 对象方法的调用(对象里面的函数的调用)

首先,存在这样一个对象,平常的调用这样的:


var person = {
    name: "掘金果酱淋",
    hello: function(someone) {
      console.log(this + " 你好啊 " + someone);
    }
  };
// 正常调用
 person.hello("world");// [object Object] 你好啊 世界

有了之前的解密,相信你能理解函数在调用时的小动作是这样的:


var person = {
    name: "掘金果酱淋",
    hello: function(someone) {
      console.log(this + " 你好啊 " + someone);
    }
  };
// 小动作
 person.hello.call(person, "world");// [object Object] 你好啊 世界


call(), apply(), bind()的原理很简单啊!

经过前面解析函数调用的小秘密,我们知道它们都“偷偷地”调用了call()

我们再看一下它们在MDN中的定义:

call()定义

fun.call(thisArg, arg1, arg2, ...)

参数

thisArg 在 fun 函数运行时指定的 this 值。if(thisArg == undefined|null) this = window,if(thisArg == number|boolean|string) this == new Number()|new Boolean()| new String()

arg1, arg2, ... 指定的参数列表。


apply()定义

func.apply(thisArg, [argsArray])

参数

thisArg 可选的。在 func 函数运行时使用的 this 值。请注意,this可能不是该方法看到的实际值:如果这个函数处于非严格模式下,则指定为 null 或 undefined 时会自动替换为指向全局对象,原始值会被包装

argsArray 可选的。一个数组或者类数组对象,其中的数组元素将作为单独的参数传给 func 函数。如果该参数的值为 null 或 undefined,则表示不需要传入任何参数。从ECMAScript 5 开始可以使用类数组对象。 浏览器兼容性 请参阅本文底部内容。。


bind()定义

function.bind(thisArg[, arg1[, arg2[, ...]]])

参数

thisArg 调用绑定函数时作为this参数传递给目标函数的值。 如果使用new运算符构造绑定函数,则忽略该值。当使用bind在setTimeout中创建一个函数(作为回调提供)时,作为thisArg传递的任何原始值都将转换为object。如果bind函数的参数列表为空,执行作用域的this将被视为新函数的thisArg。

arg1, arg2, ... 当目标函数被调用时,预先添加到绑定函数的参数列表中的参数


现在我们在回想一下前面的函数调用,那就很好理解了,我们在声明函数时,thisarguments无法知道是谁(前面说过),那就是undefinded或者null,所以根据MDN的定义,在调用函数是,函数的小动作偷偷调用call()方法,为函数设置了this对象。apply()同理!只不过你要注意它接收参数的方式不同。

bind()呢,也很好理解了,我们不想在函数执行时才被函数的小动作指定this对象,而是要固定this对象,那么bind()方法就是在内部调用了call()或者apply()方法主动指定this对象,同时为了函数可以复用,借用了闭包来保存这个this对象(闭包这里不多说),以下是模拟bind()方法的示例:


// 定义一个对象
var person = {
    name: "掘金果酱淋",
    hello: function(thing) {
      console.log(this.name + " 你好啊 " + thing);
    }
};
// 模拟bind方法的操作,接收一个函数和一个this对象(执行环境)
var bind = function(func, thisValue) {
    return function() {
        return func.apply(thisValue, arguments); // 注意apply()和arguments的妙用
    };
};
  
var boundHello = bind(person.hello, person);
boundHello("世界"); // 打印出// 掘金果酱淋 你好啊 世界

怎么样,挺简单的吧!


后话:this对象到底是什么?

this对象就是函数的执行环境(觉得不理解看一下开篇部分),我说过,执行环境是舞台,函数就是演员,函数可以调用的变量是表演需要的道具。那么,改变函数的执行环境有什么意义呢?我们看例子:


var nullArr = []; // 空数组
var arrType = Object.prototype.toString.call(nullArr); // 调用Object对象原型中的方法,同时将执行环境(this)指向 nullArr 

console.log(arrType); // 打印 // [object Array]
console.log(nullArr.toString()); // 空字符串

首先,我们需要知道,

  1. Object.prototype是所有对象的终端原型对象,其中包括的属性方法是所有对象共享的,其他对象也可以 重写 那些在终端原型对象中的方法

  2. 几乎所有的引用对象都有自己的toString()且是重写了的;

上面例子中,Object.prototype.toString()是终端原型对象的方法,而nullArr作为一个数组的实例,只能调用自己Array.prototype原型中的toString();从例子中我们可以知道,一个空数组调用自己Array.prototype原型的toString()只能得到一个空字符串

在特殊情况下,那我们想要数组实例nullArr能够使用Object.prototype.toString()方法,简单的方法就是给数组重写一个这样的方法,但是如果每一个需要该方法的数组都重新写一次,这就很不符合复用的原则了。

那我们调用call()方法将Object.prototype.toString()方法的执行环境(this)主动变成了nullArr,那么这个方法就可以调用这个执行环境中的变量了(舞台道具),从nullArr的角度看,等于它拥有了Object.prototype.toString()方法,其实应该说拥有了Object.prototype.toString()方法的指针(看开篇),注意指针只是一种关系,而不是重写了对象,(当然,这种关系只在call()执行时有,执行结束后就没有了—退下舞台)。

结语:很简单是假的

能够看完和理解上面的分析过程,你可能会得出了一个结论:这不简单啊,挺绕的!不得不承认,我说谎了,其实并不简单。然而我是为了让你有信心看下去,事情做过之后就会简单了。

我觉得人对未知的东西都会有一定的恐惧感,但如果有人一直强调很简单,那么便不会连开始尝试的勇气都没有!


参考文章:

  1. 追梦者-JavaScript中call,apply,bind方法的总结
  2. 追梦者-彻底理解js中this的指向,不必硬背
  3. Yehuda Katz-Understanding JavaScript Function Invocation and "this"(理解JavaScript函数调用和“this”)(这篇才是顿悟的来源)

QQ:1448373124(欢迎交流前端技术,对于文章疏漏处欢迎指正)

转载请注明出处---掘金果酱淋