尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

别再背this规则了:理解“调用者”才能彻底搞懂JavaScript的this指向

别再背this规则了:理解“调用者”才能彻底搞懂JavaScript的this指向 1. 别背了this的坑从来就不是“规则”入行前端这么多年我面试过不少人也带过不少新人几乎每次聊到JavaScriptthis总会成为绕不开的话题。网上关于this指向的文章一抓一大把从“默认绑定、隐式绑定、显式绑定、new绑定”四条金科玉律到各种“箭头函数没有自己的this”之类的口诀大家背得滚瓜烂熟。可一旦真到了写代码的时候该出错还是出错定位半天才发现又是this丢了。说白了问题不是你不懂this而是你一直在背“理论上的this”没有真正理解“调用时到底是谁在调用”。我一直跟团队里的人讲一句话this指向谁指向真正的调用者。这句话听起来像废话但只要你真正想明白“调用者”三个字你就能把90%的this问题解决掉完全不用死记硬背那套规则。这篇文章我想把我的实际经验完整说一说this到底是什么、什么叫“真正的调用者”、箭头函数为什么特殊、以及我在真实项目里踩过的那些坑。废话不多说咱们直接开整。2. 核心思想拆解this不是“谁定义的”而是“谁来调用的”2.1 为什么说this和“定义位置”无关很多初学者会有一个天然直觉this应该指向“定义这个函数的地方”所在的上下文。这个直觉在大多数情况下是错的。我举个例子你细品let name 全局名字; const person { name: 张三, sayName: function() { console.log(this.name); } }; const func person.sayName; func(); // 输出什么这里func拿到的就是person.sayName这个函数本身。当你执行func()的时候这个函数被谁调用的答案是“没有任何对象”在调用它它就是裸着被调用的。在非严格模式下这个this会指向全局在严格模式下指向undefined。所以这段代码最终打印的是“全局名字”或者直接报错严格模式。但你如果写成person.sayName()结果就会打印“张三”。同一个函数两种调用方式结果完全不同。函数还是那个函数定义的位置也没变变的只有“谁来调用它”。所以this的指向本质上就是“最后一次调用发生时那个调用表达式的形态”决定的。这不叫玄学这叫JavaScript的执行机制。2.2 什么是“真正的调用者”我强调“真正的调用者”是为了帮大家分辨一种很容易被忽悠的情况你以为的调用者和实际上的调用者往往不是同一个。看这一段const obj { name: 对象A, child: { name: 对象B, getName: function() { return this.name; } } }; obj.child.getName(); // 输出什么这个例子里getName定义在child对象里面调用的时候也是通过obj.child.getName()来调的。那么“真正的调用者”是谁是child不是obj。为什么因为在这个链式调用里this的绑定看的是“紧挨着函数名左边那一个对象”。函数名getName左边是.child所以this就是child对象输出“对象B”。这个例子的意义在于很多初学者会误以为this指向“整个大对象obj”因为getName看起来是“挂在obj整个结构里的”。这就错了。JavaScript不是看嵌套层级而是看“最后一步是怎么调出来的”。2.3 生活化类比把函数理解成一张名片为了把这个概念讲得更好懂我经常用一张名片的比喻。你可以把一个函数想成一张印着“我是谁”的名片但这张名片上印的“我是谁”是空白待填的。只有当你把这张名片交给某个人并且让这个人“掏出名片开始自我介绍”的时候名片左上角才会被写上当前掏出名片的人的名字。也就是说名片到手空白。谁掏出来写谁的名字。JavaScript里函数被定义的时候它的this是空白的。函数被调用的时候引擎才会根据“调用者到底是什么形态”来决定this的值。这个“调用者形态”可以是全局环境、可以是某个对象、可以是new出来的实例、也可以是call/apply/bind显式指定的对象。理解了这张名片再回头看那些所谓的“规则”其实就是几种“谁掏名片”的常见姿势。3. 四种常见的调用形态以及它们背后“谁在掏名片”3.1 直接裸调this指向全局或undefined最常见、也最容易出问题的情况就是“裸调”。裸调的意思是你调用一个函数但函数的调用者不是任何对象就是直接写函数名加括号。function test() { console.log(this); } test(); // 非严格模式下是 window严格模式下是 undefined这种行为很多人第一次接触时会觉得莫名其妙为什么不是函数自己函数不就是自己的调用者吗这是误解。函数本身不是一个“调用者”概念函数是一个“被调用的对象”。调用者的意思是“以什么容器来调用”。如果你没有用任何对象去承载这个函数调用那引擎就认为调用者是全局环境。在浏览器里全局环境是window在Node.js里是global或者模块内的undefined取决于运行环境和模块化机制。所以“裸调”这个词更准确的名字其实是“全局调用”。你写test()本质上是在全局作用域里执行一次函数调用调用者就是全局环境。3.2 方法调用谁点在函数名左边this就是谁这是日常开发中碰到最多的一种形态。一个函数被存成某个对象的属性然后用“对象.方法()”的方式调用。const user { name: 李四, greet: function() { console.log(你好我是${this.name}); } }; user.greet(); // 你好我是李四这个例子比较直观user点在greet的左边this就是user。但真正让人头晕的是“把方法取出来再调用”的场景。我再举一个很典型的例子const user { name: 王五, greet: function() { console.log(你好我是${this.name}); } }; const greetFn user.greet; greetFn(); // 你好我是undefined这里greetFn拿到了user.greet这个函数的引用但拿到的瞬间函数和user的“关联”就断了。JavaScript里函数在传递过程中并不会“记住”自己来自哪个对象它只是一个独立的函数对象。所以最后greetFn()是一次“裸调”this自然不指向user。这个坑在开发中太常见了尤其是当你把组件里的方法单独抽出来作为回调函数传给其他模块的时候。3.3 显式绑定call、apply、bind的本质是“指定掏名片的人”如果你不想依赖“谁点在左边”的天然规则你可以用JavaScript提供的手段来强制指定this指向谁。这就好比名片还是那张名片但你可以手动告诉系统今天让张三掏。function introduce() { console.log(我是${this.name}); } const person1 { name: 赵六 }; const person2 { name: 钱七 }; introduce.call(person1); // 我是赵六 introduce.apply(person2); // 我是钱七 const boundFn introduce.bind(person1); boundFn(); // 我是赵六三种显式绑定方法里call和apply是“立即执行”区别只在传参方式bind是“返回一个新函数把this固定住下次调用再执行”。我自己的经验是能不用bind就不用bind因为bind会创建一个新函数频繁使用或者在不该用的地方用可能带来一些性能开销和内存管理问题。你更需要思考的是——为什么会有“需要指定this”的需求往往是因为前面两步没做好导致this丢了你才想起来补救。3.4 new绑定这是一次“造人”的过程当函数前面加上new关键字这个函数会以“构造函数”的身份被调用。此时引擎会创建一个新对象这个新对象会成为函数的this如果构造函数没有显式返回对象那这个新对象就是整个表达式的返回值。function Person(name) { this.name name; } const p new Person(周八); console.log(p.name); // 周八这个例子中this指向的是“new出来的那个实例”而不是别的什么。这也解释了为什么构造函数里面的this.name name是在给新实例添加属性。4. 箭头函数为什么特殊它根本没有this4.1 箭头函数的this是“从外头继承来的”前面讲了四种绑定形态都是“调用时决定this指向谁”。但箭头函数不一样它不遵守这套规则。箭头函数没有自己的this它的this是定义时继承自外层作用域的而且一经确定在后续任何调用方式下都不会改变。const obj { name: 箭头示例, greet: function() { const inner () { console.log(this.name); }; inner(); } }; obj.greet(); // 箭头示例这里inner是箭头函数它自己没有this。它用的this是从外层函数greet里拿来的。而greet是作为obj的方法被调用的所以它的this指向obj于是箭头函数里的this也指向obj。从实现原理上讲箭头函数捕获的其实是“定义时所在作用域的this”。你可以把它理解成一种词法作用域的行为有点类似const self this这种写法。4.2 箭头函数能解决什么问题在实际项目中箭头函数最大的价值就是解决“回调函数里this丢失”的问题。最经典的场景就是setTimeoutconst obj { name: 定时器案例, start: function() { setTimeout(function() { console.log(this.name); // 这里this是undefined或window }, 1000); setTimeout(() { console.log(this.name); // 这里this继承自start函数指向obj }, 1000); } }; obj.start();如果不使用箭头函数setTimeout里的普通函数在回调的时候是“裸调”的this自然不指向obj。以前大家惯用的做法是在外层先const self this然后在回调里用self。箭头函数出来之后这种写法就逐渐被替代了。4.3 箭头函数不是万能的别滥用虽然箭头函数好用但它也有自己的局限。第一箭头函数不能作为构造函数。你没法对箭头函数使用new因为箭头函数压根没有this可以绑定到实例上。第二箭头函数里没有arguments对象。如果你在箭头函数里写arguments拿到的是外层作用域的arguments不是自己的参数列表。第三箭頭函数不适合做对象方法。因为它的this不会指向调用它的对象而是继承外层作用域const obj { name: 不应该这样写, greet: () { console.log(this.name); } }; obj.greet(); // undefined这种写法几乎是反直觉的。很多人可能以为obj.greet()会输出对象里的name但箭头函数直接跳过整个隐式绑定规则。所以我把箭头函数的使用场景拉回两条一是回调函数里需要保持外层this二是不需要this的纯函数。5. 实操过程项目中this丢失的经典现场与解法5.1 场景一React类组件里的事件处理函数写React类组件的时候很多人应该都踩过这个坑class Counter extends React.Component { constructor(props) { super(props); this.state { count: 0 }; } handleClick() { this.setState({ count: this.state.count 1 }); } render() { return button onClick{this.handleClick}点击/button; } }这段代码运行起来点击按钮就会报错Cannot read properties of undefined (reading setState)。为什么因为onClick{this.handleClick}是把handleClick这个函数作为一个值传给了React内部的事件系统。React在触发事件回调时并不会像JSX写的那样“通过实例点方法”去调用而是直接把函数拿出来裸调。于是this自然就不指向组件实例了。解决方案通常有三种在构造函数里手动绑定this.handleClick this.handleClick.bind(this);使用箭头函数作为类字段handleClick () { this.setState({ count: this.state.count 1 }); };在JSX里包一层箭头函数button onClick{() this.handleClick()}点击/button这三种方式本身都不是问题但我在团队里比较推荐第二种。类字段箭头函数是定义时继承外层this而外层就是构造函数那层也就是组件实例本身。这样既简洁又不容易出错。5.2 场景二把方法作为回调传给第三方库接手过老项目的朋友肯定遇到过这种场景你要把一个对象的方法作为一个回调函数传给某个第三方库或者工具函数。const user { name: 测试用户, logInfo: function() { console.log(用户${this.name}); } }; [1, 2, 3].forEach(user.logInfo);你可能会想forEach每次循环都会调用这个函数this应该指向user吧但实际运行你会发现this不是undefined就是全局对象。因为forEach接收的是一个函数引用它内部执行时不会保留user这个对象信息直接把函数拿出来调了。正确做法是包一层[1, 2, 3].forEach(() user.logInfo());或者用bind[1, 2, 3].forEach(user.logInfo.bind(user));这两种方式我都有用过。如果只是简单调用包一层箭头函数最直观如果这个回调会被多个地方复用用bind生成一个固定this的新函数更方便。5.3 场景三函数默认参数与解构赋值中的this还有一种比较冷门但偶尔会遇到的场景——在解构赋值的时候取方法然后直接调用const obj { name: 解构案例, greet() { console.log(你好${this.name}); } }; const { greet } obj; greet(); // 你好undefined这里const { greet } obj本质上是把obj.greet的值赋给了新的变量greet。你拿到手的是函数本身而不是“obj.greet”这个绑定关系。后面调用greet()就是一次裸调this自然丢失。我在Code Review的时候见到过类似写法尤其是在写工具函数时很多人会把某个方法解构出来独立使用然后踩到this丢失的坑。解决办法也就一句话要么调用时保持对象.方法()的形态要么显式用bind绑定。5.4 场景四嵌套函数里的this指向穿透还有一个容易犯错的地方就是在一个方法内部再定义一个普通函数然后在普通函数里使用this。const obj { name: 嵌套函数案例, outer: function() { function inner() { console.log(this.name); } inner(); } }; obj.outer(); // 这里不会输出“嵌套函数案例”outer作为obj的方法被调用时this指向obj。但在outer内部inner是一个普通的函数声明调用inner()时是裸调所以inner里的this不继承outer的this。你在inner里访问this.name拿到的绝对不是obj.name。以前我们要么用const self this要么用bind现在直接用箭头函数就能解决const obj { name: 嵌套函数案例, outer: function() { const inner () { console.log(this.name); }; inner(); } }; obj.outer(); // 嵌套函数案例这是因为箭头函数的this在定义时就固定为外层作用域的this了。6. 我总结的两个“底层心法”替代所有规则背诵6.1 心法一永远问自己“这个函数最后一次被调用时调用表达式长什么样”很多同学在分析this的时候会本能地去看函数定义的位置然后根据定义位置去推断指向。但this的设计和“定义位置”关系不大它和“调用位置”强相关。所以我要求自己分析任何一个this问题时只做一件事找到最后一次调用这个函数的那个表达式然后看这个表达式的形态。形态判断顺序其实可以简化成三句话是“对象.方法()”格式吗是那this就是点左边的对象。前面有new吗有那this就是新创建的实例。用了call/apply/bind吗用了那this就是显式传入的参数。如果以上都不是那它就是一次裸调。普通函数裸调时this是全局对象非严格模式或undefined严格模式。6.2 心法二箭头函数没有自己的this它只会往上一层层“借”箭头函数是一个异类它不参与上面的形态判断。你要理解一个箭头函数的this唯一的办法就是往里层作用域一层层往上找找到最近的“非箭头函数”的那个作用域看那个作用域的this是什么箭头函数的this就是什么。如果一路找到全局都没有非箭头函数包裹那箭头函数的this就是全局对象的this浏览器里是window。这两个心法结合起来几乎可以解决所有的this分析题。我甚至觉得理解到这一步之后那些“优先级”规则表已经变得不那么重要了。因为你不是在背优先级而是在根据“最后一次调用的表达式的形态”去判断。7. 面试八股文为什么挡不住实战我经常跟人聊为什么面试的时候this相关的八股文背得再熟一到项目里还是不停出错。核心原因之一是真实项目里的调用场景远比面试官出的几道小题复杂得多。你要处理的是异步回调、事件监听、方法传递、函数柯里化、装饰器、以及各种第三方框架内部对回调函数的封装调用方式。在这些场景里this的指向完全取决于“第三方库怎么调用你传进去的函数”而不是“你怎么定义它”。举一个很现实的例子class EventBus { on(event, callback) { // 假设这里做了一些内部处理 this.callbacks[event].push(callback); } emit(event) { this.callbacks[event].forEach(cb cb()); } } const bus new EventBus(); const user { name: 事件总线用户, init() { bus.on(login, function() { console.log(${this.name} 登录了); }); } };这个例子中你在user.init()里给bus.on传入了一个普通函数。当事件总线内部触发回调时它用的是cb()这种裸调形式。所以this根本不会指向user也不会指向bus它可能指向undefined严格模式。很多人会疑惑我明明是在user.init()里面定义的回调this至少应该继承init的this吧不会的普通函数的this是调用时确定的不继承定义时的外层this。唯一“继承外层this”的语法是箭头函数。这种“动态绑定”和“词法继承”的差异恰恰是八股文最难讲清楚的地方。8. 实战排查如何快速定位this丢失问题这里分享一套我的排查流程可以帮你快速定位代码里this为什么不对。第一步找出你关注的那个“this”。把断点打在它出现的那一行在DevTools里看它的this值到底是什么。第二步从this出现的位置往上读代码找到“调用这个函数的那一行”。注意不是定义函数的那一行是最终触发函数执行的那一行。你在断点处的右侧调用栈里通常能看到完整的调用链。第三步判断最终的调用表达式形态。如果它是一个裸调基本可以断定this和你想的不一样。这时候你需要在函数定义处加上console.log(this)来二次确认。第四步根据判断结果修复。如果确实需要保留外层this根据场景选择箭头函数、bind、或者包一层调用。整个流程最长不超过十分钟远比背规则快。而且这个流程你练得越多大脑里就会自然形成一套“找调用表达式”的肌肉记忆后面几乎一眼就能看出问题出在哪。9. 一些值得你马上动手实践的小练习纸上谈兵没意思你可以把下面这几个小例子先自己在浏览器控制台里跑一遍看看输出和自己预判的是否一致var name 全局变量; const obj1 { name: 对象1, fn: function() { console.log(this.name); } }; const obj2 { name: 对象2, fn: obj1.fn }; obj2.fn(); // 这个输出“对象2”你能解释吗 const obj3 { name: 对象3, fn: function() { const inner obj1.fn; inner(); } }; obj3.fn(); // 这个输出什么为什么第一个例子obj2.fn指向的虽然是obj1.fn那个函数但调用表达式是obj2.fn()函数名左边是obj2所以this指向obj2输出“对象2”。第二个例子inner拿到的是函数引用调用时是裸调。在非严格模式下会读全局name控制台输出“全局变量”在严格模式下会报错。这个结果非常反直觉但只要你坚持“调用表达式决定this”这个心法就能秒懂。我很建议你把这段代码粘贴到控制台里实际跑一遍看看输出结果是否符合你的分析。如果符合说明你对“调用者”这个概念已经有了真正的理解如果不符合说明某个环节的调用形态判断还是没有到位。10. 我个人的经验总结从最开始被this折磨到深夜到后来能一眼看出任何this问题的根源我觉得最大的转折点不是背熟了某几条规则而是彻底接受了“this是调用时决定的”这个事实。一旦你接受这个事实你就会自然而然地开始关注“调用表达式长什么样”而不是纠结“函数定义在哪个对象里”。我也带过很多新人我发现一个很有意思的现象那些能在实际项目里很快定位this问题的人往往不是规则背得最好的人而是习惯性在写回调函数前多问一句“这个回调是谁在调、怎么调”的人。这种“调用意识”一旦建立起来this问题就不再是坑而是变成一种很自然的判断。最后再分享一个小技巧如果你的代码里频繁出现“取方法再调用”的情况建议你在设计接口时尽量返回一个已经绑好this的函数或者直接使用箭头函数类字段。从根源上避免this丢失比出了问题再去修要省心得多。
返回列表