call、apply、bind的理解及手写实现

650 阅读5分钟

一、理解

JavaScript 的一个特点是,函数存在定义时上下文运行时上下文上下文是可以改变的这样的概念。而 call()apply()bind() 就是用于改变函数运行时上下文的方法,换句话说,执行这三个方法就是为了改变函数体内部this的指向,将一个对象的方法交给另一个对象来执行。

为何要改变执行上下文? 举一个例子:

有两个对象 A 和 B,A 对象有一个方法,而 B 对象因为某种原因,需要用到同样的方法,这个时候我们是单独再为 B 对象扩展一个方法还是直接借用 A 对象的这个方法呢? 答案当然是借用 A 对象的比较好,既完成了需求,又减少了内存的占用。

1、call、apply

callapply 的用法很类似,它们的共同点是调用之后都会立即执行。

var person = {
    name: '张三',
    say: function() {
        console.log(this.name);
    }
}
person.say(); // 张三

对象 person1 也想用 say 方法,就不需要重新定义,可以通过 call 或者 apply 实现:

var person1 = {
    name: '李四',
}
person.say.call(person1); // 李四
person.say.apply(person1); // 李四

callapply 的区别:

callapply 的作用一样,区别在于接受参数的形式不一样。call 方法的参数是当前上下文的对象以及参数列表,而 apply 只接受两个参数,第一个参数和 call 一样是对象,而第二个参数是一个参数数组或类数组

// 在非严格模式下,第一个参数为null或者undefined时会自动替换为指向全局对象
// call()接受参数列表
Math.max.call(null, 1, 2, 3, 4, 5);
// apply()接收参数数组
Math.max.apply(null, [1, 2, 3, 4, 5]);

2、bind

bind() 的作用和 call()apply() 一样,都是可以改变函数运行时上下文,区别是 call()apply() 在调用函数之后会立即执行,而 bind()方法调用并改变函数运行时上下文后,返回一个新的函数,供我们需要时再调用。

var person = {
    name: '张三',
    say: function() {
        return this.name;
    }
}
var person1 = {
    name: '李四',
}
// bind() 返回一个新函数,以供之后调用
var say1 = person.say.bind(person1);
console.log(say1); // ƒ () { return this.name; }
console.log(say1()); // 李四

有一点需要注意,如果连续两次或多次 bind() 之后,它输出的值是什么呢?像这样:

var person2 = {
    name: '小明',
}
var person3 = {
    name: '小红',
}
var say2 = person.say.bind(person1).bind(person2);
var say3 = person.say.bind(person1).bind(person2).bind(person3);

答案是,两次都仍将输出 李四 ,而非期待中的 小明小红 。原因是,在 Javascript 中,多次 bind() 是无效的。更深层次的原因, bind() 的实现,相当于使用函数在内部包了一个 call / apply ,第二次 bind() 相当于再包住第一次 bind() ,故第二次以后的 bind() 是无法生效的。

二、使用

1、关于数组的妙用

获取数组中最大值或最小值:

Math.max.call(null, 1, 2, 3, 4, 5); // 5
Math.max.apply(null, [1, 2, 3, 4, 5]); // 5

Math.min.call(null, 1, 2, 3, 4, 5);  // 1
Math.min.apply(null, [1, 2, 3, 4, 5]); // 1

合并两个数组:

var arr1 = [1, 2];
var arr2 = [3, 4];
Array.prototype.push.apply(arr1, arr2); // arr1: [1,2,3,4]
Array.prototype.push.call(arr1, ...arr2); // arr1: [1,2,3,4,3,4]

2、对象的继承

function Animal(name) {
    this.name = name;
    this.showName = function() {
        console.log(this.name);
    }
}

function Cat(name) {
    Animal.call(this, name);
    // Animal.apply(this, [name]);
}
var cat = new Cat("My name is Cat");
cat.showName(); // My name is Cat

Animal.call(this) 使 Animal 对象代替 this 对象,那么 Cat 中就有 Animal 的所有属性和方法了,之后 Cat 对象就可以直接调用 Animal 的方法以及属性了。

3、代理 console.log 方法

日常工作中经常要用到console.log方法,那么我们就可以定义一个log方法来代理它:

function log() {
    // console.log.call(console, ...arguments);
    console.log.apply(console, arguments);
}
log(1, 2, 3, 4); // 1 2 3 4

4、如何选用

如果不需要关心具体有多少参数被传入函数,选用 apply()

如果确定函数可接收多少个参数,并且想一目了然表达形参和实参的对应关系,选用 call()

如果我们想要将来再调用方法,不需立即得到函数返回结果,则可以使用 bind()

三、小结

call()apply()bind() 都是用来改变函数执行时的上下文,可借助它们实现继承;

call()apply() 唯一区别是参数不一样,call()apply() 的语法糖;

bind() 是返回一个新函数,供以后调用,而 apply()call() 是立即调用。

四、手写实现

1、模拟 call()

call 的目的是为了改变函数的执行上下文,然后立即执行函数。

var person = {
    name: '小明',
}
function getName() {
    console.log(this.name, ...arguments);
}
Function.prototype.myCall = function(context) {
    context = context || window; // context 是当前调用函数的对象(person),为null或者undefined时取用window
    context.fn = this; // this即为要调用的函数(getName())
    let args = [...arguments].slice(1); // 获取传入的参数,从第二个开始
    let result = context.fn(...args); // 执行添加的函数fn(getName())
    delete context.fn; // 执行完删除这个方法,以免对调用对象(person)造成改变
    return result;
}
getName.myCall(person, 30); // 小明 30

2、模拟 apply()

apply()call() 类似,只不过它需要判断一下参数数组是否存在。

var person = {
    name: '小明',
}
function getName() {
    console.log(this.name, ...arguments);
}
Function.prototype.myApply = function(context) {
    context = context || window; // context 是当前调用函数的对象(person),为null或者undefined时取用window
    context.fn = this; // this即为要调用的函数(getName())
    let args = arguments[1]; // 判断是否存在第二个参数,是否为参数数组
    if (args && toString.call(args) !== '[object Array]') {
        throw new TypeError('Erorr');
    }
    let result;
    if (args) {
        result = context.fn(...args)
    } else {
        result = context.fn();
    }
    delete context.fn; // 执行完删除这个方法,以免对调用对象(person)造成改变
    return result;
}
getName.myApply(person, [30]);

3、模拟 bind()

这里需要注意下,因为 bind 转换后的函数可以作为构造函数使用,此时 this 应该指向构造出的实例,而 bind 函数绑定的第一个参数。

var person = {
    name: '小明',
}
function getName() {
    console.log(this.name, ...arguments);
}
Function.prototype.myBind = function(context) {
    if (typeof this !== 'function') {
        throw new TypeError('Error')
    }
    context = context || window;
    // 保存调用函数的引用,这里 self 是getName()
    let self = this;
    let args = [...arguments].slice(1);
    return function F() {
        // 判断是否被当做构造函数使用
        if (this instanceof F) {
            return self.apply(this, args.concat([...arguments]))
        }
        return self.apply(context, args.concat([...arguments]))
    }
}
var getName1 = getName.myBind(person);
getName1(20); // 小明 20

在返回的新函数内部,self.apply(context, args.concat([...arguments]) 才是执行原来的 getName 函数,相当于执行 getName.apply(person)