首页 > 编程笔记

JS call()、apply()和bind()的区别

JavaScript 中的函数也是对象,相当于调用了 Function() 构造函数所创建的对象,所以每个函数都继承了 Function.prototype 对象中的属性和方法,其中有3个重要的方法:call()、apply()和bind(),这3种方法与 this 有关系。

1. call()

在 JS 中,函数中的 call() 方法用于调用该函数,它接收两个参数,第1个用于设置函数内部 this 的指向,第2个参数是一个变长参数,接收多个逗号分隔的参数并传递给原函数。

例如使用 call() 改变 this 指向,代码如下:
function sum(prop1,prop2){
    return this[prop1]+this[prop2];
}
const obj={a:1,b:2};
const result=sum.call(obj,"a","b");
result;  //3
示例中首先定义了普通函数 sum(),它用于给对象中的两个属性进行求和,两个参数为进行求和计算的属性名,因为属性名是使用变量动态表示的,所以这里使用了[]访问对象中的属性。

如果直接调用该函数 sum("a","b"),则会返回 NaN,因为这样调用函数,里边的 this 指向的是全局对象,而全局对象中并没有 a 和 b 这两个属性,所以其结果是两个 undefined 相加。

后面定义了 obj 对象,里边有 a 和 b 属性,然后通过调用 sum() 原型对象中的 call() 方法,把 obj 作为函数的 this 传递进去,这样就能成功地访问这两个属性,然后返回了正确的结果3。

使用 call() 方法还可以实现链式调用构造函数。假如有两个构造函数,它们有同样的初始化代码,那么可以把它们抽离成一个公共的构造函数,再在这两个构造函数中通过 call() 来调用这个公共的构造函数,call() 中的 this 分别设置为两个构造函数的 this,这样它们还是指向各自新创建的对象。

例如有两个创建消息对象的构造函数,一个用于创建文本消息,另一个用于创建表情消息,它们都有 message 消息内容和 sender 发送者属性,但是有不同的 msgType 消息类别属性,用于区分消息,此时就可以把初始化 message 和 sender 的代码抽离成公共的构造函数,然后各自初始化 msgType,代码如下:
function Message(message, sender){
    this.message = message;
    this.sender = sender;
}

function TextMessage(message, sender){
    Message.cal1(this, message, sender);
    this.msgType = "文本消息";
}

function EmojMessage(message, sender){
    Message.cal1(this, message, sender);
    this.msgType = "表情消息";
}

const txtMsg = new TextMessage("你好", "张三");
const emjMsg = new EmojMessage("^_^","李四");

console.log(txtMsg.message, txtMsg.msgType);
console.log(emjMsg.message, emjMsg.msgType);
//你好 文本消息
//^_^表情消息
可以看到,Message() 构造函数中的 message 和 sender 属性,以及 TextMessage() 和 EmojMessage() 构造函数中的 msgType 属性都正确地赋给了新创建的对象。

2. apply()

JS 中的 apply() 方法与 call() 方法的作用几乎一模一样,但是 apply() 的第2个参数接收的是一个数组,而不是变长参数,通过这个特性,可以把接收多个参数的函数转换成使用一个数组接收参数的函数。

例如,数组中的 push() 方法接收多个参数,把这些参数作为新的元素追加到数组中,此时就可以使用 apply() 方法,把一个数组追加到当前数组中,代码如下:
const arr1=[1,2,3];
const arr2=[4,5,6];
arr1.push.apply(arr1,arr2);
arr1;  //[1,2,3,4,5,6]

在 ES6 中,还可以使用 spread 扩展运算符进行同样的操作:
arr1.push(...arr2)
不过在一些旧的 JavaScript 的代码中,还是可以看到很多使用 apply() 的方式,这里只需知道它的用法就可以了,其他的应用场景跟 call() 保持一致。

另外在学习 Array-like 类数组的结构时,应知道它不能直接使用数组中的方法,因为它与数组对象本身没有继承关系,但是通过 apply() 或 call() 可以间接地调用数组中的方法。

例如使用数组中的 push() 方法还可以给类数组结构添加新元素,而且更重要的是,它还能自动增长类数组中的 length 属性的值,代码如下:
let arrLike={0:"a",1:"b",2:"c",length:3};
Array.prototype.push.apply(arrLike,["d","e","f"]);
arrLike;  //{0:"a",1:"b",2:"c",3:"d",4:"e",5:"f",length:6}

