call 和 apply 是 JavaScript 里非常经典、也非常适合拿来深入理解 this 隐式绑定机制、对象属性操作以及全局作用域这些知识点的方法。
这篇笔记,我会从一个最简单的 call 开始,一步一步实现出一个更接近原生行为的版本,并在最后顺便推导出 apply 的实现,分析每一步为什么还不够完善。
一、call / apply 是什么
call 和 apply 的核心作用,是立即执行一个函数,并在执行时把这个函数内部的 this 临时改变为指定的对象。它们两者的唯一区别在于传参方式不同(call 接收散列的参数,apply 接收一个数组)。
比如:
function greet(greeting, punctuation) {
console.log(greeting + ", " + this.name + punctuation);
}
var obj = { name: "Muyu" };
greet.call(obj, "Hello", "!");
-
把 greet 函数内部的 this 临时指向了 obj
-
立即执行了 greet 函数,并传入了 "Hello", "!" 作为参数
要手写实现一个完整的 call / apply,我们可以巧妙地利用 JavaScript 的隐式绑定规则(谁调用了方法,方法内部的 this 就指向谁,即 obj.fn() 内部的 this 指向 obj)。
二、实现
基础绑定 this
Function.prototype.myCall = function (context) {
context.fn = this;
context.fn();
delete context.fn;
};
function printName() {
console.log(this.name
-
this 就是调用 myCall 的原函数(比如 printName)。
-
我们把它变为了 context(即 obj)的一个临时方法 fn。
-
通过 context.fn() 调用,利用 JS 隐式绑定规则,使得原函数内部的 this 成功指向了 context。
-
执行完后,通过 delete 删除了临时属性,做到“不留痕迹”。
存在的问题: 完全丢失了接收参数的能力,也没有把原函数的执行结果 return 出去。此外,如果我们传入的 context 里面本身就有一个叫 fn 的属性,它会被我们无情地覆盖和删除。
支持参数与返回值
为了保持代码清晰,这里我们借助 ES6 的剩余参数(Rest parameters)和展开语法(Spread syntax)。如果在 ES3/ES5 时代,这里通常会使用 eval('context.fn(' + args + ')') 来实现。
Function.prototype.myCall = function (context, ...args) {
context.fn = this;
var result = context.fn(...args);
delete context.fn;
return result;
};
function add(a, b) {
存在的问题: 边界情况未处理。首先,原生 call 规定如果第一个参数传入 null 或 undefined,this 默认指向全局对象(浏览器中是 window,Node 中是 global)。其次,如果目标对象本身就有 fn 属性,会导致严重的属性冲突和数据丢失。最后,如果 context 传入的是一个数字或字符串等基础类型,直接挂载属性会失败。
修复边界处理与属性冲突(完善版:包含 call 和 apply)
Function.prototype.myCall = function (context, ...args) {
var ctx = context === null || context === undefined
? globalThis
: Object(context);
var fnSymbol = Symbol("fn");
ctx[fnSymbol] = this
-
利用 globalThis (现代 JS 提供的一个标准方式来获取全局对象,兼容浏览器和 Node)处理了 null 和 undefined。
-
考虑到传入数字 1 时直接 1.fn = this 是无效的,利用 Object(context) 对基础数据类型进行了封箱(Boxing)操作。
-
引入了 ES6 的 Symbol 创建了独一无二的属性名,完美解决了覆盖原有对象属性的痛点。
-
顺带实现了 apply,只需处理传入的是数组的情况即可。
存在的问题: 无。这已经是一个非常健壮、且能完美模拟原生行为的 Polyfill 实现了。
三、总结
在一步步完善 call 和 apply 的过程中,实际上深度运用了以下几个 JavaScript 核心机制:
-
隐式绑定 (Implicit Binding):利用 obj.method() 的调用形式,巧妙地欺骗了 JavaScript 引擎,让原本游离的函数在执行时认为自己是属于目标对象的方法,从而改变 this 指向。
-
唯一标识符 (Symbol):理解并实战了 Symbol 最经典的使用场景——作为对象的唯一属性键,避免命名冲突。
-
全局作用域 fallback:理解了在非严格模式下,当上下文丢失(null/undefined)时,JavaScript 是如何将其回退到全局对象(window / globalThis)的。
-
包装对象 (Wrapper Objects):体会到了对基础数据类型(如 String, Number, Boolean)调用对象方法时,引擎底层偷偷进行的 Object() 封箱操作。
如果你想顺着这套思路继续夯实 JavaScript 的底层基础,我建议可以从以下几个方向入手,每一个都可以尝试“手写实现”:
-
手撕 new 操作符
- 核心思路:尝试手写一个
myNew(constructor, ...args) 函数,模拟:创建一个空对象 -> 链接原型链 -> 利用刚才手写的 apply 绑定 this 执行构造函数 -> 处理返回值的全过程。
-
深入防抖与节流 (Debounce & Throttle)
- 进阶挑战:不仅要实现基础版,还要实现带有“首次立即执行”参数的完美版,并在内部使用 apply 来纠正 setTimeout 中丢失的 this。
-
手写简单的 Promise
- 核心思路:从最基础的
resolve / reject 状态切换写起,一直到实现 then 方法的链式调用,这是检验 JavaScript 异步控制流和回调函数功底的最佳试金石。
-
彻底搞透 ES5 的继承模式
)
;
}
var obj = { name: "Muyu" };
printName.myCall(obj);
function add(a, b) {
return this.x + a + b;
}
var obj2 = { x: 10 };
var res = add.myCall(obj2, 1, 2);
console.log(res);
return this.x + a + b;
}
var obj = { x: 10, fn: "我是原有的属性" };
var res = add.myCall(obj, 1, 2);
console.log(res);
console.log(obj.fn);
try {
add.myCall(null, 1, 2);
} catch (error) {
console.log(error.message);
}
;
var result = ctx[fnSymbol](...args);
delete ctx[fnSymbol];
return result;
};
Function.prototype.myApply = function (context, argsArray) {
var ctx = context === null || context === undefined
? globalThis
: Object(context);
var fnSymbol = Symbol("fn");
ctx[fnSymbol] = this;
var result = ctx[fnSymbol](...(argsArray || []));
delete ctx[fnSymbol];
return result;
};
function testContext(a, b) {
return [this.value, a, b];
}
var obj = { value: "myObject", fn: "不要覆盖我" };
var value = "Global Value";
testContext.myCall(obj, 1, 2);
console.log(obj.fn);
console.log(testContext.myCall(null, "a", "b"));
function checkType() { return typeof this; }
console.log(checkType.myCall(123));
console.log(testContext.myApply(obj, [1, 2]));
console.log(testContext.myApply(obj));
JavaScript 学习笔记:手写 call/apply 的实现与进阶