深入理解 call、apply、bind

本文阅读 17 分钟
首页 知识库 正文

上一篇我们知道,call、apply、bind都是和this指向有关的,这三个方法是JavaScript内置对象Function的原型的方法。相当一部分前端工程师对它们的理解仍旧比较浅显,所谓具备JavaScript基础扎实,是绕不开这些基础又常用的知识点的,这次让我们来深入理解并掌握它们。

基本介绍

1. 语法

  1. func.call(thisArg, param1, param2, ...)//func是个函数
  2. func.apply(thisArg, [param1,param2,...])
  3. func.bind(thisArg, param1, param2, ...)

返回值:

call / apply:返回func 执行的结果 ;
bind:返回func的拷贝,并拥有指定的this值和初始参数。

参数:

thisArg(可选):

  1. functhis指向thisArg对象
  2. 非严格模式下:若thisArg指定为null,undefined,则functhis指向window对象;
  3. 严格模式下:functhisundefined
  4. 值为原始值(数字,字符串,布尔值)的this会指向该原始值的自动包装对象,如 String、Number、Boolean。

param1param2(可选): 传给func的参数。

  1. 如果param不传或为 null/undefined,则表示不需要传入任何参数.
  2. apply第二个参数为类数组对象,数组内各项的值为传给func的参数。

2. 必须是函数才能调用call/apply/bind

call、apply 和 bind 是挂在Function对象上的三个方法,只有函数才有这些方法。只要是函数就可以调用它们,比如: Object.prototype.toString就是个函数,我们经常看到这样的用法:Object.prototype.toString.call(data)

3. 作用

改变函数执行时的this指向,目前所有关于它们的运用,都是基于这一点来进行的。

4. 如何不弄混call和apply

弄混这两个方法的不在少数,不要小看这个问题,记住下面的这个方法就好了。双a记忆法:apply是以a开头,它传给func的参数是类Array对象(类数组对象),也是以a开头的。

call与apply的唯一区别

传给func的参数写法不同:

  • apply是第2个参数,这个参数是一个类数组对象:传给func参数都写在数组中。
  • call从第2~n的参数都是传给func的。

call/apply与bind的区别

执行:

  • call/apply改变了函数的this的指向并马上执行该函数
  • bind则是返回改变了this指向后的函数,不执行该函数

返回值:

  • call/apply 返回func的执行结果;
  • bind返回func的拷贝,并指定了functhis指向,保存了func的参数。

返回值这段在下方bind应用中有详细的示例解析。

什么是类数组?

先说数组,这我们都熟悉。它的特征有:可以通过索引(index)调用,如 array[0];具有长度属性length;可以通过 for 循环或forEach方法,进行遍历。

那么,类数组是什么呢?顾名思义,就是具备与数组特征类似的对象 。比如,下面的这个对象,就是一个类数组。

  1. let arrayLike = {
  2. 0: 1,
  3. 1: 2,
  4. 2: 3,
  5. length: 3
  6. };

类数组 arrayLike 可以通过下标进行调用,具有length属性,同时也可以通过 for 循环进行遍历。

类数组,还是比较常用的,只是我们平时可能没注意到。比如,我们获取 DOM 节点的方法,返回的就是一个类数组;再比如,在一个函数体中使用 arguments 获取到的所有参数,也是一个类数组。

但是需要注意的是:类数组无法使用 forEach、splice、push 等数组原型链上的方法 ,毕竟它不是真正的数组。那么类数组想使用数组原型链上的方法,该怎么办呢?请继续往下看。

call/apply/bind的核心理念:借用方法

什么是借用方法,让我们打个比方。

生活中:

平时没时间做饭的我,周末想给家人炖个牛肉火锅尝尝。但是没有适合的锅,而我又不想出去买,所以就向邻居借了一个锅来用,这样既达到了目的,又节省了开支,一举两得。

程序中:

A对象有个方法,B对象因为某种原因也需要用到同样的方法,那么这时候我们是单独为 B 对象扩展一个方法呢,还是借用一下 A 对象的方法呢?

当然是借用 A 对象的方法更便捷,既达到了目的,又节省了内存。

这就是call/apply/bind的核心理念:借用方法借助已实现的方法,改变方法中数据的this指向,减少重复代码,节省内存。

