深入理解-callapplybind

2次阅读

共计 9857 个字符,预计需要花费 25 分钟才能阅读完成。

call、apply、bind 都是和 this 指向有关的,这三个方法是 JavaScript 内置对象 Function 的原型的方法。

基本介绍

1. 语法

func.call(thisArg, param1, param2, ...)//func 是个函数

func.apply(thisArg, [param1,param2,...])

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 方法,进行遍历。

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

let arrayLike = {
    0: 1,
    1: 2,
    2: 3,
    length: 3
};

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

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

但是需要注意的是:类数组无法使用 forEach、splice、push 等数组原型链上的方法,毕竟它不是真正的数组。

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

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

生活中:

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

程序中:

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

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

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

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

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

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

应用场景

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

1. 判断数据类型

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

function isType(data, type) {
  const typeObj = {"[object String]": "string",
    "[object Number]": "number",
    "[object Boolean]": "boolean",
    "[object Null]": "null",
    "[object Undefined]": "undefined",
    "[object Object]": "object",
    "[object Array]": "array",
    "[object Function]": "function",
    "[object Date]": "date", // Object.prototype.toString.call(new Date())
    "[object RegExp]": "regExp",
    "[object Map]": "map",
    "[object Set]": "set",
    "[object HTMLDivElement]": "dom", // document.querySelector('#app')
    "[object WeakMap]": "weakMap",
    "[object Window]": "window", // Object.prototype.toString.call(window)
    "[object Error]": "error", // new Error('1')
    "[object Arguments]": "arguments"
  };

  let name = Object.prototype.toString.call(data); // 借用 Object.prototype.toString()获取数据类型
  let typeName = typeObj[name] || "未知类型"; // 匹配数据类型
  return typeName === type; // 判断该数据类型是否为传入的类型
}

console.log(isType({}, "object"), //>> true
  isType([], "array"), //>> true
  isType(new Date(), "object"), //>> false
  isType(new Date(), "date") //>> true
);

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

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

比如借用数组的 push 方法:

// 类数组对象
var arrayLike = {
  0: "OB",
  1: "Koro1",
  length: 2
};

Array.prototype.push.call(arrayLike, "添加数组项 1", "添加数组项 2");

console.log(arrayLike);
//>> {"0":"OB","1":"Koro1","2":"添加数组项 1","3":"添加数组项 2","length":4}

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

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

const arr = [15, 6, 12, 13, 16];

const max = Math.max.apply(Math, arr); // 16

const min = Math.min.apply(Math, arr); // 6

4. 继承

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

// 父类
function supFather(name) {
    this.name = name;
    this.colors = ['red', 'blue', 'green']; // 复杂类型

}

supFather.prototype.sayName = function (age) {console.log(this.name, 'age');
};

// 子类
function sub(name, age) {
    // 借用父类的方法:修改它的 this 指向, 赋值父类的构造函数里面方法、属性到子类上
    supFather.call(this, name);
    this.age = age;
}

// 重写子类的 prototype,修正 constructor 指向
function inheritPrototype(sonFn, fatherFn) {sonFn.prototype = Object.create(fatherFn.prototype); // 继承父类的属性以及方法
    sonFn.prototype.constructor = sonFn; // 修正 constructor 指向到继承的那个函数上
}

inheritPrototype(sub, supFather);
sub.prototype.sayAge = function () {console.log(this.age, 'foo');
};

// 实例化子类,可以在实例上找到属性、方法
const instance1 = new sub("OBKoro1", 24);
const instance2 = new sub("小明", 18);
instance1.colors.push('black')

console.log(instance1);
//>> {"name":"OBKoro1","colors":["red","blue","green","black"],"age":24}
console.log(instance2);
//>> {"name":"小明","colors":["red","blue","green"],"age":18}

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

5. call、apply 应该用哪个?

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

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

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

const obj = {
    age: 24,
    name: 'OBKoro1',
}

const obj2 = {age: 777}

callObj(obj, handle);
callObj(obj2, handle);

// 根据某些条件来决定要传递参数的数量、以及顺序
function callObj(thisAge, fn) {let params = [];

    if (thisAge.name) {params.push(thisAge.name);
    }

    if (thisAge.age) {params.push(thisAge.age);
    }

    fn.apply(thisAge, params); // 数量和顺序不确定 不能使用 call
}

function handle(...params) {console.log('params', params); // do some thing
}

6. bind 的应用场景

保存函数参数

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

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

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

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

实际上这里也用了闭包,我们知道 bind 会返回一个函数,这个函数也是闭包

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

回调函数 this 丢失问题

这是一个常见的问题,调试了一番才发现是 this 指向丢失的问题。

class Page {constructor(callBack) {
        this.className = 'Page';
        this.MessageCallBack = callBack; // 回调函数
        this.MessageCallBack('发给注册页面的信息'); // 执行 PageA 的回调函数
    }
}

class PageA {constructor() {
        this.className = 'PageA';
        // 问题在下面这句
        this.pageClass = new Page(this.handleMessage);// 注册页面 传递回调函数 
    }

    // 与页面通信回调
    handleMessage(msg) {console.log('处理通信', this.className, msg); // 'Page' this 指向错误
    }
}

new PageA();

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

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

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

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

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

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

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

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

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

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

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

用原生 JavaScript 实现 call/apply、bind

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

1. 实现 call

