1. 别背了,this的坑从来就不是“规则”
入行前端这么多年,我面试过不少人,也带过不少新人,几乎每次聊到JavaScript,this总会成为绕不开的话题。网上关于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. 箭头函数为什么特殊:它根本没有this
4.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丢失”的问题。
最经典的场景就是setTimeout:
const 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丢失,比出了问题再去修要省心得多。