call、apply、bind区别及手写实现

635 阅读8分钟

call方法

我们在手写实现call之前,先来了解一下call的作用
call的作用:改变this的指向,并立即执行函数
call的参数:第一个是this要指向的对象,第二个参数是参数列表
其他特性:第一个参数如果传的是nullundefinedthis会指向window

let foo = {
    value: 1
}

function bar() {
    console.log(this.value)
}

bar(); // undefined 由于bar里面打印this.value, bar函数内没有value属性,并且window上也没有value属性,所以打印undefined

我们可以通过call方法改变this的指向,让this指向foo对象

bar.call(foo) // 1

上面的这种方式相当于在foo对象里面,调用bar函数

let foo = {
    value: 1,
    bar: function() {
        console.log(this.value)
    }
}

foo.bar() // 1

这时候 this就是指向foo,但是这样会给foo添加了一个bar属性,所以调用完需要删除它

所以我们模拟call方法的步骤:
1.将函数设置为对象的属性
2.执行该函数
3.删除该函数

手写call

我们在函数原型链上扩展一个自定义call方法

Function.prototype.myCall = function (context) {
    // context就是要指向的对象
    // 我们给context添加一个fn属性,值为this,这里的this就是调用myCall的函数
    // 1.将函数设置为对象的属性
    context.fn = this;
    // 2.执行该函数
    context.fn();
    // 3.删除该函数
    delete context.fn;
}

经过测试成功

let foo2 = {
    value: 2
}
function bar2() {
    console.log(this.value)
}

bar2.myCall(foo2) // 2

接着我们继续优化,call的第一个参数 如果是undefinednull,则指向window,所以需要判断一下,另外call还可以传递参数

Function.prototype.myCall = function (context) {
    // context就是要指向的对象
    // 这里需要做一个判断
    context = context || window;
    // 收集参数, 获取arguments参数,arguments是一个类数组,所以需要转换成数组,然后从第二个参数开始,因为第一个参数是指向的对象
    const args = [...arguments].slice(1);
    // 我们给context添加一个fn属性,值为this,这里的this就是调用myCall的函数
    // 1.将函数设置为对象的属性
    context.fn = this;
    // 2.执行该函数, 并传递参数
    context.fn(...args);
    // 3.删除该函数
    delete context.fn;
}

我们继续进行优化,因为函数的返回值可能是一个对象,所以需要返回这个对象

Function.prototype.myCall = function (context) {
    // context就是要指向的对象
    // 这里需要做一个判断
    context = context || window;
    // 收集参数, 获取arguments参数,arguments是一个类数组,所以需要转换成数组,然后从第二个参数开始,因为第一个参数是指向的对象
    const args = [...arguments].slice(1);
    // 我们给context添加一个fn属性,值为this,这里的this就是调用myCall的函数
    // 1.将函数设置为对象的属性
    context.fn = this;
    // 2.执行该函数, 并传递参数
    const res = context.fn(...args);
    // 3.删除该函数
    delete context.fn;

    return res;
}

测试

let foo2 = {
    value: 2
}
function bar2(...args) {
    console.log(this.value) // 1
    console.log(args) // [Arguments] { '0': [ 1, 2, 3 ] }

    return {
        name: 'xxx'
    }
}
bar2.myCall(foo2, 1, 2)
console.log(bar2.myCall4(foo2, 1, 2)) // { name: 'xxx' }

由于context对象可能存在fn,如果删除fn可能会将原来的对象的fn删除,所以我们可以用symbol来替代fn

Function.prototype.myCall4 = function (context) {
    // context就是要指向的对象
    // 这里需要做一个判断
    context = context || window;
    // 收集参数, 获取arguments参数,arguments是一个类数组,所以需要转换成数组,然后从第二个参数开始,因为第一个参数是指向的对象
    const args = [...arguments].slice(1);
    // 我们给context添加一个fn属性,值为this,这里的this就是调用myCall的函数
    // 1.将函数设置为对象的属性
    // 用Symbol替换掉fn
    const fnSymbol = Symbol();
    context[fnSymbol] = this;
    // 2.执行该函数, 并传递参数
    const res = context[fnSymbol](...args);
    // 3.删除该函数
    delete context.fn;

    return res;
}

