上周给几个准备校招的朋友过了一遍手写 apply,发现一个很有意思的现象:凡是能顺利写出来的,都是真正想通了 this 绑定的人;凡是卡住的,基本都被“怎么让这个函数用我指定的 this 去调用”这句话绕晕了。这道题在面试里出现频率高,不是因为它难,而是它把 JavaScript 的函数调用机制、原型链、参数处理、异常清理全部串在了一块。
这篇文章就拿 call 当对照组,把 apply 从最小实现到健壮版本逐步拆开。我不绕弯子,直接给你看完整结论:apply 和 call 的区别只有参数传入方式不同,其余机制完全同源。理解了这一点,手写 apply 的难度会瞬间下降一个量级。文章里我会把每一行代码为什么这么写、常见边界怎么处理、动手测试会遇到哪些坑都展开讲,适合正在准备面试的开发者,也适合想弄明白 this 到底怎么工作的人。
1. 动手之前,先搞清楚 this 到底是怎么决定的
1.1 四条绑定规则:重点先看“方法调用”
JavaScript 里的 this 不是在定义函数时确定的,而是在函数被调用的一瞬间,由调用方式决定。日常见到的 this 绑定规则,可以浓缩成四种:
- 默认绑定:直接裸调
fn(),非严格模式下 this 指向全局对象,严格模式下是undefined。 - 隐式绑定:通过对象属性调用
obj.fn(),this 指向obj。 - 显式绑定:用
fn.call(obj)、fn.apply(obj)、fn.bind(obj)手动指定。 - new 绑定:构造函数被
new调用时,this 指向新创建的对象。
四条规则的优先级是:new > 显式绑定 > 隐式绑定 > 默认绑定。
这四条里,手写 apply 真正用到的是“隐式绑定”。有个现象很多人天天见却没回过味来:obj.method()之所以 this 指向 obj,是因为函数调用时,点号左边的对象会作为基准,把 this 绑定到它身上。
这不是什么魔法,而是 JS 语言规范对“成员表达式调用”的固定处理。既然引擎面对obj.method()会把 this 指向 obj,那我们能不能反过来用这个机制:把任何一个函数临时变成某个对象的属性,再用obj.fn()的形式调用,this 是不是就自然等于那个对象了?
这就是手写 apply 的底层逻辑。
1.2 手写 apply 的本质:把函数“借”给对象当方法用一次
理解了隐式绑定,apply 的实现思路其实一句话就能说清:把被调用的函数临时挂到指定对象上,当作对象的方法调用一次,调用完立刻删除这个临时属性。
写得更流程化一点:
- 拿到目标函数,它此时是
this。 - 把目标函数赋值到
context对象上,例如context.fn = this。 - 用
context.fn(...args)的方式调用,this 自动就是context。 - 拿到返回值后,删除
context.fn这个临时属性。 - 把返回值抛给调用者。
这个过程可以类比成“借邻居的厨房做一顿饭”:你手里有烤箱(目标函数),邻居家有厨房(context 对象),你把烤箱搬进邻居家、固定在台面上(context.fn = this),做完菜、取走菜(拿到 return 值),再把烤箱搬走(delete 临时属性)。
有一个细节必须留意:整个过程中临时挂上去的那个属性,不能被原对象已有的属性“撞车”,也不能污染原对象。所以后来 ES6 时代我们普遍用Symbol生成唯一 key,而不是随便起一个字符串名字。这个是后话,第 3 章会专门展开。
现在再回头看为什么手写 apply 要写在Function.prototype上。因为 apply 是所有函数共用的能力,挂在原型上,任何一个函数都可以通过fn.myApply(...)访问到,此时方法内部的this就是调用它的那个函数。如果你想放在普通函数里也行,但那就无法做到“任何函数都能直接用”,所以原型扩展是唯一合理位置。
2. call 与 apply 的差异:一个传参数列表,一个传参数数组
2.1 原生行为对比:代码几乎一样,就差一手参数展开
很多人会把 call 和 apply 当成两个孤立的 API 去背,其实它们的内部骨架完全一致。用同一个加法函数举例:
function sum(a, b, c) { return a + b + c; } // call:参数一个个列出来 sum.call(null, 1, 2, 3); // 6 // apply:参数放在数组或类数组里 sum.apply(null, [1, 2, 3]); // 6用表格看几点核心对比:
| 对比维度 | call | apply | 手写时关注点 |
|---|---|---|---|
| thisArg 绑定 | 支持 | 支持 | 两者一致,处理 null/undefined 的方式相同 |
| 参数传入形式 | 不定长参数列表 | 数组 / 类数组对象 | apply 多做一步“展开数组” |
| 是否立即执行 | 是 | 是 | 返回值都来自原函数的 return |
| 典型场景 | 参数个数固定 | 参数动态生成、存在数组里 | ES6 之前 apply 几乎无法被替代 |
在 ES5 时代,apply 有一个杀手级场景:把一个数组元素批量展开传给函数。Math.max.apply(null, [1, 2, 3])是当年取数组最大值的标准写法,因为Math.max不接收数组、只接收不定长参数,而 apply 恰好能把数组“拆”成一个个参数送进去。同样的场景还有arr1.push.apply(arr1, arr2)。
到了 ES6,展开运算符出来之后,这两个场景都能直接写成Math.max(...[1, 2, 3])和arr1.push(...arr2)。所以现在 apply 在日常代码里的出镜率确实低了不少,但面试题依然爱考,因为它考的不是“你会不会用”,而是“你是否理解函数调用的本质”。
2.2 为什么“基于 call 对比”是吃透 apply 的最短路径
这里有个很关键的理解:call 本质上可以被 apply 实现,反之则不行(至少在 ES5 下不行)。
先看正向推导。手写一个 myCall 只需要一行:
Function.prototype.myCall = function (context, ...rest) { return this.myApply(context, rest); };为什么能这么写?因为 rest 参数收集到的本身就是数组,把它直接作为 apply 的第二个参数,就完成了“参数列表 → 参数数组”的转换。
反过来假如先手写 call,再想从 call 推导出 apply,就卡住了:call 接收的是arg1, arg2, arg3...这种独立参数,你当然可以用arguments拿到伪数组,但问题是 ES5 没有展开运算符,你无法把一个数组“拆”成独立参数传给函数。也就是说,apply 比 call 多绕了一道“参数数组展开”,这个操作在 ES6 之前没有语法层面的原生支持,只能靠 apply 自己实现。
所以标题说“5 分钟吃透”的底气从这里来:你只要记住 call 的 this 绑定机制,apply 只是在参数处理上多了一个数组展开动作,其余完全没有区别。当你把差异定位到这一点,手写 apply 就不是背代码,而是顺理成章地把 call 的实现延伸一下。
3. 手写 myApply:从最小实现到健壮版
3.1 最小实现:核心逻辑只有几行
先把核心逻辑跑通。有一版“最小实现”长这样:
Function.prototype.myApply = function (context, args) { context = context == null ? globalThis : Object(context); const fnKey = Symbol('临时方法'); context[fnKey] = this; const result = context[fnKey](...args); delete context[fnKey]; return result; };这段代码已经能 work 于大部分常规情况:指定 this、传数组参数、返回原函数结果、清理临时属性。
但注意,它还远不够健壮。至少存在三个明显问题:
- 如果
args不是数组,比如传了undefined或者类数组对象,...args行为就会偏离原生 apply。 - 如果原函数执行过程中抛出异常,
delete context[fnKey]永远不会执行,临时属性残留。 - 如果调用 myApply 的
this根本不是函数,代码不会给出明确报错。
这些问题不是考试刁难,而是真实环境里迟早会撞上的边界。所以下面给出完整版。
3.2 健壮版:逐行说明“为什么这么写”
Function.prototype.myApply = function (context, argsArray) { // 1. 保证调用者是一个函数 if (typeof this !== 'function') { throw new TypeError(this + ' is not callable'); } // 2. 处理 thisArg:null/undefined 指向全局对象,原始类型装箱 const ctx = context == null ? globalThis : Object(context); // 3. 生成唯一 key,避免覆盖原对象已有属性 const fnKey = Symbol('临时方法'); // 4. 把当前函数挂到目标对象上 ctx[fnKey] = this; // 5. 参数数组标准化:null/undefined 视为无参,类数组转为真数组 let argsList; if (argsArray == null) { argsList = []; } else if (typeof argsArray === 'object' || typeof argsArray === 'function') { argsList = Array.from(argsArray); } else { throw new TypeError('CreateListFromArrayLike called on non-object'); } // 6. 调用原函数,无论是否抛错都要清理临时属性 try { return ctx[fnKey](...argsList); } finally { delete ctx[fnKey]; } };逐段拆解:
第一步的类型检查。Function.prototype.myApply这个方法本身可能被任何值调用,比如你把一个普通对象当成函数来用:const fake = {}; fake.myApply(...),此时方法内部的this是{},不是函数。原生 apply 面对这种情况会抛TypeError,我们手写版也应该给出一致的行为。这也解释了为什么很多手写题会包含typeof this !== 'function'判断——它不是可有可无的防御代码,而是为了向原生行为对齐。
第二步的 context 处理。这里用了context == null这种写法,注意是宽松等于,它一次性覆盖了context === null和context === undefined两种情况。为什么它们要指向全局对象?因为原生 apply 的非严格模式语义就是如此:显式传入 null 或 undefined 时,this 默认绑定全局对象。globalThis是 ES2020 之后跨环境的通用全局对象,在浏览器里是window,在 Node 里是global。
Object(context)这一步解决的是原始类型问题。如果你myApply('hello', ...),Object('hello')会返回一个 String 包装对象,这样属性才能挂上去。这一步不是过度设计,如果你不加,字符串、数字、布尔值这些原始类型会让你踩进一个隐蔽的坑,第 4 章会专门演示。
第三步的 Symbol key。为什么不用'__fn__'这种字符串?两个原因。第一,普通字符串可能和对象已有的属性撞车,比如对象本身就有__fn__属性,那你就把人家覆盖了。第二,Symbol 每次创建都是全新的、唯一的,不可能和已有属性撞名字,还能保证delete时删的一定是自己刚挂上去的那个属性。
顺带补充一个历史背景:ES5 时代没有 Symbol,那时候手写 call/apply 的 polyfill 通常用随机字符串模拟:
const key = '__tempFn_' + Date.now() + '_' + Math.random();这种做法只能“大概率不冲突”,并不能绝对保证。Symbol 出来后这个隐患才算彻底解决。所以现在做手写题,用 Symbol 是比随机字符串更正确的方案。
第五步的参数处理。原生 apply 的第二个参数,规范层面的定义是“数组或类数组对象”,也就是要有length属性和数字下标。arguments对象、DOM 节点列表、{ 0: 'a', 1: 'b', length: 2 }这类结构,原生 apply 都能处理。Array.from恰好能把类数组转成真数组,这样后面展开时就能直接使用...argsList。
这里要特别提一下:Array.from对字符串也有效,但原生 apply 接收原始字符串会抛TypeError。所以完整版里先用typeof argsArray === 'object' || typeof argsArray === 'function'做了前置校验,把字符串、数字这类原始值挡在门外,行为才能和原生对齐。
第六步的 try/finally。这是很多手写教程不讲、但实际价值很高的细节。如果原函数执行到一半抛错,没有 finally 的情况下delete ctx[fnKey]会跳过,临时属性会永远留在那个对象上。用try/finally保证无论函数是否正常返回,清理动作都会执行。你可以在控制台这样验证残留问题:
const obj = {}; function boom() { throw new Error('xx'); } // 没有 finally 的实现 try { boom.myApplyNoFinally(obj, []); } catch (e) {} console.log(Object.getOwnPropertySymbols(obj)); // 能看到残留的 Symbol 属性有 finally 的版本再测,结果会是空数组。
3.3 为什么不直接写context[fnKey](...args)而要多包一层
有人可能会问:既然 args 本来就是数组,Array.from不是多余吗?这里必须考虑到 apply 的第二个参数不一定是数组。真实的调用可能是:
add.myApply(obj, arguments); // 函数内部拿到的 arguments 类数组 add.myApply(obj, { 0: 1, 1: 2, length: 2 }); add.myApply(obj, undefined); // 不传第二个参数如果只写...args,参数为 undefined 时直接展开是允许的(结果相当于无参调用),但遇到没有length的对象时,展开行为就不受控了。为了对原生行为负责,参数标准化的步骤不该省。
4. 实测中的三个坑:手写版与原生 apply 差在哪
4.1 坑 1:context 是冻结对象,赋值会失败
这是手写方案和原生方案最本质的差别,说句大实话:手写版在某些极端情况下不可能 100% 等同原生。原生 apply 是引擎内部操作,不需要真的往对象上挂属性;我们的模拟方案必须写属性,那就绕不开对象是否可扩展、是否冻结的问题。
const frozenCtx = Object.freeze({ x: 1 }); function getX() { return this.x; } getX.apply(frozenCtx); // 原生:1,正常工作 getX.myApply(frozenCtx, []); // 手写:严格模式下抛 TypeError原因很简单:ctx[fnKey] = this试图给冻结对象新增属性,严格模式下直接报错。想完全模拟原生行为,就得彻底换思路,比如用WeakMap做映射、把函数和 context 关联起来,不往对象上挂任何属性。这种实现更接近原生的“旁路”思路,但底层原理就不再是“临时借用方法调用”了。作为面试和原理理解,知道这个边界就足够了——真实业务里你不会给冻结对象调 apply。
4.2 坑 2:原始类型 context 不装箱,属性根本挂不住
这个坑比上一个隐蔽得多。看这段常见错误写法:
Function.prototype.badApply = function (context, args) { const key = Symbol('fn'); context[key] = this; // 假设 context 是 'hello' return context[key](...args); };当你执行someFn.badApply('hello')时,'hello'是一个原始字符串。第一行的context[key] = this确实会执行,但这里有个微妙的机制:点运算符访问原始值时,会临时创建一个包装对象,属性实际上赋值到了这个临时对象上。到了第二行,再访问context[key]时,字符串又临时创建了一个新的包装对象,这个新对象上没有你刚挂的属性,于是调用直接失败。
这在非严格模式下不会报错,但结果永远是undefined is not a function,而且特别难排查。解决办法就是前面说的Object(context):先把原始值装箱成同一个包装对象,然后在同一个对象上完成赋值、调用、删除。
const ctx = context == null ? globalThis : Object(context);Object('hello')返回的是字符串包装对象,后续所有属性操作都在这个对象上,问题自然解除。
4.3 坑 3:第二个参数不是数组,行为就跑偏
原生 apply 对第二个参数有一套完整的类型校验:null/undefined 接受,数组和类数组对象接受,其他类型抛TypeError。手写版很容易把校验漏掉。
比如你传一个数字5给 apply 的第二个参数,原生直接报错;手写版如果简单写成context[fnKey](...args),...5会静默展开成空参数列表,没有任何异常提示。这类行为差异在单元测试里很容易漏掉,所以完整版里前置的typeof argsArray检查是必要的。
还有一个容易忽略的类数组边界:Array.from会把字符串转成字符数组,但原生 apply 对原始字符串是拒绝的。完整版用typeof argsArray === 'object' || typeof argsArray === 'function'把字符串挡在外面,才算和原生对齐。
4.4 附带提醒:不要直接改写 Function.prototype.apply
练习归练习,生产环境里不要去覆盖原生Function.prototype.apply。很多框架内部的工具函数、第三方库的兼容层都依赖原生行为,你覆盖之后轻则行为不一致,重则导致运行时报错。
想验证自己的实现,挂一个独立名字的myApply最安全。如果你真要写 polyfill,也要严格对齐规范,并且把它放在业务代码加载之前,同时保留对原生方法的引用以便降级。不过说实话,以现在浏览器的兼容性,apply polyfill 的应用场景已经非常小了,它更多是一个学习道具。
5. 一举三得:从 myApply 瞬间推导 call 和 bind
5.1 call:真的只需要一行
前面已经提过,myCall 可以基于 myApply 直接实现:
Function.prototype.myCall = function (context, ...rest) { return this.myApply(context, rest); };这里能成立的原因不复杂:myApply 的第二个参数是数组,而 rest 收集到的正好是真数组。你甚至可以认为 call 就是 apply 的“语法糖”——只是把“整理数组”这一步交给了调用者。
实测一下:
const obj = { base: 10 }; function add(a, b) { return this.base + a + b; } console.log(add.myCall(obj, 1, 2)); // 13 console.log(add.myApply(obj, [1, 2])); // 13两个结果都是 13。这就很有意思:你已经在这篇文章里一口气手写了两道高频面试题。
5.2 bind:理解原理,不追求当场手写完整版
bind 和 call/apply 的最大区别是不立即执行,它返回一个新函数,等调用时再把参数合并好传进去。最小实现长这样:
Function.prototype.myBind = function (context, ...presetArgs) { const fn = this; return function (...laterArgs) { return fn.apply(context, presetArgs.concat(laterArgs)); }; };可以看到,bind 内部调用了 apply 完成最终调用。所以如果你把 apply 吃透了,bind 的思路也就不难理解。但要注意,完整版 bind 还涉及“返回的函数可以被 new 调用,此时 this 绑定失效”以及原型链处理,复杂度明显更高。面试一般不会要求现场完整手写 bind,能把最小实现讲明白、说出和 apply 的关系,已经足够说明你掌握了这个家族的核心机制。
5.3 自己验证 myApply:一套可以直接跑的测试用例
写完之后怎么确认自己没写错?我建议至少跑一遍下面这些用例:
// 1. 基本功能:this 绑定 + 数组传参 const obj = { base: 10 }; function add(a, b) { return this.base + a + b; } console.log(add.myApply(obj, [1, 2])); // 13 // 2. 不传第二个参数:不报错,等价于无参调用 function getContext() { return this; } console.log(getContext.myApply(obj) === obj); // true // 3. null / undefined context:非严格模式下 this 指向全局对象 function checkThis() { return typeof this; } console.log(checkThis.myApply(null)); // 'object' console.log(checkThis.myApply(undefined)); // 'object' // 4. 类数组对象作为第二个参数 console.log(add.myApply(obj, { 0: 1, 1: 2, length: 2 })); // 13 // 5. 原函数抛错时,临时属性仍然清干净 function boom() { throw new Error('boom'); } try { boom.myApply(obj, []); } catch (e) {} console.log(Object.getOwnPropertySymbols(obj)); // [] // 6. 非函数调用 myApply 时抛 TypeError const notFunc = {}; try { notFunc.myApply(obj, []); } catch (e) { console.log(e instanceof TypeError); // true }这六组用例基本覆盖了核心行为、边界条件和异常清理。你在本地环境跑一遍,全绿就说明这个手写版立住了。
最后分享一个我反复跟人讲过的体会:手写 apply 这件事,最大的收获不是“记住了一段模板代码”,而是你因此把 this 绑定的直觉练出来了。我现在看代码,看到fn()就知道 this 大概率是全局或 undefined,看到obj.fn()就知道 this 必然是 obj,这些都是当年反复手写 call/apply 之后才自然形成的条件反射。建议你在跑完上面测试之后,顺手用同样的思路把手写 call、手写 bind 也各实现一遍;原理类知识,早晚会从“背过答案”变成“长在身上”。