还记得刚才的类数组么?如果它想借用 Array 原型链上的slice方法,可以这样:

  1. let domNodes = Array.prototype.slice.call(document.getElementsByTagName("*"));

以此类推,domNodes 就可以应用 Array 下的其他方法了。

应用场景

这些应用场景,多加体会就可以发现它们的理念都是:借用方法。

1. 判断数据类型

Object.prototype.toString用来判断类型再合适不过,借用它我们几乎可以判断所有类型的数据:

  1. function isType(data, type) {
  2. const typeObj = {
  3. "[object String]": "string",
  4. "[object Number]": "number",
  5. "[object Boolean]": "boolean",
  6. "[object Null]": "null",
  7. "[object Undefined]": "undefined",
  8. "[object Object]": "object",
  9. "[object Array]": "array",
  10. "[object Function]": "function",
  11. "[object Date]": "date", // Object.prototype.toString.call(new Date())
  12. "[object RegExp]": "regExp",
  13. "[object Map]": "map",
  14. "[object Set]": "set",
  15. "[object HTMLDivElement]": "dom", // document.querySelector('#app')
  16. "[object WeakMap]": "weakMap",
  17. "[object Window]": "window", // Object.prototype.toString.call(window)
  18. "[object Error]": "error", // new Error('1')
  19. "[object Arguments]": "arguments"
  20. };
  21. let name = Object.prototype.toString.call(data); // 借用Object.prototype.toString()获取数据类型
  22. let typeName = typeObj[name] || "未知类型"; // 匹配数据类型
  23. return typeName === type; // 判断该数据类型是否为传入的类型
  24. }
  25. console.log(
  26. isType({}, "object"), //>> true
  27. isType([], "array"), //>> true
  28. isType(new Date(), "object"), //>> false
  29. isType(new Date(), "date") //>> true
  30. );

2. 类数组对象借用数组的方法

因为类数组对象不是真正的数组,所以没有数组类型上自带的一些方法,所以我们需要去借用数组的方法。

比如借用数组的push方法:

  1. //类数组对象
  2. var arrayLike = {
  3. 0: "OB",
  4. 1: "Koro1",
  5. length: 2
  6. };
  7. Array.prototype.push.call(arrayLike, "添加数组项1", "添加数组项2");
  8. console.log(arrayLike);
  9. //>> {"0":"OB","1":"Koro1","2":"添加数组项1","3":"添加数组项2","length":4}

3. apply获取数组最大值最小值

apply直接传递数组当作要调用方法的参数,也省去了展开数组这一步,比如使用Math.max、Math.min来获取数组的最大值/最小值。

  1. const arr = [15, 6, 12, 13, 16];
  2. const max = Math.max.apply(Math, arr); // 16
  3. const min = Math.min.apply(Math, arr); // 6

4. 继承

ES5的继承也都是通过借用父类的构造方法来实现父类方法/属性的继承:

  1. // 父类
  2. function supFather(name) {
  3. this.name = name;
  4. this.colors = ['red', 'blue', 'green']; // 复杂类型
  5. }
  6. supFather.prototype.sayName = function (age) {
  7. console.log(this.name, 'age');
  8. };
  9. // 子类
  10. function sub(name, age) {
  11. // 借用父类的方法:修改它的this指向,赋值父类的构造函数里面方法、属性到子类上
  12. supFather.call(this, name);
  13. this.age = age;
  14. }
  15. // 重写子类的prototype,修正constructor指向
  16. function inheritPrototype(sonFn, fatherFn) {
  17. sonFn.prototype = Object.create(fatherFn.prototype); // 继承父类的属性以及方法
  18. sonFn.prototype.constructor = sonFn; // 修正constructor指向到继承的那个函数上
  19. }
  20. inheritPrototype(sub, supFather);
  21. sub.prototype.sayAge = function () {
  22. console.log(this.age, 'foo');
  23. };
  24. // 实例化子类,可以在实例上找到属性、方法
  25. const instance1 = new sub("OBKoro1", 24);
  26. const instance2 = new sub("小明", 18);
  27. instance1.colors.push('black')
  28. console.log(instance1);
  29. //>> {"name":"OBKoro1","colors":["red","blue","green","black"],"age":24}
  30. console.log(instance2);
  31. //>> {"name":"小明","colors":["red","blue","green"],"age":18}