至此,手写call已实现。

apply方法

applycall 的区别在于参数传递方式, apply 接受的是一个数组

手写apply

Function.prototype.myApply = function (context, args) {
    // context就是要指向的对象
    // 这里需要做一个判断
    context = context || window;
    // 我们给context添加一个fn属性,值为this,这里的this就是调用myCall的函数
    // 1.将函数设置为对象的属性
    // 用Symbol替换掉fn
    const fnSymbol = Symbol();
    context[fnSymbol] = this;
    // 2.执行该函数, 并传递参数
    const res = context[fnSymbol](args);
    // 3.删除该函数
    delete context.fn;

    return res;
}
// 测试
const foo = {
    value: 1
}
function bar() {
    console.log(this.value); // 1
    console.log(arguments) // [Arguments] { '0': [ 1, 2, 3 ] }

    return {
        name: 'xxx'
    }
}

console.log(bar.myApply(foo, [1,2,3])) //{ name: 'xxx' }

bind方法

先来了解一下bind的用法

let foo = {
    value: 1
}

function bar() {
    console.log(this.value)
    console.log(...arguments) // hello world !
}

// bind会返回一个函数,并且可以分批次传入参数
const barFn = bar.bind(foo, 'hello', 'world')
barFn('!')

手写bind

Function.prototype.myBind = function (context) {
    //这里的this是调用myBind的函数,即bar
    // 将this保存起来
    let self = this

    // 获取传入的参数,除了第一个参数
    // 因为arguments是类数组,它不存在slice方法,所以这里我们可以将数组原型上的slice指向arguments,这样arguments就拥有了slice方法
    let args = Array.prototype.slice.call(arguments, 1)

    // bind会返回一个函数
    return function () {
        // 将this指向context
        // 因为bind返回的函数可能被调用多次,所以这里需要将参数进行合并
        self.apply(context, [...args, ...arguments])
    }
}

测试一下

const barFn2 = bar.myBind(foo, 'hello world', '!');
barFn2('zzz') // hello world ! zzz

bind 还有一个特性,就是可以和new一起使用,即返回的函数可以作为构造函数使用,此时bind绑定的this会失效,但传入的参数有效,此时 this指向的是实例对象,通过测试可以看到this.nameundefined

let person = {
    name: "张三"
}

function personFn(age, sex) {
    this.hobby = 'Sleep'
    console.log(this.name) // undefined
    console.log('年龄:', age) // 年龄:18
    console.log('性别:', sex) // 性别:男
}

personFn.prototype.say = function () {
    console.log('hello')
}

const bindPersonFn = personFn.bind(person, 18)
const bindPersonObj = new bindPersonFn('男')

console.log(bindPersonObj.hobby); // Sleep
console.log(bindPersonObj.say()); // 可以访问原型上的方法

实现上述功能

Function.prototype.myBind = function (context) {
    //这里的this是调用myBind的函数,即bar
    // 将this保存起来
    let self = this

    // 获取传入的参数,除了第一个参数
    // 因为arguments是类数组,它不存在slice方法,所以这里我们可以将数组原型上的slice指向arguments,这样arguments就拥有了slice方法
    let args = Array.prototype.slice.call(arguments, 1)

    // bind会返回一个函数
    let fnBind = function () {
        // 将this指向context
        // 因为bind返回的函数可能被调用多次,所以这里需要将参数进行合并

        // 判断是否是new调用,如果是new调用,this 会指向实例,将绑定函数的this指向该实例,可以让实例获得绑定函数的值
        if (this instanceof fnBind) {
            // 如果是new调用,this指向实例对象
            self.apply(this, [...args, ...arguments])
        } else {
            // 如果不是new调用,this指向context
            self.apply(context, [...args, ...arguments])
        }
    }
    return fnBind;
}

