深入理解与手写 JavaScript 的 call 方法

94 阅读3分钟

在 JavaScript 这门充满魅力与挑战的编程语言里,call方法是一个极为重要的存在。它就像是一把神奇的钥匙,能让我们精准操控函数执行时this的指向,还能顺利地给函数传递参数。今天,咱们就来深入探究一下call方法,不仅要搞懂它的工作原理,还要亲手实现一个属于自己的call方法。

一、call方法是什么

在 JavaScript 的世界里,所有函数都自带一个强大的武器 ——call方法,这个方法被定义在Function.prototype这个原型对象上。这意味着,只要是个函数,就有资格调用call方法。call方法肩负着两个关键使命:一是改变函数内部this的指向,二是调用函数并把参数传递进去

它的语法形式为:function.call(thisArg, arg1, arg2, ...)。这里的thisArg就是用来指定函数执行时this应该指向的值,而arg1, arg2, ... 则是要传递给函数的参数,参数的数量没有限制,可以有好多。

比如说,我们有这样一段代码:

let name = "Trump";

function gretting() {
    return `hello, I am ${this.name}`;
}
const lj = {
    name: "雷军"
};

console.log(gretting.call(lj)); 

在这个例子中,gretting函数内部的this,原本是指向全局对象window(在非严格模式下),但通过call方法,我们把它的指向指定为了lj对象。所以,当gretting函数执行时,this.name就会去lj对象里找name属性,最终输出 hello, I am 雷军

这里有个特殊情况得注意,如果call方法的第一个参数是null或者undefined,在非严格模式下,this会指向全局对象window;但要是在严格模式下,this就是null或者undefined,这时候如果尝试访问this上的属性,就会抛出错误。看下面这个例子:

// 非严格模式
function test1() {
    console.log(this === window); 
}
test1.call(null); 


// 严格模式
'use strict';
function test2() {
    console.log(this); 
}
test2.call(null); 

非严格模式下,test1.call(null)会输出true,因为this指向了window;而在严格模式下,test2.call(null)会输出null,如果在test2函数里尝试访问this的属性,就会报错。

二、手动指定this的指向

当我们调用call方法时,第一个参数thisArg就像一个指挥官,决定了函数执行时this的去向。就像前面的gretting.call(lj),lj这个对象就成了gretting函数执行时this的指向。

再举个例子,假设有一个对象obj,里面有个方法method,我们想在另一个地方调用这个method方法,并且让this指向一个新的对象newObj,这时候call方法就派上用场了:

const obj = {
    message: "Hello from obj",
    method: function() {
        console.log(this.message);
    }
};

const newObj = {
    message: "Hello from newObj"
};
obj.method.call(newObj); 

这段代码执行后,会输出Hello from newObj,因为通过call方法,obj.method执行时this被指定为了newObj,所以this.message访问的是newObj里的message属性。

三、参数一个一个地传

call方法的另一个特点就是参数传递方式很特别,它是一个一个往后传的。比如说,我们有一个简单的加法函数add:

function add(a, b) {
    return a + b;
}
console.log(add.call(null, 3, 5)); 

这里add函数需要两个参数a和b,通过call方法调用时,我们把参数3和5一个一个地传递进去,最终函数返回8。这种参数传递方式,让我们在调用函数时能更灵活地控制每个参数的值。

四、手写call方法的实现思路

现在,我们要挑战一下,自己来实现一个call方法,看看它到底是怎么工作的。

1.给Function.prototype添加myCall方法

因为call方法是所有函数都能调用的,所以我们得在Function.prototype这个原型对象上添加一个自定义的myCall方法,这样所有函数就都能使用我们自己实现的这个类似call功能的方法了。

2.处理this的指向

首先,要判断传入的第一个参数context(也就是要绑定的this值)。如果context是null或者undefined,在非严格模式下,让它指向window就可以了。

3.在context上挂载方法

为了让函数内部的this能指向context,我们可以在context上临时创建一个属性,然后把当前要调用的函数赋值给这个属性。这里为了避免属性名冲突,我们可以利用 ES6 新增的Symbol来创建一个独一无二的属性名。Symbol是一种新的数据类型,它创建出来的值都是唯一的,这样就不用担心会覆盖context原本的属性了。

4.收集并传递参数

把call方法传入的除第一个参数外的其他参数收集起来,然后传递给挂载在context上的函数进行调用。这里我们可以使用 ES6 的rest运算符...来轻松收集这些参数。rest运算符可以把函数的剩余参数收集到一个数组里,非常方便我们后续处理。

5.清理临时属性

调用完函数后,我们要把在context上临时创建的属性删除掉,这样能避免污染context对象。

五、手写call的代码实现

下面就是我们按照上述思路实现的myCall方法代码:

Function.prototype.myCall = function(context,...args){
    if(context === undefined || context === null){
        context = window;
    }
    if(typeof this !== "function"){
       throw new TypeError("Function.prototype.myCall called on non-function");
    }
    // 函数要运行
    const fnKey = Symbol('fn');
  
    context[fnKey] = this;
    // 收集参数
    try {
        const result = context[fnKey](...args);
        return result;
    } finally {
        delete context[fnKey];
    }
 }

我们来测试一下这个myCall方法:

let name = "Trump";
function gretting() {
    return `hello, I am ${this.name}`;
}
const lj = {
    name: "雷军"
};

console.log(gretting.myCall(lj));  

运行这段代码,会输出hello, I am 雷军,这就说明我们自己实现的myCall方法成功了,它真的能像原生的call方法一样改变函数内部this的指向并调用函数。

但是还有一个问题,上面的call函数的实现是没有考虑严格模式的,那如果考虑严格模式,代码应该有怎样的变动。

其实我们只需要多考虑一点,在第三步的处理this指向的时候。在非严格模式下,让它指向window;如果context是原始值(像数字、字符串、布尔值这些),我们得把它转换对应的包装对象(例如new Number(1)、new String('a')、new Boolean(true))。这是因为在 JavaScript 里,函数执行时this必须是一个对象或者nullundefined,如果是原始值,就需要包装一下才能作为this的指向。

下面附上代码:

'use strict';
Function.prototype.myCall = function(context, ...args) {

   if (context === undefined || context === null) {
       context = window; // 非严格模式下自动转为window
   } else {
       context = Object(context); // 严格模式下确保原始值被包装
   }

   if (typeof this !== 'function') {
       throw new TypeError('Function.prototype.myCall called on non-function');
   }

   const fnKey = Symbol('fn');
   Object.defineProperty(context, fnKey, {
       value: this,
       configurable: true
   });

   try {
       const result = context[fnKey](...args);
       return result;
   } finally {
       delete context[fnKey];
   }
}

六、总结

通过手写call方法,我们对 JavaScript 中函数this的绑定机制以及函数参数的传递方式有了更深入的理解。call、apply和bind这三个方法虽然都能改变函数this的指向,但它们各有各的特点和应用场景。在实际开发中,我们要根据具体的需求,灵活选择合适的方法,这样才能写出更高效、更灵活的 JavaScript 代码。希望大家通过这篇文章,对call方法有更深刻的认识。如果有什么问题,欢迎大家在评论区交流。