JavaScript中this指向详解:四种绑定规则与call/apply实战指南 先讲个真实的翻车现场。有次我帮一位刚转前端的A同学排查线上问题他写了个组件在事件回调里想拿某个对象的数据结果控制台清一色的undefined。我让他打印了一下this屏幕上赫然是全局对象。他盯着看了三秒钟来了一句我以为this永远指向定义它的那个对象。这个问题太典型了几乎每个写 JavaScript 的人都绕不过去。this既不是变量也不是属性它的指向完全由函数调用方式决定。很多业务 bug、框架困惑、面试题归根结底都是this指向没搞清。所以我把这块内容整理成一个系列第一篇先把地基打牢围绕this的四种绑定规则、call和apply的核心用法用规则 场景 代码 记忆方法的方式拆开讲配合一张可以自己画出来的思维脑图争取让不同基础的读者一篇就能建立完整的知识框架。这篇文章适合三类人刚学完 JavaScript 基础但一遇到this就发懵的新手写了段时间业务但 debug 半天都是栽在this丢失上的开发者以及正在准备面试、想用最短时间把这块高频考点彻底理清的人。老规矩先讲原理再讲场景最后讲我平时排查这类问题的真实心得。1. 复杂背后其实只有一句话this 是函数运行时的主语很多人背了很多笔记什么谁调用指向谁箭头函数没有 this背得滚瓜烂熟可一换代码场景还是懵。问题不在记忆在于没理解this到底是哪来的。1.1 从执行上下文看 this 的本质JavaScript 代码在运行时会创建一个执行上下文Execution Context它包含三样核心内容变量环境、作用域链、还有this。也就是说this不是某个对象自带的属性而是每次函数被调用时由引擎动态计算出来的一个值。这就能解释为什么同一个函数定义在同一个位置用不同方式调用this指向可以完全不同。比如const obj { name: 示例对象, say() { console.log(this.name); } }; const fn obj.say; obj.say(); // 输出: 示例对象 fn(); // 输出: undefined严格模式下输出 undefined非严格模式指向全局对象函数say从头到尾都是同一个函数但obj.say()和fn()两次调用结果完全不同。区别在哪调用方式不同。this是在函数真正被调用时引擎根据调用表达式决定出来的主语。所谓主语就是这句话里的动作由谁来发。1.2 关键看调用位置别看定义位置我见过太多人 debugthis时盯着函数定义的地方看看半天也看不出问题。方向就错了。判断this指向第一件事永远是找到这个函数被调用的位置然后去套调用规则。举个例子下面这段代码this到底是谁const user { name: 小明, greet() { return function () { console.log(this.name); }; } }; user.greet()(); // 输出什么答案是undefined非严格模式下是全局对象globalThis上没有name属性。很多人会想greet是user调用的里面的this不是应该指向user吗注意user.greet()返回的是一个新的函数第二次调用时调用位置是光杆调用没有任何对象前缀。所以这个新函数的this走的是默认绑定跟user一点关系都没有。这就是典型的丢失 this。搞清楚这一点后面四条规则就顺理成章了。this的判断逻辑不是背一张表而是每次调用时回答一个问题这个函数是谁发起的发起者不同主语就不同。2. 四种绑定规则默认、隐式、显式、new一张脑图就能装下既然this是调用时决定的那具体按什么规则决定JavaScript 一共就四种绑定方式至今没有第五种。把这四条规则理清再用树状结构画成脑图整个知识框架就立住了。2.1 默认绑定没人让它干活它就找全局最朴素的规则一个函数被光秃秃地调用没有任何前缀、没有任何修饰符那么非严格模式下this指向全局对象严格模式下指向undefined。function showThis() { use strict; console.log(this); // undefined } showThis(); // 非严格模式 function showThis2() { console.log(this); // 浏览器里是 windowNode 里是 global } showThis2();什么叫光秃秃地调用比如fn()、setTimeout里的回调、数组forEach里的普通函数非箭头通通是这种情况。只要调用没有对象前缀默认绑定立刻生效。注意使用const/let声明的变量不会挂到全局对象上但这不影响this本身指向全局对象。所以function fn() { console.log(this) }在浏览器里打印window而console.log(this) window为true。2.2 隐式绑定谁调用了它this 就是谁最常见的场景obj.method()这种形式。函数作为对象的属性被调用时this指向这个对象本身。机制很好理解——引擎在执行属性调用时会把该对象作为接收方传进去。const cat { name: 喵喵, speak() { console.log(${this.name}喵 ~); } }; cat.speak(); // 喵喵喵~这里speak是cat的属性调用方式是cat.speak()所以this指向cat。注意一个陷阱把方法取出来再调用隐式绑定就失效了。const catSpeak cat.speak; catSpeak(); // 报错或输出 undefinedthis 走默认绑定把cat.speak赋值给catSpeak相当于把函数从对象里拆了出来调用时它和cat就没有任何关系了。这跟 1.2 里那个嵌套返回函数的例子是同一类问题。2.3 显式绑定call、apply、bind直接指定隐式绑定依靠对象调方法来指定this但有些场景下我们想把一个函数强行绑定到指定对象上调用那就要用call、apply和bind。它们可以显式地把this指定成我们想要的对象。function introduce(age, hobby) { console.log(我是${this.name}今年${age}岁喜欢${hobby}); } const person { name: 张三 }; introduce.call(person, 25, 跑步); introduce.apply(person, [25, 跑步]);两个函数的效果完全一样唯一的区别是传参方式call用逗号分隔参数apply用数组传参。bind和它们不同它不立即执行函数而是返回一个新函数新函数的this被永久锁定。const boundIntroduce introduce.bind(person, 25); boundIntroduce(游泳); // 我是张三今年25岁喜欢游泳这里有个值得注意的细节bind可以分两次传参第一次传入的25会变成age的固定值第二次调用时传的游泳会传给hobby。这种预置参数的能力在事件处理、定时器里特别常用。2.4 new 绑定构造函数里 this 的诞生过程用new调用函数时this指向一个新创建的、净土一样的对象。整个流程相当于四步创建一个全新的空对象。把该对象的原型链指向构造函数的prototype属性。将this绑定到这个新对象上。如果构造函数没有返回对象那么这个新对象就是new表达式的结果。function Person(name) { this.name name; } const p new Person(李四); console.log(p.name); // 李四当函数以构造方式被调用this不需要你操心谁调用了它——它永远指向那个新创建的对象。就算你这个构造函数内部走了this.name name这个name也是挂在全新对象上的。3. 当规则打架时怎么办优先级记忆法与经典推理链四条规则不是并列的它们有严格的优先级高低。如果两个规则撞上得先判断谁更硬。3.1 一句话记住优先级优先级从高到低是new 绑定显式绑定call/apply/bind隐式绑定obj.method()默认绑定光杆调用我自己的记法是新造的不如点名的点名的不如挂对象的挂对象的不如没人管的。一个新对象在构造函数中诞生时优先级最高其次是调用方强行指定this再其次才是哪个对象调就有哪个对象最后是无人认领时甩给全局。3.2 一道经典推理题走完全过程来看一个隐式绑定和显式绑定打架的场景const objA { name: A, fn: fn }; const objB { name: B }; function fn() { console.log(this.name); } objA.fn.call(objB); // 输出 B推理过程objA.fn拿到的是一个函数按隐式绑定思维看它应该指向objA。但它后面跟了个.call(objB)显式绑定的优先级高于隐式绑定于是this被强行指定为objB输出B。再扩展一层new的优先级function fn(name) { this.name name; } const obj { name: 对象 }; const boundFn fn.bind(obj); const instance new boundFn(实例); console.log(obj.name); // 对象——没有变化 console.log(instance.name); // 实例bind明明把this锁死成了obj但new boundFn(实例)一出现新对象照样诞生this指向新对象而不是obj。这就验证了new绑定压过显式绑定。为什么这么设计因为new的目的是创建全新实例如果bind的永久锁死能阻止它那new这个操作就废了。所以引擎在new面前bind的锁会被主动解开。3.3 边界情况call/apply 传 null 或 undefined显式绑定不是万能的把null或undefined传给call/apply时this会被忽略实际走默认绑定。function fn() { console.log(this); } fn.call(null); // 浏览器里输出 window非严格模式 fn.call(undefined); // 同样输出 window这个边界经常在借用 Math.max 求数组最大值里踩到const nums [3, 7, 1, 9, 4]; Math.max.apply(null, nums); // 9这里传入null只是为了我不关心 this只想借用这个方法——本质上this无所谓真正的目的是把数组展开成参数。这种写法在业务里很常见但严格模式下this会保持null而不变成全局对象写工具库时要格外小心。4. 不只是换 thiscall 和 apply 的实战场景盘点call和apply表面上只是换this但真正拉开差距的是它们在实际开发中的应用姿势。很多场景不是为了换而换而是我有一把锤子顺手就能解决一类问题。4.1 借用别人的方法JavaScript 是鸭子类型语言。一个对象没有某个方法但它可以借用另一个对象的方法只要结构上像就行。const dog { name: 旺财, speak() { console.log(${this.name}汪汪 ~); } }; const cat { name: 咪咪 }; dog.speak.call(cat); // 咪咪汪汪~看起来有点奇怪但这就是鸭子类型只要cat有name它就能调用dog的speak方法。这在处理伪数组、类数组对象时特别有用。4.2 伪数组转数组前端第一课各种类数组——arguments、DOM 元素集合、NodeList——没有数组的map、filter等方法但它们又确实长得像数组。最经典的处理方式就是借用数组方法function collectArgs() { // 老办法 const args1 Array.prototype.slice.call(arguments); // ES6 新办法 const args2 Array.from(arguments); // 或者 const args3 [...arguments]; console.log(args1, args2, args3); }Array.prototype.slice.call(arguments)是最老的姿势利用slice的遍历能力把类数组转成真数组。ES6 之后虽然有了Array.from和展开运算符但面试题里仍然高频出现而且引用第三方库源码时你还是经常能看到这种老写法。再比如NodeList的快速遍历const items document.querySelectorAll(li); const names Array.prototype.map.call(items, item item.textContent);4.3 展开数组和分批传参不用apply展开数组行不行也行但apply是最直接的方案尤其当参数数量不定的时候。const numbers [12, 5, 8, 130, 44]; const max Math.max.apply(null, numbers); // 130 const min Math.min.apply(null, numbers); // 5 // ES6 展开后 const max2 Math.max(...numbers); // 130两个写法的执行效果一样。apply是旧时代的展开方式展开运算符是新时代的语法糖。如果你在维护老项目遇到apply不要慌它就是这么工作的。4.4 数组拼接的妙用往数组中间插入一组元素很多人只会splice其实apply也能干const arr [1, 2, 5, 6]; const toInsert [3, 4]; // 从索引 2 开始删除 0 个元素插入 toInsert 的每一项 arr.splice.apply(arr, [2, 0].concat(toInsert)); // 结果: [1, 2, 3, 4, 5, 6]道理不复杂splice(2, 0, 3, 4)等价于splice.apply(arr, [2, 0, 3, 4])。在 ES6 之前这是解决动态参数插入的标准手段。现在有了展开运算符直接写arr.splice(2, 0, ...toInsert)也行但阅读老代码时你得认识apply的这套玩法。4.5 call 在继承场景中的应用在 ES6 的class出现之前组合继承靠的就是call。子构造函数通过Parent.call(this, ...)把父类实例属性复制到自己身上。function Parent(name) { this.name name; } function Child(name, age) { Parent.call(this, name); this.age age; } const child new Child(王五, 12); console.log(child.name); // 王五逻辑很直白Child的this是新创建的子类实例通过Parent.call(this, name)Parent内部的this.name name就作用到了这个子类实例上。这比实例化一个父类再改原型要轻巧得多。5. call、apply、bind 对比表与选型思路三者容易混我直接用一张真正方便对照的表来收束这一部分。你对比着看几遍配合代码理解一下后面选择用哪个就不会犹豫了。方法是否立即执行传参方式产生新函数this 是否可再改典型场景call是逐个传参否每次调用可不同借用方法、立即执行指定 thisapply是数组传参否每次调用可不同参数不定、展开数组bind否逐个传参可分批是不可再改除 new 场景事件回调预绑定、柯里化选型就三句话要立即执行参数又能穷尽——用call。要立即执行参数在数组里或者数量不定——用apply。不急着执行想拿到一个this被锁死的新函数留给后面用——用bind。bind最典型的使用场景是事件监听const button document.querySelector(#btn); const user { name: 赵六 }; function onClick() { console.log(this.name); } button.addEventListener(click, onClick.bind(user));如果不加.bind(user)事件回调里的this指向button每次点击就想问为什么不是 user。加上bind之后这个函数无论什么时候被调用都跟user绑定了。事件回调太容易丢thisbind在这就是救火队员。6. 箭头函数this 界的一条清流不按常理出牌前面讲的四种规则到箭头函数这里全部失效。箭头函数没有自己的this它不参与绑定规则而是捕获定义它时所在的那个外层函数的this。简单说箭头函数里的this在写代码时就已经定死了不是在调用时算出来的。const outer { name: 外层对象, inner() { const arrow () { console.log(this.name); }; arrow(); } }; outer.inner(); // 外层对象arrow是普通函数的话光杆调用会走默认绑定this指向全局但是它是箭头函数所以它直接沿用了外层inner函数的this而inner是被outer调用的所以输出outer.name。这个特性在事件回调和异步代码里特别省心const obj { count: 0, incrementLater() { setTimeout(() { this.count; console.log(this.count); // 这里的 this 是 obj }, 100); } }; obj.incrementLater(); // 1如果是普通函数回调setTimeout里的this会指向全局this.count直接报错或输出undefined。箭头函数相当于把外面的this带进了回调里省掉了that this、const self this这类老写法。需要注意的是箭头函数因为不具备自己的this所以它不能作为构造函数也不能用new实例化。而且如果外层没有普通函数顶层作用域的箭头函数里的this就会指向全局对象——比如一个模块顶层直接写箭头函数它的this取决于模块环境在浏览器里往往就是全局对象。7. 我的 this 排查三步法从玄学到科学讲完规则分享一点实际排查经验。每次遇到this指向不对不要瞎试按下面三步走基本能快速定位。7.1 第一步找到调用点画一条调用链先找到出问题的那一行函数调用语句看它是怎么被调用的是obj.method()是fn()是new出来的还是通过call/apply/bind调用的如果有嵌套比如user.greet()()就把每一层的调用方式都标出来一层层判断。7.2 第二步套规则算优先级每层调用先套四条规则再用优先级排序。最高优先级是new其次是显式绑定然后是隐式绑定最后是默认绑定。如果遇到箭头函数就跳过绑定规则直接看外层函数或者所在作用域的定义位置。7.3 第三步用工具验证别靠猜在函数内部第一行加console.log(this)或者直接在调用点写debugger打断点在控制台里看this到底是什么。很多人一遇到this就问 AI、查博客其实最快的方法是打断点一手信息永远比二手资料可靠。调试this的一个冷门技巧在控制台里输入console.dir(fn)可以看函数对象的属性而输入fn.call(context)可以模拟this为context时的行为用来验证自己的判断很快。7.4 我自己常犯的三个低级错误整理这篇文章时我也想了一下以前自己在业务里踩过最多的this坑是这三个事件回调忘了绑定。写 React 类组件或原生addEventListener时直接把方法引用传出去没意识到方法被取出来再调用this 就丢了。解构赋值导致丢失。const { save } user;之后调用save()这跟取方法再调用是一模一样的机制this早就不指向user了。在回调里用普通函数而不是箭头函数。arr.forEach(function(item) { this.update(item) })这种写法this直接走默认绑定最后还得在外面存一个that this。现在写代码遇到这类场景我已经默认用箭头函数了。8. 画出你的思维脑图一个可以随时自查的骨架回归标题里的思维脑图。我建议你不要死记我上面的文字而是自己画一张图。脑图的分支就三条this 是什么、this 怎么定、this 怎么改。第一层画this 是什么调用时决定指向主语。第二层画this 怎么定四规则 —— 默认、隐式、显式、new优先级重重叠叠记清楚。顺序就是new 显式 隐式 默认。第三层画this 怎么改call/apply立即执行、换 this、bind不执行、锁 this、箭头函数写代码时捕获外层 this。如果你能把这张图自己画出来说明你已经把调用位置 → 绑定规则 → 优先级 → 工具方法这条链路彻底串起来了。画完挂在电脑旁边或者干脆记在脑心里以后再遇到this相关问题打开这张图就能定位到是哪一级出了问题。这一篇是系列一先把这个根基打好。后面我可以继续深入bind的柯里化应用、this在各种框架中的实际表现、以及一套完整的业务排查案例拆解。如果你在阅读或练习时有哪条规则不太确定的最好的验证方式不是反复纠结而是打开控制台把例子敲进去看一眼输出一切都会变得清晰。