实现思路

  1. 参考 call 的语法规则,需要设置一个参数thisArg,也就是 this 的指向
  2. thisArg 封装为一个 Object
  3. 通过为 thisArg 创建一个临时方法,这样 thisArg 就是调用该临时方法的对象了,会将该临时方法的 this 隐式指向到 thisArg
  4. 执行 thisArg 的临时方法,并传递参数
  5. 删除临时方法,返回方法的执行结果
/**
 * 用原生 JavaScript 实现 call
 */
Function.prototype.myCall = function(thisArg, ...arr) {

  //1. 判断参数合法性 /////////////////////////
  if (thisArg === null || thisArg === undefined) {// 指定为 null 和 undefined 的 this 值会自动指向全局对象(浏览器中为 window)
    thisArg = window;
  } else {thisArg = Object(thisArg);// 创建一个可包含数字 / 字符串 / 布尔值的对象,//thisArg 会指向一个包含该原始值的对象。}

  //2. 搞定 this 的指向 /////////////////////////
  const specialMethod = Symbol("anything"); // 创建一个不重复的常量
  // 如果调用 myCall 的函数名是 func,也即以 func.myCall()形式调用;// 根据上篇文章介绍,则 myCall 函数体内的 this 指向 func
  thisArg[specialMethod] = this; // 给 thisArg 对象建一个临时属性来储存 this(也即 func 函数)// 进一步地,根据上篇文章介绍,func 作为 thisArg 对象的一个方法被调用,那么 func 中的 this 便
  // 指向 thisArg 对象。由此,巧妙地完成将 this 隐式地指向到 thisArg!let result = thisArg[specialMethod](...arr);
  
  //3. 收尾
  delete thisArg[specialMethod]; // 删除临时方法
  return result; // 返回临时方法的执行结果
};

let obj = {name: "coffe1891"};

function func() {console.log(this.name);
}

func.myCall(obj);//>> coffe1891

认真读一下注释,基本就能理解了,主要还是用到判断 this 指向的规则。

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

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

// 不够严谨
thisArg = thisArg ? Object(thisArg) : window;
thisArg = thisArg || window;

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

// 网上的方案: thisArg = thisArg || window;
// 这种方案不严谨,代码举例:// 参数明明传了值,本意不是要让 thisArg 指向 window,然后结果却意外地指向了 window
handle.elseCall(''); // window
handle.elseCall(0); // window
handle.elseCall(false); // window

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

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

2. 实现 Apply

实现思路

  1. 传递给函数的参数处理,不太一样,其他部分跟 call 一样;
  2. apply 接受第二个参数为类数组对象
/**
 * 用原生 JavaScript 实现 apply
 */
Function.prototype.myApply = function(thisArg) {if (thisArg === null || thisArg === undefined) {thisArg = window;} else {thisArg = Object(thisArg);
  }

  // 判断是否为【类数组对象】function isArrayLike(o) {
    if (
      o && // o 不是 null、undefined 等
      typeof o === "object" && // o 是对象
      isFinite(o.length) && // o.length 是有限数值
      o.length >= 0 && // o.length 为非负值
      o.length === Math.floor(o.length) && // o.length 是整数
      o.length < 4294967296
    )
      // o.length < 2^32
      return true;
    else return false;
  }

  const specialMethod = Symbol("anything");
  thisArg[specialMethod] = this;

  let args = arguments[1]; // 获取参数数组
  let result;

  // 处理传进来的第二个参数
  if (args) {
    // 是否传递第二个参数
    if (!Array.isArray(args) && !isArrayLike(args)) {
      throw new TypeError("第二个参数既不为数组,也不为类数组对象。抛出错误");
    } else {args = Array.from(args); // 转为数组
      result = thisArg[specialMethod](...args); // 执行函数并展开数组,传递函数参数
    }
  } else {result = thisArg[specialMethod]();}

  delete thisArg[specialMethod];
  return result; // 返回函数执行结果
};

3. 实现 bind

实现思路

(1)拷贝调用函数:

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

(2)返回拷贝的函数funcForBind

(3)调用拷贝的函数funcForBind

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

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

/**
 * 用原生 JavaScript 实现 bind
 */
Function.prototype.myBind = function(objThis, ...params) {const thisFn = this;// 存储调用函数,以及上方的 params(函数参数)
  // 对返回的函数 secondParams 二次传参
  let funcForBind = function(...secondParams) {
    // 检查 this 是否是 funcForBind 的实例?也就是检查 funcForBind 是否通过 new 调用
    const isNew = this instanceof funcForBind;

    //new 调用就绑定到 this 上, 否则就绑定到传入的 objThis 上
    const thisArg = isNew ? this : Object(objThis);

    // 用 call 执行调用函数,绑定 this 的指向,并传递参数。返回执行结果
    return thisFn.call(thisArg, ...params, ...secondParams);
  };

  // 复制调用函数的 prototype 给 funcForBind
  funcForBind.prototype = Object.create(thisFn.prototype);
  return funcForBind;// 返回拷贝的函数
};

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

let func = function(p,secondParams){// 其实测试用的 func 其参数可以是任意多个
    console.log(p.name);
    console.log(this.name);
    console.log(secondParams);
}
let obj={name:"1891"}
func.myBind(obj,{name:"coffe"})("二次传参");
//>> coffe
//>> 1891
//>> 二次传参

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

正文完
 0