手写 call、apply、bind 函数

199 阅读10分钟

函数 this 指向改变的方法有三种,分别是 callapplybind 函数,那么它们三者的区别和实现原理是怎么样的呢?本文围绕这两个问题,会给读者交出一份满意的答卷。

this 指向的问题

在讲解 callapplybind 函数的区别和实现原理之前,我们有必要了解普通函数中,this 指向的问题。

普通函数的 this 指向的对象是不确定的,比如:

function dynamicThis() {
  console.log(this);
}

const user = {
  name: 'jack',
  dynamicThis
}

dynamicThis();  // window 对象
user.dynamicThis(); // user 对象

可以看到两次函数的调用,this 指向分别是 window 对象和 user 对象,因此对于普通函数来说,哪个对象调用了这个函数,函数内部的this就指向它

噢!在第二次函数调用中,user 对象调用了 dynamicThis 函数,所以内部的 this 指向是 user 对象。

那...第一次函数调用看起来没有任何对象调用它,为什么 this 指向是 window 呢?其实在全局声明 dynamicThis 函数的时候,window 对象就有了 dynamicThis 函数的定义,而 dynamicThis() 语句相当于 window.dynamicThis(),因此内部的 this 指向是 window,且看如下例子证明:

function dynamicThis() {
    console.log(this);
}
console.log(window);

f99df496c3d86f6058ac356864b136e.png

可以看到 window 对象内部已经有 dynamicThis 函数定义了。

既然普通函数调用的 this 指向对象是不确定的,那怎么让它变成确定的呢,或者说怎么让 this 指向变成我们理想的对象呢?那就是接下来要详细介绍的 callapplybind 函数了。

call 函数

call 函数的第一个参数是要改变函数内部 this 指向的对象,后面的每一个参数是和函数正常调用时传递的参数意义一样。对于上面的例子,我们可以改写成这样子,让 this 指向变得统一:

function dynamicThis() {
  console.log(this);
}

const user = {
  name: 'jack',
  dynamicThis
}

dynamicThis.call(user);  // user 对象
user.dynamicThis(); // user 对象

或者

dynamicThis();  // window 对象
user.dynamicThis.call(window); // window 对象

上述的 call 函数只传入了第一个参数,因此我们需要再举一个例子来消化 call 函数的完整用法:

const user1 = {
    name: 'jack'
};

const user2 = {
    name: 'tom'
};

function introductionSelf(age, fav) {
    console.log(`My name is ${this.name}, and ${age} years old.`);
    console.log(`I like ${fav}.`)
}

introductionSelf.call(user1, 18, 'football');
// My name is jack, and 18 years old.
// I like football.

introductionSelf.call(user2, 20, 'basketball');
// My name is tom, and 20 years old.
// I like basketball.

在这个例子中,声明一个用来 自我介绍 的函数,需要姓名(name),年龄(age),爱好(fav)三类信息,其中姓名通过函数内部的 this 指向的对象的 name 属性获得,年龄和爱好通过传递函数参数获得。

实现原理

经过上面的实例代码,我们已经基本熟悉 call 函数的用法,它其实主要做了两件事:

  1. 改变原函数内部的 this 指向
  2. 将第二个参数之后的每一个参数传递给原函数

对于第一点,我们在说明 this 指向问题的时候,总结出了一句话:哪个对象调用了这个函数,函数内部的 this 就指向它。那就可以把原函数作为 call 函数的第一个参数的属性方法,再用这个参数去调用原函数,从而就改变了原函数的 this 指向。

对于第二点,我们只需要在实现第一点的基础上,将 call 函数剩余的参数传递给原函数即可。

call 函数原理代码如下:

Function.prototype.myCall = function(firstArgObj, ...otherArgs) {
    // 如果不是函数调用myCall, 则抛出错误
    if(typeof this !== "function") {
        throw new Error("not function");
    }
    // 如果第一个参数不传或者为 null, undefined,则将 this 指向设置为 window
    firstArgObj = firstArgObj || window;

    const id = Symbol();  // 核心代码
    firstArgObj[id] = this;  // 核心代码
    const res = firstArgObj[id](...otherArgs);  // 核心代码
    delete firstArgObj[id];  // 核心代码
    return res;  // 核心代码
}
var basic = 3;
const obj = {
    basic: 4
};
function sum(a, b) {
    return this.basic + a + b;
}
sum(1, 2); // 6
sum.myCall(obj, 1, 2); // 7

