ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

彻底搞懂call、apply、bind:从this机制到手写实现与项目选型

彻底搞懂call、apply、bind:从this机制到手写实现与项目选型 十年前我刚学 JavaScript 的时候就在面试题里遇见过“说说 call、apply、bind 的区别”。当时我背得滚瓜烂熟call 传参数列表、apply 传数组、bind 返回新函数。可真到项目里一用还是会被 this 整得云里雾里。后来带过不少人发现这个问题几乎是前端面试的必考题也是区分“背答案”和“真理解”的试金石。这篇不是给你再抄一遍标准答案而是想把这个知识点从头到尾拆透先说清楚这三个方法到底在解决什么底层问题再讲三者的本质差异接着用边界情况、手写实现来检验理解是否到位最后结合真实项目聊一聊怎么选型。不管是准备面试、阅读源码还是日常开发里遇到 this 丢失这篇都应该能帮上忙。1. 从 this 之谜说起这三个方法究竟在解决什么问题1.1 this 是调用时决定的不是定义时决定的很多初学者对 call、apply、bind 的困惑根源在于对 this 的理解停留在“指这个对象”这样模糊的层面。要真正搞清楚这三个方法的作用必须先接受 JavaScript 里一个非常重要的设计函数的 this 是在调用时被决定的而不是在定义时被决定的。这是什么意思看一个最简单的例子const user { name: 小明, sayHi() { console.log(大家好我是${this.name}); } }; user.sayHi(); // 大家好我是小明 const fn user.sayHi; fn(); // 大家好我是undefined同样一个函数在user.sayHi()这种形式下调用时因为它是作为user这个对象的方法被调用的JavaScript 引擎会把 this 指向user。但当我把方法解构赋值给变量fn然后用fn()这种方式调用时这已经变成了一个普通的函数调用此时 this 不再指向user而是指向全局对象浏览器里是 window——在严格模式下甚至是 undefined。我在带新人时经常会用一个生活化的类比this 就像“现场指挥”。函数定义的时候谁也不知道指挥是谁只有在函数真正开始执行的那一刻JavaScript 引擎才根据“你是怎么调用它的”来确定 this。是obj.method()这样的形式调用this 就是 obj是裸调用method()this 就可能是全局对象或者 undefined。这就是动态 this 的本质。1.2 三个方法解决的就是“this 丢失”的问题理解了 this 是调用时决定的自然就能推导出一个结论在某些情况下this会“丢”。最常见的场景就是事件回调、定时器、异步任务、以及把方法解构出来单独使用。比如const user { name: 小明, sayHi() { console.log(大家好我是${this.name}); } }; setTimeout(user.sayHi, 1000); // 1秒后输出大家好我是undefinedsetTimeout会在 1 秒后把user.sayHi这个函数作为回调调用此时它是一个普通的函数调用this 丢了。这就是 JS 开发中非常经典的“回调丢失 this”问题。call、apply、bind这三个方法核心作用就是在函数调用时显式地指定 this 的指向所以它们统称为“显式绑定”。有了它们我们可以强行告诉引擎“这个函数执行的时候this 就给我指向这个对象别再给我去猜了。”1.3 动态 this 不是 bug而是特性有朋友可能会问为什么 JavaScript 不采用静态 this这样不就永远不会丢了吗这其实和 JavaScript 的设计哲学有关。动态 this 给了函数极大的复用能力——同一个函数可以给不同的对象“借用”这是原型链继承、混入mixin模式、函数式编程里很常见的手段。正因为 this 是灵活的才需要 call、apply、bind 这样的工具去控制它。这个背景理解到位之后再看三个方法的具体区别就比较顺了它们本质上都是“改变 this 指向”的工具只是在使用方式、执行时机上有差异。接下来我们就逐个击破。2. call、apply、bind 三兄弟的分工与本质区别2.1 一张表看清楚三者的核心差异先上一个对比表把核心差异串起来后面再逐个展开对比维度callapplybind执行时机立即执行立即执行返回新函数调用时才执行传参方式逐个列出数组或类数组整体传入逐个列出可分两次传返回值原函数的执行结果原函数的执行结果绑定了 this 的新函数是否修改原函数不改不改不改但新函数 this 被永久绑定经典场景借用方法、临时换 this参数不确定、数组传参事件绑定、柯里化、预置参数从表格能看出来call 和 apply 是一对孪生兄弟它们唯一的区别就是传参方式而 bind 完全不是一个路数它不立即执行函数而是返回一个新函数把 this 和参数“存起来”留到以后调用。来一段直观的代码感受三者差异const userA { name: Alice }; const userB { name: Bob }; function greet(age, city) { console.log(我是${this.name}今年${age}岁来自${city}); } greet.call(userA, 25, 北京); // 我是Alice今年25岁来自北京 greet.apply(userB, [30, 上海]); // 我是Bob今年30岁来自上海 const greetBob greet.bind(userB, 28); greetBob(深圳); // 我是Bob今年28岁来自深圳注意最后一行的用法bind的时候传入了userB和28返回了一个新函数greetBob等到真正调用时再传剩余参数深圳。这就是它和 call/apply 最大的不同——不立即执行先绑定留待后用。2.2 call 的典型用法借用方法与临时换主call 最典型的场景是“借用方法”。JavaScript 里很多方法不是某个类型私有的而是可以“借”给其他对象用的。最经典的例子是用数组的方法处理伪数组比如函数的argumentsfunction list() { // 传统写法借用数组的 slice 方法把 arguments 变成真正的数组 const args Array.prototype.slice.call(arguments); return args.join(-); } console.log(list(a, b, c)); // a-b-c在我刚入行那会儿Array.prototype.slice.call(arguments)几乎算是必背代码。现在有了Array.from和展开运算符这种写法不常见了但在某些说老不老、说新不新的环境里你依然能在源码中遇到它。它的核心逻辑就是arguments不是数组没有slice方法但我可以把数组的slice方法借过来通过 call 把 this 指向arguments让它去“切割”这个伪数组再返回一个真正的数组。call 的第二个典型场景是“临时换主”。比如你在两个对象之间复用某个逻辑const logger { prefix: 【日志】, log(message) { console.log(this.prefix message); } }; const errorLogger { prefix: 【错误】 }; logger.log(系统启动成功); // 【日志】系统启动成功 logger.log.call(errorLogger, 磁盘空间不足); // 【错误】磁盘空间不足这里我们复用了logger.log的逻辑但临时把 this 换成了errorLogger不用再写一遍同结构的函数。在继承和混入模式里Parent.method.call(this, ...)这种写法也经常出现目的就是在当前实例上执行父类的初始化逻辑。2.3 apply 的特殊之处参数以数组形式整体传递apply 和 call 唯一的区别就是参数形式apply 接受一个数组或类数组对象作为第二个参数整体传给函数。最经典的例子是用Math.max求数组的最大值const numbers [10, 5, 78, 33, 99, 1]; const max Math.max.apply(null, numbers); console.log(max); // 99Math.max接收的是多个参数Math.max(10, 5, 78)不是数组。如果数组元素数量不确定没办法一个个列出来apply 就派上用场了——它把整个数组“铺开”作为参数列表传进去。ES6 之后我们有了更优雅的写法Math.max(...numbers)但底层思路是一样的。apply 的另一个不可替代价值在于“参数转发”。写一个通用的包装函数时参数个数不确定收集到的是一整个数组想原封不动传给目标函数用 call 就麻烦了因为你不知道有多少个参数。这时候 apply 是最自然的function wrap(fn) { return function(...args) { console.log(即将调用函数参数个数, args.length); return fn.apply(this, args); // 把收集到的数组整体转发 }; }防抖、节流、柯里化封装这类高阶函数里fn.apply(this, args)几乎是标配。2.4 bind 的独门绝技锁定 this 而不立即执行bind 做的事情可以理解为“给函数定制一个永久性的调用环境”它把 this 和部分参数固定下来返回一个全新的函数。这个新函数什么时候调用、在哪里调用、怎么调用this 都不会变了。最常见的应用是事件回调class Counter { constructor() { this.count 0; this.button document.querySelector(#btn); } setup() { // 如果不 bind点击事件触发时 this 会指向 button 元素而不是 Counter 实例 this.button.addEventListener(click, this.increment.bind(this)); } increment() { this.count; console.log(this.count); } }没有 bind 的话事件系统调用回调时会把 button 作为 thisthis.count就变成 undefined 了。bind(this)把 increment 方法“绑定”到当前实例上事件触发时依然能找到正确的 this。bind 还有个很实用的顺带能力预置参数这也是柯里化的一种形态。写一个通用的请求函数再用 bind 生成特定接口的专属函数function request(api, params) { console.log(请求接口${api}参数, params); } // 预置第一个参数之后只需要传 params const getUserInfo request.bind(null, /api/user/info); const getOrderList request.bind(null, /api/order/list); getUserInfo({ id: 1 }); getOrderList({ page: 2 });这个模式在事件绑定、定时器传参、函数式编程里都很常见。bind 的名字也起得很形象把函数“绑”在一个对象上绑定后就再也拿不下来了。3. 背着答案也容易翻车几个边界情况与易错点3.1 手写实现把三个方法的内部逻辑拆给你看我带过的学员里能把答案背得一字不差的很多但让他们手写一个简易的 bind不少人就卡壳了。其实手写实现这三个方法是检验理解程度的最好方式也是面试里常见的进阶题。我们逐个来。先看 call 的实现思路。核心就三步把目标函数临时挂到指定的 this 对象上、用这个对象去调用函数、调用完把临时属性删掉Function.prototype.myCall function(context, ...args) { // context 为空时的处理非严格模式下 context 拿不到就指向全局对象 context context ?? globalThis; // 用 Symbol 生成一个临时键避免覆盖 context 上的原有属性 const fnKey Symbol(tempFn); // 把当前函数挂到 context 上此时函数的 this 就是 context 了 context[fnKey] this; // 通过对象方法调用的方式调用函数args 自动按位置传参 const result context[fnKey](...args); // 调用完删除临时属性防止污染目标对象 delete context[fnKey]; return result; };这个实现我加了三个细节Symbol 键避免命名冲突、调用完 delete 清理现场、context 为空时回退到全局对象。理解了 call 的本质——“让函数成为目标对象的一个临时方法”——你就能明白为什么 call 能把 this 指过去。apply 的实现和 call 几乎一样唯一的区别是参数第二步是数组Function.prototype.myApply function(context, argsArray []) { context context ?? globalThis; const fnKey Symbol(tempFn); context[fnKey] this; const result context[fnKey](...argsArray); delete context[fnKey]; return result; };bind 稍微绕一点它不立即执行而是返回一个新函数等新函数调用时再把绑定的 this 和参数合到一起传给原函数Function.prototype.myBind function(context, ...bindArgs) { const originalFn this; return function(...callArgs) { // 合并 preArgs 和 调用时传入的参数 return originalFn.apply(context, [...bindArgs, ...callArgs]); }; };这个版本包含了柯里化的效果bind 时传的参数和调用时传的参数会拼在一起作为最终参数。一般的面试问到 bind 实现这个版本基本能过关。但要注意一点标准的 bind 返回的函数如果被当作构造函数用new调用this 会被新对象覆盖new 绑定的优先级比显式绑定更高严格版实现要考虑这个情况。面试里能主动说出这一点水平立刻就拉开了。3.2 箭头函数与 new两个绕不过去的优先级问题先说 new 和显式绑定的优先级。JavaScript 的 this 绑定规则中有个铁律new 绑定优先级高于显式绑定。什么意思看这段代码const obj { name: 对象 }; function Person(name) { this.name name; } Person.call(obj, 小明); console.log(obj.name); // 小明this 指向了 obj const BoundPerson Person.bind(obj); const p new BoundPerson(小红); console.log(p.name); // 小红p 才是 this不是 obj第一次 call 时this 被绑到了 objobj.name 变成了小明。第二次用 bind 之后再 new发生了一件有趣的事虽然 BoundPerson 的 this 被永久绑定到了 obj但new BoundPerson创建了一个新对象 p函数执行时 this 指向了 p而不是 obj。这就是 new 绑定的优先权。第二个绕不过去的点是箭头函数。箭头函数最大的特点之一就是不绑定自己的 this——它没有自己的 this 绑定内部的 this 直接继承外层作用域。这意味着const obj { name: 箭头函数测试, normal: function() { console.log(this.name); }, arrow: () { console.log(this.name); } }; obj.normal(); // 箭头函数测试this 是 obj obj.arrow(); // undefinedthis 是外层作用域的 this比如 window箭头函数的 this 是定义时就确定好的所以你再怎么 call、apply、bind 它都改不了const arrowFn () { console.log(this); // 这里 this 永远等于定义时所在作用域的 this }; arrowFn.call({ name: 试着改我 }); // 输出仍然是原本的 this改不动这也是为什么现代开发中箭头函数越来越流行——它天然避免了 this 丢失问题。但要注意它并不能完全替代 bind因为有些场景需要“调用时才确定 this”比如事件监听、对象方法、原型方法这些场景用箭头函数反而会踩坑。3.3 严格模式下的意外null 与 undefined 的 this 处理差异还有一个细节很多人写代码时没注意给 call/apply/bind 传null或undefined作为第一个参数时this 的指向在严格模式和非严格模式下完全不同。在非严格模式下如果第一个参数是 null 或 undefinedthis 会被自动替换为全局对象浏览器中的 window而在严格模式下文件开头写use strict或者在 ES Module 里this 就是你传进去的 null 或 undefined不会替换。function showThis() { console.log(this 是, this); } showThis.call(null); // 非严格模式this 是 window全局对象 // 严格模式this 是 null这就是为什么很多库的源码里会看到“借 null 求最大值”的写法Math.max.apply(null, arr)。非严格模式下 null 会被替换成全局对象而Math.max内部根本不依赖 this 的值所以传什么都没关系。但如果你写的库要在严格模式下运行或者你自己习惯用严格模式就得小心这个差异了。我给的建议是永远不要依赖“默认绑定”这条隐式规则。要么显式传入一个明确的目标对象要么用??给个兜底值不要赌运行环境是非严格模式。我自己封装函数时习惯性地在开头写context context ?? globalThis这样无论什么模式行为都一样。4. 真实项目里的选型经验什么时候该用哪个4.1 我的个人使用频率排序与理由如果问我在真实项目里哪个用得多我的排序是bind 最多call 次之apply 最少。这不是拍脑袋说的而是由实际场景决定的bind 用在“预绑定 this 稍后调用”的场景最多。事件回调、定时器、防抖节流函数、React 类组件的方法绑定……这些场景都是先把函数准备好等某个事件发生后再调用this 不能在调用时临时指定必须在注册回调前就锁定。所以 bind 的使用频率最高。call 用在“借用方法”和“临时换 this”的场景。虽然 ES6 之后很多借用操作有了更现代的原生写法但在阅读老代码、维护既有系统时call 的痕迹还很重。apply 用得最少因为展开运算符和剩余参数吸收了大量它原本的阵地。但它并没有消失只要涉及“参数整体转发”就绕不开它。4.2 一段实际代码里的三种用法复盘与其干巴巴地说选型不如看一段综合了三种用法的代码。假设我们在写一个简单的工具库里面有个log方法能把数组参数格式化成字符串并加上前缀const logger { prefix: [LOG], formatAndLog(array) { const formatted Array.prototype.join.call(array, - ); console.log(this.prefix formatted); } }; function logWithFixedPrefix(prefix, ...items) { // bind 预置 prefix 参数生成一个专属 logger const specialLogger { prefix }; const boundLog logger.formatAndLog.bind(specialLogger); boundLog(items); } logWithFixedPrefix([ERROR], 磁盘满, 内存不足, CPU高);展开看这里发生了什么Array.prototype.join.call(array, - )join 本来是数组的方法通过 call 借给它用。虽然数组本身就有 join 方法但这里展示的是“借方法”的通用模式类数组对象也能借。logger.formatAndLog.bind(specialLogger)把 formatAndLog 的 this 锁定到 specialLogger 上这样无论什么时候调用 boundLogthis 都稳定指向 specialLoggerprefix 就能正确读取。如果你想把一组参数整体交给一个函数而参数个数不确定那就是 apply 出场的时机function callWithSpread(fn, context, argsArray) { return fn.apply(context, argsArray); }这三种方法放在一个任务里就能明显感觉出来call 是“借别人的工具给当前对象用”apply 是“参数打包整体投递”bind 是“先锁定身份再放出去干活”。方向完全不同选型的依据就是看你卡在哪个环节。4.3 面试官最爱问的追问区别之外的三连问既然提到了面试我把我作为面试官经常追问的三个问题也列出来供大家自查第一问bind 支持柯里化吗支持。bind 不只是绑定 this还能预置参数。你可以在 bind 时传一部分参数调用返回的新函数时传剩余参数最终参数按顺序拼接。这正是手写 bind 时[...bindArgs, ...callArgs]这个合并动作的意义。第二问已经 bind 过的函数还能用 call 再改变它的 this 吗不能。bind 返回的新函数内部实现是靠 apply/call 去调用原函数但它自己并不关心你后续是不是又用了 call。举个例子const objA { name: A }; const objB { name: B }; function greet() { console.log(this.name); } const boundGreet greet.bind(objA); boundGreet(); // A boundGreet.call(objB); // 仍然是 A不是 B因为boundGreet内部已经把 this 写死了你在外面再怎么 call 都只是调用这个“被绑定过的新函数”新函数内部还是按时objA执行的。第三问三个方法会不会修改原函数都不会。原函数定义之后它的代码和 this 绑定行为不会改变。call/apply 只是在“某一次调用”时临时改变了 thisbind 是生成一个新函数原函数始终是原样。这三个追问其实就是对“永久绑定”“临时换 this”“不修改原函数”这些关键性质的具体化测试能回答清楚说明不是只背了定义而是真的理解了内部机制。5. 站在今天的 JavaScript 里重新审视这三个方法5.1 箭头函数普及后bind 的使用场景被压缩了多少ES6 之后箭头函数成了很多场景下替代 bind 的方案。因为箭头函数不绑定自己的 this而是继承外层作用域的 this所以如果你在写回调时能保证外层 this 就是想要的目标直接用箭头函数就够了class Counter { constructor() { this.count 0; } setup() { // 传统写法 document.querySelector(#btn).addEventListener(click, this.increment.bind(this)); // 箭头函数写法 document.querySelector(#btn).addEventListener(click, (e) this.increment(e)); } increment() { this.count; } }箭头函数写法更干净也不需要额外创建绑定函数。但注意它并没有完全取代 bind如果回调函数本身需要被解构出来独立使用或者你拿到的是一个现有的函数引用而不是箭头函数字面量bind 依然不可替代。比如别人传给你一个写好的函数你想让它 this 指过去除了 bind 没有别的办法。5.2 现代替代语法对 apply 经典场景的冲击展开运算符确实改变了 apply 的地盘。以前Math.max.apply(null, arr)几乎是唯一的解法现在Math.max(...arr)一行搞定以前Array.prototype.slice.call(arguments)转数组现在Array.from(arguments)和[...arguments]都更直观。那 apply 是不是可以退休了并不是。有两个场景它依然有独特价值一是“参数个数完全不受控”的转发场景工具函数里经常需要把收到的参数数组整体传给另一个函数直接fn.apply(this, args)最稳妥二是某些特殊环境里展开运算符对超大数组可能会触发参数数量上限的问题apply 也有它的边界它们本质上是同一类限制但如果你在维护兼容旧环境的代码apply 的可控性会更好。5.3 框架源码里的 call/apply/bind为什么它们仍是基石我在读一些框架和库的源码时经常看到这三个方法的身影。拿 React 早期的类组件来说this.handleClick.bind(this)几乎是每个组件里都要写的。Vue 的工具函数里也少不了Array.prototype.slice.call这种借方法的技巧。事件系统、发布订阅、函数式工具库到处都是 bind 和 apply 的影子。这三个方法在 JavaScript 的地基里扎得很深因为它们直接关联着 this 机制这个核心设计。理解了它们很多源码里看起来“怪异”的写法都会变得顺理成章。比如看到toArray(list)返回Array.prototype.slice.call(list)你就知道这是把类数组转正看到fn.apply(this, args)你就知道这是参数转发看到.bind(this)你就知道作者在锁定调用上下文。我个人在带团队时的经验是想验证一个人是不是真的理解了这三个方法别问他区别让他手写一个简易版 bind再解释为什么 bind 过的函数再用 call 改不了 this。能把它拆明白说明对调用链、this 绑定优先级、柯里化都有了直观的体感。如果只是背答案建议打开控制台把本文里的代码段逐个跑一遍再把 bind 的简易版实现自己写出来这个知识点才算真正长在你身上。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表