同理,pop() 也可以在类数组结构中用于删除它里边的属性,并自动减少 length 属性的值。

其他的方法例如 forEach()、map() 等也可以如此调用,下方示例演示了如何使用 forEach() 遍历类数组结构,因为这里只给 forEach() 传递了一个回调函数作为参数,所以下例使用 call() 来演示它的用法,代码如下:
let arrLike={0:"a",1:"b",2:"c",length:3};
Array.prototype.forEach.call(arrLike,v=>console.log(v));
输出结果为
a
b
c

使用数组中的 slice() 方法还能把类数组转换为普通数组的形式,只需忽略 slice() 方法的参数,这种使用方法在 rest 运算符出现以前非常普遍,用于把函数中的 arguments 类数组结构转换为数组,然后就可以使用数组中的方法来操作参数了,代码如下:
function f(){
    const args=Array.prototype.slice.apply(arguments);
    args.forEach(arg=>{
        console.log(arg);
    })
}
f(1,2,3);
输出结果如下:
1
2
3

其他的方法,例如 shift()、unshift()、reverse()、includes() 等,也都可以使用 call() 或 apply() 应用到类数组对象中。

3. bind()

在 JS 中,bind() 与 call() 类似,用于给函数绑定 this,并通过变长参数给函数传递参数,不同之处在于,使用 bind() 会创建并返回一个新的函数,这个函数并不会立即被执行,而是需要在合适的地方进行调用。

下方示例展示了使用 bind() 给函数绑定 this 指向的过程,代码如下:
const obj={
    a:1,
    f(b){
        return this.a+b;
    },
};
const f=obj.f;
console.log(f(10));        //NaN
const boundF=f.bind(obj);
console.log(boundF(10));   //11
代码中使用常量 f 保存了 obj 对象中的 f() 方法的引用,直接调用它会丢失 this 对 obj 的指向,所以 f 中的 this.a 会变成 undefined,其结果就成了 NaN,按之前判断 this 的原则,在调用 f(10) 时,左侧没有内容,因为它的 this 指向的就是全局对象,而全局对象里并没有 a 这个变量。

后面使用了 bind() 方法,把 obj 作为 this 绑定到了 f() 函数中,之后再调用它就可以访问 obj 中 a 属性的值了。

在这个例子中,可以看到并没有使用 bind() 给 f() 传递参数,这样后边再调用的时候需要手动传递参数。不过,也可以在使用 bind() 的时候给函数传递参数,除了传递全部参数之外,还可以只传递一部分参数,后续参数在调用的时候再进行传递,这种使用 bind() 传递了部分参数的函数称为部分传递参数函数(Partially Applied Function)。

例如,假设有一个构建文件路径字符串的函数,接收目录和文件名两个参数,如果目录是确定的,则可以使用 bind() 把目录参数确定好,然后在返回的新函数中传递文件名参数,代码如下:
function buildPath(dir,fileName){
    return `${dir}/${fileName}`;
}
const usr=buildPath.bind(null, "/usr");
console.log(usr("image.jpg"));  ///usr/image.jpg
这种用法和柯里化类似,只是柯里化需要在函数内部返回一系列接收1个参数的子函数,并且可以捕获内部的状态,而使用 bind() 则只能保存参数,且只有在最后调用新创建的函数时,函数中的代码才会被执行。

不过,利用 bind() 和 apply() 可以把任何一个函数转换为柯里化的形式,代码如下:
function curry(func){
    return function_curry(...args){
        if(args.length>=func.length){
            return func.apply(null,args);
        }else{
            return_curry.bind(null,...args);
        }
    };
}
curry() 接收一个函数作为参数,并返回柯里化后的新函数,新函数的执行过程如下:
  1. 如果接收的参数数量大于或等于原函数中参数的数量,即参数已经传递完毕,则直接返回最后执行的结果。
  2. 如果数量小于原函数中参数的数量,则使用 bind() 创建一个新函数,并加上新传递的参数。
  3. 重复第 ① 步。

代码中的 func.length 用于获取函数参数的数量,因为函数本身也是对象,它内部有这个属性。在使用返回的新函数时,能够以完全柯里化的形式调用,也能以部分柯里化的方式调用,代码如下:
function add(a,b,c){
    return a+b+c;
}
const addCurry=curry(add);
console.log(addCurry(2)(4)(10));  //16
console.log(addCurry(1,3)(6));    //10
console.log(addCurry(4)(5,7));    //16

推荐阅读