通过测试发现,已实现上述功能,但调用函数原型上的方法会报错

const bindPersonFn = personFn.myBind(person, 18)
const bindPersonObj = new bindPersonFn('男')

console.log(bindPersonObj.hobby); // Sleep
console.log(bindPersonObj.say()); // 此时调用 函数原型上的值会报错,这里我们还需要将函数原型指向实例对象的原型

我们还需要将返回函数的原型指向调用myBind的函数的原型

Function.prototype.myBind = function (context) {
    //这里的this是调用myBind的函数,即bar
    // 将this保存起来
    let self = this

    // 获取传入的参数,除了第一个参数
    // 因为arguments是类数组,它不存在slice方法,所以这里我们可以将数组原型上的slice指向arguments,这样arguments就拥有了slice方法
    let args = Array.prototype.slice.call(arguments, 1)

    // bind会返回一个函数
    let fnBind = function () {
        // 将this指向context
        // 因为bind返回的函数可能被调用多次,所以这里需要将参数进行合并

        // 判断是否是new调用,如果是new调用,this 会指向实例,将绑定函数的this指向该实例,可以让实例获得绑定函数的值
        if (this instanceof fnBind) {
            // 如果是new调用,this指向实例对象
            self.apply(this, [...args, ...arguments])
        } else {
            // 如果不是new调用,this指向context
            self.apply(context, [...args, ...arguments])
        }
    }

    fnBind.prototype = this.prototype

    return fnBind;
}

通过测试,现在可以正常调用函数原型上的say方法

const bindPersonFn = personFn.myBind(person, 18)
const bindPersonObj = new bindPersonFn('男')

console.log(bindPersonObj.hobby); // Sleep
console.log(bindPersonObj.say()); // 正常调用 say方法

但是在这个写法中,我们直接修改了fnBindprototype,也会直接修改绑定函数的prototype,这里可以使用空函数进行中转

最终的手写bind版本

Function.prototype.myBind = function (context) {

    // 当调用myBind不是函数的时候,提示错误
    if (typeof this !== 'function') {
        throw new Error('TypeError')
    }

    //这里的this是调用myBind的函数,即bar
    // 将this保存起来
    let self = this

    // 获取传入的参数,除了第一个参数
    // 因为arguments是类数组,它不存在slice方法,所以这里我们可以将数组原型上的slice指向arguments,这样arguments就拥有了slice方法
    let args = Array.prototype.slice.call(arguments, 1)

    // 创建一个空函数
    let fnTemp = function () { };

    // bind会返回一个函数
    let fnBind = function () {
        // 将this指向context
        // 因为bind返回的函数可能被调用多次,所以这里需要将参数进行合并

        // 判断是否是new调用,如果是new调用,this 会指向实例,将绑定函数的this指向该实例,可以让实例获得绑定函数的值
        if (this instanceof fnBind) {
            // 如果是new调用,this指向实例对象
            self.apply(this, [...args, ...arguments])
        } else {
            // 如果不是new调用,this指向context
            self.apply(context, [...args, ...arguments])
        }
    }

    // 让fnBind继承fnTemp的原型
    fnTemp.prototype = this.prototype
    fnBind.prototype = new fnTemp()

    return fnBind;
}

总结

相同点

callapplybind都可以改变函数this的指向,且第一个参数都是要指向的对象,如果第一个参数是nullundefined,则指向全局window

不同点

  1. callapply改变this指向后会立即执行,且只会临时改变this一次,而bind改变this指向后不会立即执行,而是会返回一个永久改变this指向的函数。
  2. 参数传递方式不同,callbind是传递参数列表,且bind可以分多次传入,而apply是传递一个参数数组。