核心代码解释:

  1. const id = Symbol(); ,生成 Symbol 值的属性名,该属性名和任何一个属性名都不相等,即使第一个参数中也有一个 Symbol 值的属性名,它们也不相等,即 Symbol() ≠ Symbol()。所以就可以避免覆盖第一个参数中原来的属性。
  2. firstArgObj[id] = this;,原函数(this)作为 myCall 函数的第一个参数的属性方法,this 表示原函数,因为是原函数调用 myCall 的,所以 myCall 函数内部的 this 指向原函数。
  3. const res = firstArgObj[id](...otherArgs);,用第一个参数去调用原函数,并将第二个以后的参数传递给原函数,保存结果。
  4. delete firstArgObj[id];,调用完函数后,将第一步创建的属性删除,不然污染了第一个参数对象。
  5. return res;,最后,将函数调用的结果返回。

温习提示:apply 函数和 bind 函数实现原理是在 call 函数实现原理的基础上进行修改的,只要弄明白了 call 函数实现原理,apply 函数和 bind 函数的实现代码也是信手拈来。

apply 函数

apply 函数只有两个参数,第一个参数是要改变函数内部 this 指向的对象,第二个参数是一个数组或者类数组,数组的每一个元素表示传递给原函数的参数。它的用法如下:

求数组中的最大值或最小值

const arr = [2, 7, 1, 3, 10, 5];
Math.max.apply(null, arr); // 10
// 相当于 Math.max(2, 7, 1, 3, 10, 5)
Math.min.apply(null, arr); // 1
// 相当于 Math.min(2, 7, 1, 3, 10, 5)

向原数组一次性添加多个元素

const arr = [2, 3, 1, 8];
arr.push.apply(arr, [4, 9, 10]);
console.log(arr); // [2,3,1,8,4,9,10]

实现原理

apply 函数其实就是把 call 函数的第二个参数以后的每一个参数都放到数组里。所以对于 call 函数的原理代码,只需要修改以下地方:

  1. 将函数定义的参数部分 function(firstArgObj, ...otherArgs) 改为 function(firstArgObj, otherArgs)
  2. 判断 otherArgs 参数是否是数组,如果是,则用扩展运算符把这些参数传递给原函数;否则直接调用函数。
Function.prototype.myApply = function(firstArgObj, otherArgs) {  // 修改代码部分
    // 如果不是函数调用myApply, 则抛出错误
    if(typeof this !== "function") {
        throw new Error("not function");
    }

    firstArgObj = firstArgObj || window;
    let res = null;
    const id = Symbol();
    firstArgObj[id] = this;

    // 修改代码部分
    if (otherArgs instanceof Array) {
        res = firstArgObj[id](...otherArgs);
    } else {
        res = firstArgObj[id]();
    }
    
    delete firstArgObj[id];
    return res;
}

var basic = 3;
const obj = {
    basic: 4
};
function sum(a, b) {
    return this.basic + a + b;
}
sum.myApply(obj, [1, 2]); // 7
sum(1, 2); // 6

bind 函数

bind 函数的参数形式和 call 函数一样,但是 bind 函数返回的是一个新的函数,需要再次调用新函数,才相当于去调用了原函数。注意,既然 bind 函数返回的是新函数,那么这个新函数又可以传递参数,这些参数仍然是传递给原函数的。比如:

function mySum(a, b) {
    return this.basic + a + b;
}
const item = {
    basic: 5
};
const sumFn = mySum.bind(item, 3);
sumFn(4); // 5 + 3 + 4 = 12

bind 函数返回的新函数中,this 指向分两种情况:

  1. 以普通函数的形式调用这个新函数时,this 指向的对象是 bind 函数的一个参数,和 callapply 函数一样。
  2. new 操作符形式调用这个新函数创建实例的时候,this 指向是这个实例。

举个例子来说明一下第二种情况:

// 用来将 name 和 age 信息保存在对象上
function userMsg(name, age) {
    this.name = name;
    this.age = age;
}

userMsg.prototype.introduction = function () {
    return this.name + ", " + this.age;
};
const emptyObj = {};
const UserFunc = userMsg.bind(emptyObj, "jack");

// new 的实例对象
const userInstance = new UserFunc(20);

emptyObj.x;  // undefined
emptyObj.y;  // undefined
emptyObj.introduction();  // 报错,emptyObj.introduction is not a function

userInstance.name;  // jack
userInstance.age;  // 20
userInstance.introduction();  // jack, 20

可以看到,bind 函数的第一个参数对象,没有任何属性和方法,而数据都保存到了 new 操作符创建的实例上,并且 new 操作符的实例对象是可以调用原函数中原型对象的方法,即:实例对象继承了原函数中原型对象的方法。

实现原理

搞明白了 bind 函数的用法之后,跟 call 函数对比有两个不同的点:

  1. 返回的不是原函数的调用结果而是一个新的函数。
  2. 新增以 new 操作符调用新函数的形式,并且实例对象继承原函数中原型对象的方法。