类似的应用场景还有很多,就不再一一列举了。关键在于它们借用方法的理念,若不理解的话可以多看几遍。

5. call、apply应该用哪个?

call,apply的效果完全一样,它们的区别也在于

  • 参数数量/顺序确定就用call,参数数量/顺序不确定的话就用apply
  • 考虑可读性:参数数量不多就用call,参数数量比较多的话,把参数整合成数组,使用apply。
  • 参数集合已经是一个数组的情况,用apply,比如上文的获取数组最大值/最小值。

参数数量/顺序不确定的话就用apply,比如以下示例:

  1. const obj = {
  2. age: 24,
  3. name: 'OBKoro1',
  4. }
  5. const obj2 = {
  6. age: 777
  7. }
  8. callObj(obj, handle);
  9. callObj(obj2, handle);
  10. // 根据某些条件来决定要传递参数的数量、以及顺序
  11. function callObj(thisAge, fn) {
  12. let params = [];
  13. if (thisAge.name) {
  14. params.push(thisAge.name);
  15. }
  16. if (thisAge.age) {
  17. params.push(thisAge.age);
  18. }
  19. fn.apply(thisAge, params); // 数量和顺序不确定 不能使用call
  20. }
  21. function handle(...params) {
  22. console.log('params', params); // do some thing
  23. }

6. bind的应用场景

保存函数参数

首先来看下一道经典的面试题:

  1. for (var i = 1; i <= 5; i++) {
  2. setTimeout(function test() {
  3. console.log(i) //>> 6 6 6 6 6
  4. }, i * 1000);
  5. }

造成这个现象的原因是等到setTimeout异步执行时,i已经变成6了。那么如何使他输出: 1,2,3,4,5呢?可以通过bind来巧妙实现。

  1. for (var i = 1; i <= 5; i++) {
  2. // 缓存参数
  3. setTimeout(function (i) {
  4. console.log('bind', i) //>> 1 2 3 4 5
  5. }.bind(null, i), i * 1000);
  6. }

实际上这里也用了闭包,我们知道bind会返回一个函数,这个函数也是闭包(下一篇会深度介绍“闭包”的相关知识)。

它保存了函数的this指向、初始参数,每次i的变更都会被bind的闭包存起来,所以输出1-5。具体细节,下面有个手写bind方法,详细阅读一下就能搞明白。

回调函数this丢失问题

这是一个常见的问题,下面是我在开发VSCode插件处理webview通信时,遇到的真实问题,一开始以为VSCode的API哪里出问题,调试了一番才发现是this指向丢失的问题。

  1. class Page {
  2. constructor(callBack) {
  3. this.className = 'Page';
  4. this.MessageCallBack = callBack; //回调函数
  5. this.MessageCallBack('发给注册页面的信息'); // 执行PageA的回调函数
  6. }
  7. }
  8. class PageA {
  9. constructor() {
  10. this.className = 'PageA';
  11. //问题在下面这句
  12. this.pageClass = new Page(this.handleMessage);//注册页面 传递回调函数
  13. }
  14. // 与页面通信回调
  15. handleMessage(msg) {
  16. console.log('处理通信', this.className, msg); // 'Page' this指向错误
  17. }
  18. }
  19. new PageA();

回调函数this为何会丢失?显然声明的时候不会出现问题,执行回调函数的时候也不可能出现问题。问题出在传递回调函数的时候:

  1. this.pageClass = new Page(this.handleMessage);

因为传递过去的this.handleMessage是函数内存地址,没有附带上下文对象,也就是说该函数this.handleMessage没有绑定它的this指向。

既然知道问题了,那我们只要绑定回调函数的this指向为PageA就解决问题了。

回调函数this丢失的解决方案

(1)bind绑定回调函数的this指向:

这是典型bind的应用场景, 绑定this指向,用做回调函数。

  1. this.pageClass = new Page(this.handleMessage.bind(this));
  2. //绑定回调函数的this指向

(2)用箭头函数绑定this指向:

箭头函数的this指向定义的时候外层第一个普通函数的this,在这里指的是class类:PageA

  1. this.pageClass = new Page(() => this.handleMessage());
  2. //箭头函数绑定this指向

