
上周给几个准备校招的朋友过了一遍手写 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用表格看几点核心对比对比维度callapply手写时关注点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. 处理 thisArgnull/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 坑 1context 是冻结对象赋值会失败这是手写方案和原生方案最本质的差别说句大实话手写版在某些极端情况下不可能 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 和 bind5.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 也各实现一遍原理类知识早晚会从“背过答案”变成“长在身上”。