JavaScript 学习笔记:手写 call/apply 的实现与进阶
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", "!"); // Hello, Muyu!
这里做了两件事:
-
把
greet函数内部的this临时指向了obj -
立即执行了
greet函数,并传入了"Hello", "!"作为参数
要手写实现一个完整的 call / apply,我们可以巧妙地利用 JavaScript 的隐式绑定规则(谁调用了方法,方法内部的 this 就指向谁,即 obj.fn() 内部的 this 指向 obj)。
所以,一个完整的 call 至少要支持:
-
临时将函数作为目标对象的一个属性
-
立即执行该函数,并传入剩余参数
-
拿到执行结果后,删除该临时属性
-
处理带有返回值的函数
-
兼容
context为null、undefined或基础数据类型的情况 -
确保临时属性名不会覆盖对象原有的属性
二、实现
基础绑定 this
Function.prototype.myCall = function (context) {
// 把调用 myCall 的原函数(this)绑定到 context 的一个新属性上
context.fn = this;
// 通过隐式绑定执行函数
context.fn();
// 用完即焚,保持目标对象整洁
delete context.fn;
};
// ================= 测试用例 =================
function printName() {
console.log(this.name);
}
var obj = { name: "Muyu" };
// 正确用例:成功改变了 this 指向
printName.myCall(obj); // Muyu
// 错误用例:无法传递参数,也没有返回值
function add(a, b) {
return this.x + a + b;
}
var obj2 = { x: 10 };
var res = add.myCall(obj2, 1, 2);
console.log(res); // undefined (而且内部计算也是错的)
这个版本干了什么:
-
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) {
return this.x + a + b;
}
var obj = { x: 10, fn: "我是原有的属性" };
// 正确用例:参数传递成功,且成功拿到了返回值
var res = add.myCall(obj, 1, 2);
console.log(res); // 13
// 错误用例 1:原对象的 fn 属性被覆盖并删除了
console.log(obj.fn); // undefined
// 错误用例 2:当传入的 context 为 null 时,会抛出异常
try {
add.myCall(null, 1, 2);
} catch (error) {
console.log(error.message); // Cannot set properties of null (setting 'fn')
}
这个版本干了什么:
-
使用
...args接收了函数调用时传入的额外参数。 -
在调用
context.fn(...args)时传入了参数,并用result变量接住了原函数的返回值。 -
在删除临时属性后,返回了
result。
存在的问题: 边界情况未处理。首先,原生 call 规定如果第一个参数传入 null 或 undefined,this 默认指向全局对象(浏览器中是 window,Node 中是 global)。其次,如果目标对象本身就有 fn 属性,会导致严重的属性冲突和数据丢失。最后,如果 context 传入的是一个数字或字符串等基础类型,直接挂载属性会失败。
修复边界处理与属性冲突(完善版:包含 call 和 apply)
// 手撕 call
Function.prototype.myCall = function (context, ...args) {
// 1. 处理 null/undefined,默认指向全局对象
// 2. 使用 Object() 包装基础类型(如数字、字符串),使其能挂载属性
var ctx = context === null || context === undefined
? globalThis
: Object(context);
// 使用 Symbol 生成唯一键,绝对不会覆盖对象原有的属性
var fnSymbol = Symbol("fn");
ctx[fnSymbol] = this;
var result = ctx[fnSymbol](...args);
delete ctx[fnSymbol];
return result;
};
// 手撕 apply(逻辑与 call 完全一致,仅接收参数的方式改为数组)
Function.prototype.myApply = function (context, argsArray) {
var ctx = context === null || context === undefined
? globalThis
: Object(context);
var fnSymbol = Symbol("fn");
ctx[fnSymbol] = this;
// 容错处理:如果不传 argsArray,默认传入空数组展开
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"; // 在浏览器全局下模拟
// 正确用例 1:修复了属性冲突,原有的 fn 属性完好无损
testContext.myCall(obj, 1, 2);
console.log(obj.fn); // 不要覆盖我
// 正确用例 2:兼容 null 和 undefined 指向全局对象
// 注:在 Node 环境下 globalThis 没有 value 属性,结果为 undefined;浏览器环境下相当于 window.value
console.log(testContext.myCall(null, "a", "b")); // ['Global Value', 'a', 'b'] (浏览器环境下)
// 正确用例 3:基础类型包装 (this 会变成一个 Number 对象)
function checkType() { return typeof this; }
console.log(checkType.myCall(123)); // "object"
// 正确用例 4:myApply 测试
console.log(testContext.myApply(obj, [1, 2])); // ['myObject', 1, 2]
console.log(testContext.myApply(obj)); // ['myObject', undefined, undefined]
这个版本干了什么:
-
利用
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 的继承模式
-
原型链继承、借用构造函数继承、组合继承。
-
以及目前 ES5 中最完美的寄生组合式继承(这也是 ES6
class extends的底层语法糖)。
-