面试题:用原生JavaScript实现call/apply、bind

在一线互联网公司的面试中,手写实现call、apply、bind(特别是bind)一直是比较高频的面试题,在这里我们也一起来实现一下这几个函数。

1. 实现call

实现思路

  1. 参考call的语法规则,需要设置一个参数thisArg,也就是this的指向;
  2. thisArg封装为一个Object;
  3. 通过为thisArg创建一个临时方法,这样thisArg就是调用该临时方法的对象了,会将该临时方法的this隐式指向到thisArg上(参考上一篇文章《彻底搞懂this》);
  4. 执行thisArg的临时方法,并传递参数;
  5. 删除临时方法,返回方法的执行结果。
  1. /**
  2. * 用原生JavaScript实现call
  3. */
  4. Function.prototype.myCall = function(thisArg, ...arr) {
  5. //1.判断参数合法性/////////////////////////
  6. if (thisArg === null || thisArg === undefined) {
  7. //指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中为window)
  8. thisArg = window;
  9. } else {
  10. thisArg = Object(thisArg);//创建一个可包含数字/字符串/布尔值的对象,
  11. //thisArg 会指向一个包含该原始值的对象。
  12. }
  13. //2.搞定this的指向/////////////////////////
  14. const specialMethod = Symbol("anything"); //创建一个不重复的常量
  15. //如果调用myCall的函数名是func,也即以func.myCall()形式调用;
  16. //根据上篇文章介绍,则myCall函数体内的this指向func
  17. thisArg[specialMethod] = this; //给thisArg对象建一个临时属性来储存this(也即func函数)
  18. //进一步地,根据上篇文章介绍,func作为thisArg对象的一个方法被调用,那么func中的this便
  19. //指向thisArg对象。由此,巧妙地完成将this隐式地指向到thisArg!
  20. let result = thisArg[specialMethod](...arr);
  21. //3.收尾
  22. delete thisArg[specialMethod]; //删除临时方法
  23. return result; //返回临时方法的执行结果
  24. };
  25. let obj = {
  26. name: "coffe1891"
  27. };
  28. function func() {
  29. console.log(this.name);
  30. }
  31. func.myCall(obj);//>> coffe1891

认真读一下注释,基本就能理解了,主要还是用到判断this指向的规则。如果读完还是不明白,可以加我的微信(见本书封面)交流。附带更正一下,其中第11行:

当以非构造函数形式被调用时,Object 等同于 new Object()
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object

多讲一下如何正确地判断thisArg:

上面代码判断thisArg的写法是经过调整后的严谨写法,因为之前笔者发现很多前端工程师判断参数thisArg,只是简单的以是否为false来判断,比如:

  1. // 不够严谨
  2. thisArg = thisArg ? Object(thisArg) : window;
  3. thisArg = thisArg || window;

经过测试,以下三种情况,thisArg都会意外地绑定到window上:

  1. // 参数明明传了值,本意不是要让thisArg指向window,然后结果却意外地指向了window
  2. func.myCall(''); // window
  3. func.myCall(0); // window
  4. func.myCall(false); // window

所以应该严谨一点儿判断,如下:

  1. // 严谨的判断
  2. if (thisArg === null || thisArg === undefined) {
  3. // 指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中为window)
  4. thisArg = window;
  5. } else {
  6. thisArg = Object(thisArg);
  7. //创建一个包含原始值(数字,字符串,布尔值)的对象,
  8. //thisArg 会指向一个包含该原始值的对象。
  9. }

2. 实现Apply