再回想一下 call 函数源代码的实现,我们首先要修改的是 return 语句不再返回原函数的结果,而是一个函数,并且是在这个新函数里面调用原函数,最后再将结果返回。修改代码如下:

Function.prototype.myBind = function(firstArgObj, ...otherArgs) {
    // 如果不是函数调用 myBind, 则抛出错误
    if(typeof this !== "function") {
        throw new Error("not function");
    }
    
    firstArgObj = firstArgObj || window;
    const id = Symbol();
    const _this = this;  // 新增代码,将原函数保存在名为 _this 的常量中
    
    // 在新函数里面调用原函数,并将结果结果返回
    const bindFn = function(...innerArgs) {
        firstArgObj[id] = _this;
        const res = firstArgObj[id](...otherArgs, ...innerArgs);
        delete firstArgObj[id];
        return res;
    }
    // 返回的是一个函数
    return bindFn;
}

前面说过,新函数也可以接收参数,所以再最后调用原函数的时候,应该把 bind 函数接收的参数和 新函数接收的参数一起传递给原函数,也就是语句 firstArgObj[id](...[...otherArgs, ...innerArgs]) 的作用。

上述代码已经实现 bind 函数一半的功能了,接下来要实现另一半功能:新增以 new 操作符调用新函数的形式,并且实例对象继承原函数中原型对象的方法。

如果是以 new 操作符调用新函数的话,那么 bindFn 函数内部的 this 指向是 new 的实例对象,这时,就要把原函数作为 new 实例对象的属性方法去调用,所以在 bindFn 函数内部应该有以下这段代码:

const bindFn = function(...innerArgs) {
    //...
    
    this[id] = _this;
    const res = this[id](...otherArgs, ...innerArgs);
    delete this[id];
    return res;
    
    //...
}

这样就能将原函数内部的 this 指向改为 new 的实例对象。

那不能平白无故地就执行这段代码,我们要为这段代码增加判断——如果 bindFn 内部的 thisbindFn 的实例时,才执行这段代码,为此,我们需要借助 instanceof 运算符进行判断:

const bindFn = function(...innerArgs) {
    //...
    if (this instanceof bindFn) {
        this[id] = _this;
        const res = this[id](...otherArgs, ...innerArgs);
        delete this[id];
        return res;
    }
    //...
}

那怎么让实例对象去继承原函数中原型对象的方法呢?答案是让 bindFn 函数的原型对象(prototype)的原型 指向 原函数的原型对象,即:

bindFn.prototype = Object.create(_this.prototype)

至此我们就实现 bind 函数的所有功能,再结合刚开始写的 myBind 代码,得到 bind 函数最终的原理代码为:

Function.prototype.myBind = function(firstArgObj, ...otherArgs) {
    // 如果不是函数调用 myBind, 则抛出错误
    if(typeof this !== "function") {
        throw new Error("not function");
    }
    
    firstArgObj = firstArgObj || window;
    const id = Symbol();
    const _this = this;
    
    const bindFn = function(...innerArgs) {
        let res = null;
        
        // new 操作符调用 bindFn 函数
        if (this instanceof bindFn) {
            this[id] = _this;
            res = this[id](...otherArgs, ...innerArgs);
            delete this[id];
            if (res && (typeof res === 'object' || typeof res === 'function')) {
                return res;
            }
            return this;
        }
        
        // 普通函数形似调用 bindFn
        firstArgObj[id] = _this;
        res = firstArgObj[id](...otherArgs, ...innerArgs);
        delete firstArgObj[id];
        return res;
    }
    // 继承原函数的共享属性和方法
    bindFn.prototype = Object.create(_this.prototype)
    // 记得把 constructor 指回 bindFn 构造函数
    bindFn.prototype.constructor = bindFn
    return bindFn;
}

总结

  1. 对于普通函数而言,哪个对象调用了这个函数,函数内部的 this 就指向它。
  2. call 函数的第一个参数是要改变函数内部 this 指向的对象,后面的每一个参数都是原函数的参数。实现 call 原理代码的关键在于,使用第一个参数对象去调用原函数,这样就能改变原函数内部的 this 指向。
  3. apply 函数 和 call 函数的区别在于,apply 函数只接收两个参数,第一个参数并无差别,第二个参数是一个数组或类数组,相当于对 call 函数的第二个参数以后的每个参数放到了数组里。只需要对 call 函数原理代码的传参形式和调用原函数的方式进行修改即可。
  4. bind 函数 和 call 函数的区别在于,返回的不是原函数调用的结果,而是一个新的函数,再调用新函数才返回结果,并且当用 new 操作符调用新函数时,原函数的内部 this 指向不再是 bind 函数传入的第一个参数,而是 new 实例对象,该实例对象继承原函数中原型对象的方法。这也是实现 bind 函数原理代码的关键所在。