实现思路

  1. 传递给函数的参数处理,不太一样,其他部分跟call一样;
  2. apply接受第二个参数为类数组对象, 这里用了《JavaScript权威指南》一书中判断是否为类数组对象的方法。
  1. /**
  2. * 用原生JavaScript实现apply
  3. */
  4. Function.prototype.myApply = function(thisArg) {
  5. if (thisArg === null || thisArg === undefined) {
  6. thisArg = window;
  7. } else {
  8. thisArg = Object(thisArg);
  9. }
  10. //判断是否为【类数组对象】
  11. function isArrayLike(o) {
  12. if (
  13. o && // o不是null、undefined等
  14. typeof o === "object" && // o是对象
  15. isFinite(o.length) && // o.length是有限数值
  16. o.length >= 0 && // o.length为非负值
  17. o.length === Math.floor(o.length) && // o.length是整数
  18. o.length < 4294967296
  19. )
  20. // o.length < 2^32
  21. return true;
  22. else return false;
  23. }
  24. const specialMethod = Symbol("anything");
  25. thisArg[specialMethod] = this;
  26. let args = arguments[1]; // 获取参数数组
  27. let result;
  28. // 处理传进来的第二个参数
  29. if (args) {
  30. // 是否传递第二个参数
  31. if (!Array.isArray(args) && !isArrayLike(args)) {
  32. throw new TypeError(
  33. "第二个参数既不为数组,也不为类数组对象。抛出错误"
  34. );
  35. } else {
  36. args = Array.from(args); // 转为数组
  37. result = thisArg[specialMethod](...args); // 执行函数并展开数组,传递函数参数
  38. }
  39. } else {
  40. result = thisArg[specialMethod]();
  41. }
  42. delete thisArg[specialMethod];
  43. return result; // 返回函数执行结果
  44. };

3. 实现bind

手写实现bind是一线互联网企业中的一个高频的面试题,如果面试的中高级前端,只是能说出它们的区别、用法并不能让你脱颖而出,对bind的理解要有足够的深度,才能得到面试官的赞许。

实现思路

(1)拷贝调用函数:

  • 调用函数,也即调用myBind的函数,用一个变量临时储存它;
  • 使用Object.create复制调用函数的prototype给funcForBind

(2)返回拷贝的函数funcForBind

(3)调用拷贝的函数funcForBind

  • new调用判断:通过instanceof判断函数是否通过new调用,来决定绑定的context;
  • 通过call绑定this、传递参数;
  • 返回调用函数的执行结果。

这里给出一种基于ES6的实现(基于老版ES的实现方式也不少,但没有ES6的spread和rest操作符实现简洁):

  1. /**
  2. * 用原生JavaScript实现bind
  3. */
  4. Function.prototype.myBind = function(objThis, ...params) {
  5. const thisFn = this;//存储调用函数,以及上方的params(函数参数)
  6. //对返回的函数 secondParams 二次传参
  7. let funcForBind = function(...secondParams) {
  8. //检查this是否是funcForBind的实例?也就是检查funcForBind是否通过new调用
  9. const isNew = this instanceof funcForBind;
  10. //new调用就绑定到this上,否则就绑定到传入的objThis上
  11. const thisArg = isNew ? this : Object(objThis);
  12. //用call执行调用函数,绑定this的指向,并传递参数。返回执行结果
  13. return thisFn.call(thisArg, ...params, ...secondParams);
  14. };
  15. //复制调用函数的prototype给funcForBind
  16. funcForBind.prototype = Object.create(thisFn.prototype);
  17. return funcForBind;//返回拷贝的函数
  18. };

上面的代码第7行可能不好理解,二次传参(secondParams)是说什么?举个例子:

  1. let func = function(p,secondParams){//其实测试用的func其参数可以是任意多个
  2. console.log(p.name);
  3. console.log(this.name);
  4. console.log(secondParams);
  5. }
  6. let obj={
  7. name:"1891"
  8. }
  9. func.myBind(obj,{name:"coffe"})("二次传参");
  10. //>> coffe
  11. //>> 1891
  12. //>> 二次传参

上面第8行调用myBind之后,会返回一个新函数,然后再给这个新函数传入参数,就是二次传参。

解压密码: detechn或detechn.com

免责声明

本站所有资源出自互联网收集整理,本站不参与制作,如果侵犯了您的合法权益,请联系本站我们会及时删除。

本站发布资源来源于互联网,可能存在水印或者引流等信息,请用户自行鉴别,做一个有主见和判断力的用户。

本站资源仅供研究、学习交流之用,若使用商业用途,请购买正版授权,否则产生的一切后果将由下载用户自行承担。

彻底搞懂 this
« 上一篇 11-16
《JavaScript百炼成仙》PDF版
下一篇 » 11-17

发表评论

惪特博客
  • 文章总数:
    18497 篇
  • 评论总数:
    53330 条
  • 标签总数:
    8873 个
  • 总浏览量:
    22774155 次
  • 最后更新:
    3月27日

最多点赞

随便看看

标签TAG