刚开始带前端团队的时候,我最头疼的事情之一就是面试。问十个候选人,八个能把this的规则背得滚瓜烂熟:默认绑定、隐式绑定、显式绑定、new绑定,甚至箭头函数不绑定this都能一字不差说出来。结果我一给代码,让他们说输出什么,立马露馅。
原因很简单,大多数人学this,用的是“背答案”的方式,而不是“看本质”的方式。标题里这句话,是我一直想跟所有前端开发者说的:this指向谁?指向真正的调用者。把这句话真正理解透了,你会发现面试官再怎么变着花样出题,核心永远就那么一个。
这篇文章不打算给你列一堆“八股文”规则让你背,而是从“调用者”这个视角,把this的底层逻辑拆开揉碎讲清楚。同时,我会把实际开发里遇到的各种this丢失场景、排查思路、修复手段全部摊开来讲。不管你是刚入门的前端新手,还是被this坑过几次的初中级开发,这篇文章都能帮你把这块短板彻底补上。
1. 整体设计思路:为什么“调用者视角”比“规则背诵”更靠谱
1.1 传统八股文的局限性
你打开任意一篇讲this的文章,大概率会看到这样的总结:谁调用指向谁、没调用者指向window、严格模式下是undefined、call和apply可以改指向、箭头函数不绑定this。
这些结论有错吗?没错。但问题是,它们都是“结论”,不是“方法”。面试题稍微换一层皮,比如把对象方法赋值给一个变量再调用、把方法作为回调传进去、或者嵌套一层函数,很多人的大脑就当场死机。
为什么?因为背规则的人,脑子里装的是“规则库”,而不是“推导能力”。遇到见过的题型,套规则能解出来;遇到没见过的题型,规则的优先级自己都搞不清楚。比如隐式绑定和显式绑定冲突时该听谁的?new和call同时出现时谁说了算?这些在八股文里虽然也有顺序表,但你光靠背,根本理解不了背后的逻辑。
1.2 调用者视角是怎么回事
我来换个思路。想象你有一段代码:
const person = { name: '张三', greet() { console.log(`你好,我是${this.name}`); } }; person.greet();按照调用者视角,我们应该问一个问题:greet这个方法在被调用的那一刻,是通过谁去调的?
答案是person。因为写的是person.greet(),在greet前面有一个清晰的调用者person。这就好比你在公司里喊了一声“帮我倒杯水”,这个指令是谁发出的?是你发出的,所以执行的人知道该听谁的。JavaScript里的this,本质上就是在问:这个函数的调用者是谁,我就把this指向谁。
一旦你切换到这种视角,很多看似复杂的规则其实就是大白话:
- 函数直接调用,前面没有“点”,调用者就是undefined或全局对象;
- 函数前面有“点”,比如
obj.method(),调用者就是obj; - 你想强制指定调用者,用call、apply、bind把调用者“绑”上去;
- 用new调用函数时,系统帮你新建了一个对象,并且把this指向这个新对象;
- 箭头函数比较特殊,它压根没有自己的this,它用的this是定义它时外层作用域的那个this。
看到没?这些规则本质上都是围绕“调用者”展开的。你不需要背优先级表格,只需要在每次看到函数调用时,下意识问一句:这个函数的调用者是谁?
1.3 这套思路能解决什么问题
用调用者视角去看代码,最大的好处是你能动态分析一段代码,而不是靠记忆匹配。动态分析的能力,才是面试官真正想考察的东西。因为实际开发中,你不会总是写obj.method()这种规规矩矩的代码,更多时候是回调、事件绑定、函数传参、解构赋值,这些场景里this特别容易丢。而丢了this的根本原因,往往是调用者变了——本来调用者应该是某个对象,结果因为某种操作,调用者变成了全局对象或者干脆变成了undefined。
所以这篇文章的后续内容,会以“找调用者”为核心线索,把各种场景串起来。只要你会问“谁在调用它”,你就已经赢了一半。
2. 核心细节解析:绑定的几种真实场景与背后逻辑
2.1 默认绑定:没有调用者时的兜底策略
先看最简单的情况:
function sayName() { console.log(this.name); } var name = '全局名字'; sayName();这里sayName()前面没有点,没有call,没有new,它就是一个光秃秃的函数调用。那调用者是谁?严格来说,没有调用者。在非严格模式下,JavaScript引擎会把这个this默认指向全局对象(浏览器里是window,Node.js里是global);在严格模式下,则直接给undefined。
你可能会问:为什么非严格模式要默认指向全局对象?这是历史遗留问题。早期JavaScript设计时没有模块概念,所有顶层变量天然挂在全局对象上,所以函数里的this默认指向全局也算是一种“自然设计”。后来ES5引入了严格模式,意识到这种默认行为很容易造成误操作——你在函数里随手写个this.xxx = 1,结果污染了全局——所以严格模式下改成undefined,逼着开发者明确指定调用者。
这个差异虽然简单,但在面试题里经常作为一个小坑出现。我记得有个很经典的题目是这样的:
function func() { 'use strict'; console.log(this); } func();输出是undefined。因为严格模式生效范围包括函数内部,无论你在哪调用,函数体内的this被严格模式约束为undefined。这题看似考严格模式,其实考的仍然是“没有调用者时,this怎么处理”这个底层逻辑。
2.2 隐式绑定:找到那个“点”前面的对象
隐式绑定,就是标题所说的“真正的调用者”最直观的体现。当一个函数作为对象的一个属性被调用时,即obj.method()这种形式,this会指向obj。
const user = { name: '李四', age: 28, introduce() { console.log(`我是${this.name},今年${this.age}岁`); } }; user.introduce();输出:我是李四,今年28岁。因为调用introduce时的调用者是user,所以this.name就是user.name。
这个逻辑看起来简单,但有几个变种特别容易让人迷糊。
第一个变种是对象方法赋值给变量:
const fn = user.introduce; fn();此时输出是什么?答案是Cannot read properties of undefined之类的报错,或者this.name是undefined。原因很简单:当你执行const fn = user.introduce时,你只是把函数的引用取出来了,并没有调用它。等到fn()执行时,调用者已经变成了光秃秃的全局环境,前面没有点,user对象和这次调用已经没有任何关系。这就是典型的“丢this”:不是this自己跑了,是你把函数和它的调用者拆散了。
第二个变种是嵌套函数:
const obj = { name: '内部对象', outer() { function inner() { console.log(this.name); } inner(); } }; obj.outer();这里obj.outer()执行时,outer内部定义了一个普通函数inner,并直接调用inner()。注意,inner的调用者是全局环境,不是obj。所以输出的是全局的name(或者undefined)。很多人误以为外层函数的this会传进去,实际上普通函数不会继承外层函数的this。这个坑在ES6之前尤其常见,当时大家都用var that = this来手动保存外层this。
第三个变种是对象方法链:
const objA = { name: 'A', method() { console.log(this.name); } }; const objB = { name: 'B', child: objA }; objB.child.method();这里虽然写了两层点,但真正调用method的“最后一级”调用者是objA,所以this.name是A。判断规则就一条:看调用那一刻,点前面紧挨着的那个对象是谁。不需要往再上层看。
2.3 显式绑定:手动指定调用者的三个工具
有些场景下,函数的调用者不好改,或者你想强制指定this,这时候就需要call、apply、bind这三个工具。它们的作用本质上是同一个:绕过默认的调用者关系,手动指定this该指向谁。
function greet() { console.log(`你好,${this.name}`); } const user1 = { name: '王五' }; const user2 = { name: '赵六' }; greet.call(user1); // 你好,王五 greet.apply(user2); // 你好,赵六 const boundGreet = greet.bind(user1); boundGreet(); // 你好,王五call和apply的区别只在于传参方式:call用逗号分隔参数,apply用数组传参。bind则比较特殊,它不会立即执行函数,而是返回一个新函数,这个新函数的this被永久固定为传入的对象。
这里有个非常关键的细节:bind返回的新函数,this是不可再变的。即使你之后对这个新函数再用call、apply想改this,也改不回来。只有new操作符能绕过bind的this绑定,这在面对一些源码级别的面试题时会考到,但实际开发中很少遇到。我后面在“常见问题”里会专门讲一个bind + new的坑。
显式绑定理解起来不难,真正重要的是搞清楚它适合用在什么场景。最常见的场景就是回调函数丢失this。举个例子:
const counter = { count: 0, add() { this.count++; } }; document.getElementById('btn').addEventListener('click', counter.add);这段代码的问题是:当你把counter.add作为事件回调传进去时,浏览器在触发事件时会调用这个函数,而调用它的是事件系统,不是counter对象。所以回调执行时this指向的是按钮元素,counter.add里的this.count就是undefined++,结果自然是NaN。
解决办法就是用bind把this绑回去:
document.getElementById('btn').addEventListener('click', counter.add.bind(counter));这样不管事件系统怎么调用,add内部的this始终是counter。
2.4 new绑定与箭头函数:两个特殊的存在
用new调用函数时,JavaScript引擎会做这几件事:创建一个新对象,把这个新对象的原型指向函数的prototype,然后把这个新对象作为this传入函数执行,最后如果函数没有返回对象,就返回这个新对象。所以new绑定其实也是一种“指定调用者”的方式,只不过这个调用者是系统帮你新建的。
function Person(name) { this.name = name; } const p = new Person('测试'); console.log(p.name); // 测试这里Person内部的this,指向的就是new创建出来的新对象p。从调用者视角来看,你可以理解为:new在幕后做了一次“强行指定调用者”的操作,把this指向了一个崭新的对象。
箭头函数则完全不同。它没有自己的this,它的this在定义时就确定了,取决于定义它的那个位置所在的词法作用域。换句话说,箭头函数的this不取决于“谁调用它”,而取决于“它在哪定义”。
const obj = { name: '箭头函数测试', greet: () => { console.log(this.name); } }; obj.greet();这个代码输出什么?很多人以为输出箭头函数测试,因为greet是obj的方法。但实际上输出的是全局的name(或者undefined)。为什么?因为箭头函数没有自己的this,它会向外层作用域找this。而对象字面量{}不构成作用域,所以greet定义时所在的作用域是全局作用域,this就是全局对象。
如果你换成普通函数写法,obj.greet()的this就是obj。这就是箭头函数和普通函数在this上的根本区别。理解了这个,你就能明白为什么React类组件时代,在render里绑定事件时要用箭头函数来保持this指向组件实例。
3. 实操过程:从调用点判断this指向的完整流程
3.1 建立“调用点分析”思维
纸上谈兵没意思,真正要练的,是拿到一段代码,能快速准确地分析出每个函数调用的调用点。我自己的方法是走一套固定流程,四步走:
第一步,找出代码里所有函数调用,标注调用形式。这一步是在脑子里过一遍,划出每个函数被调用的位置。
第二步,判断调用形式属于哪一类:是直接调用fn(),还是作为方法调用obj.fn(),还是通过call/apply/bind调用,还是通过new调用,还是箭头函数。
第三步,根据调用形式确定this:直接调用看严格模式和全局;方法调用看点前面是谁;显式调用看传入对象是谁;new调用看新对象是谁;箭头函数看定义处外层作用域的this。
第四步,如果遇到复合场景(比如bind之后的函数又被调用、方法赋值后又用call调用),按照优先级和调用顺序逐步推理。
这套流程看着简单,但实际操作中需要大量练习才能形成条件反射。我下面拿几个经典场景逐个拆解。
3.2 场景一:方法赋值后调用
const animal = { name: '小猫', speak() { console.log(`${this.name}在叫`); } }; const speakFn = animal.speak; speakFn();分析:speakFn()是一个直接调用,调用者不是animal,而是全局环境。因此this指向全局对象(非严格模式)或undefined(严格模式)。所以speakFn执行时,this.name大概率是undefined,输出undefined在叫。
这个题目是面试高频题,本质就是考的“方法的引用被抽出来了,调用者丢了”。修复方式有两种:要么在赋值时用bind把animal绑上去,要么不要在赋值后光秃秃地调用。
3.3 场景二:回调函数里的this
const numbers = [1, 2, 3]; const helper = { factor: 10, multiply(arr) { return arr.map(function (item) { return item * this.factor; }); } }; const result = helper.multiply(numbers);这里helper.multiply(numbers)调用时,multiply内部的this是helper,没问题。但map里的回调是一个普通函数,它在执行时是map内部直接调用的,调用者是undefined(严格模式)或全局(非严格模式),因此this.factor取不到helper.factor,结果是item * undefined,也就是NaN。
如果把这个回调改成箭头函数,就能正常得到[10, 20, 30]。为什么?因为箭头函数没有自己的this,它在定义时捕获了外层multiply函数作用域的this,而multiply的this是helper。这就是所谓“词法作用域的this”——箭头函数把this流动到了定义它的那一刻。
这种例子在业务代码里太常见了,特别是在处理数组、Promise回调、异步函数时。我建议在所有这类场景里,优先使用箭头函数,简单又安全。
3.4 场景三:bind、call和new的联合使用
这是一道比较狠的面试题:
function Foo() { this.name = 'Foo实例'; } const obj = { name: 'obj' }; const BoundFoo = Foo.bind(obj); const instance = new BoundFoo(); console.log(instance.name);我们来分析。Foo.bind(obj)返回一个新函数BoundFoo,这个函数的this被固定为obj。但是当用new调用BoundFoo时,new操作符创建了一个新对象,并且这个新对象会覆盖bind的this绑定。所以在Foo内部,this.name = 'Foo实例'里的this,不是obj,而是new创建出来的新对象instance,因此instance.name是Foo实例。
这个知识点官方文档里写得比较隐晦,但实际验证过确实如此。记住一个结论:new绑定的优先级高于bind。这是唯一一个能“打破”bind固定this的情况。这种题目在实际开发中遇到概率极低,但在面试中属于区分中高级开发者的经典题。
3.5 场景四:事件监听与类方法
在React类组件时代,这个坑几乎人人踩过:
class Button extends React.Component { constructor(props) { super(props); this.state = { clicked: false }; } handleClick() { this.setState({ clicked: true }); } render() { return <button onClick={this.handleClick}>点击</button>; } }如果在render里直接写onClick={this.handleClick},等到点击事件触发时,this已经丢了。因为React事件系统调用回调时,不会以Button实例作为调用者。解决办法就是在constructor里加上this.handleClick = this.handleClick.bind(this),或者在JSX里写成箭头函数onClick={() => this.handleClick()}。
这个场景几乎可以当作一个“this丢失的教科书案例”来理解:类的实例方法本质上是一个普通函数,它本身不自动绑定this,只有通过实例调用时,点前面才是实例。一旦你把它单独拿出来当作回调传出去,调用者就变了。
4. 常见问题与排查技巧实录
4.1 回调函数中this丢失的三种修复方案
实际开发中,this丢失几乎都发生在回调场景。我总结了三种主流修复方案,按使用频率排序。
第一种,绑定固定this,用bind。这个前面已经说过,适用于你已经明确知道回调执行时想要谁作为this。
第二种,利用箭头函数捕获外层this。箭头函数代码简洁,没有多余的this绑定,而且遵循词法作用域规则,不会出现“this被调用者override”的问题。
第三种,在调用位置直接改成箭头函数包装。如果回调的this预期值就是当前调用点所在作用域的this,那就是直接在代码里写() => targetFunction(params)。
这三种方案适用场景略有不同:bind最通用,能保留原函数引用;箭头函数适合内联回调场景;箭头函数包装适合需要传参数时。我在实际代码评审时,往往会建议团队统一约定:除非有特殊原因,否则回调一律用箭头函数。
4.2 定时器与异步操作中的this
const timer = { seconds: 0, start() { setInterval(function () { this.seconds++; console.log(this.seconds); }, 1000); } }; timer.start();这个例子初看没问题,实际上每次输出都是NaN。因为setInterval的回调是定时器系统直接调用的,使用普通函数时this指向全局对象,this.seconds是undefined,加1之后变成NaN。
很多人第一反应是改成箭头函数:
start() { setInterval(() => { this.seconds++; }, 1000); }这样确实能用,因为箭头函数捕获了start方法作用域的this,而start的this是timer。如果你更倾向于不用箭头函数,也可以:
start() { setInterval(function () { this.seconds++; }.bind(this), 1000); }这两种写法在结果上没有区别,但箭头函数会让我写起来更舒服,因为不需要在回调里再想“要绑谁”。
4.3 解构赋值与方法提取引起的this丢失
ES6解构特别方便,但也特别容易在无意中丢this:
const store = { count: 0, increment() { this.count++; } }; const { increment } = store; increment();这里increment()是光秃秃的调用,调用者不是store,而是全局,所以this.count变成了NaN。类似的问题还会出现在把对象方法传给第三方库时,比如array.map(store.method)、Promise.resolve().then(store.method)等。
在代码评审中,我经常提醒团队:把方法单独拿出来用之前,先想想它内部有没有依赖this。如果依赖,就应该使用bind或者箭头函数包装来确保this不丢。
4.4 如何用调试工具快速确认this指向
有时候分析半天不如实际打断点看一眼。在Chrome DevTools里,给函数内部加断点,然后在Console面板执行console.log(this),就能直接看到当前的this指向谁。这一招非常快,尤其适合排查复杂调用链。
另外一个很有用的方式是利用Function.prototype.call的行为反推。如果你怀疑某个函数内部this可能不对,可以在调用它之前显式地调用fn.call(某个对象, ...args),如果能修复问题,说明问题确实出在this绑定上,再进一步判断该怎么改。
4.5 常见问题速查表
我把开发中最常见的几种“this异常”场景整理成一个速查表,方便你排查时快速对照。
| 场景 | 表现 | 根本原因 | 推荐修复方式 |
|---|---|---|---|
| 对象方法直接调用 | 正常 | 调用者是对象本身 | 无需修复 |
| 方法赋值后调用 | this指向全局或undefined | 调用者变成了全局环境 | bind或箭头函数 |
| 回调函数传参 | this丢失 | 第三方库以自身上下文调用 | bind或箭头函数 |
| 定时器/事件监听 | this指向window/元素 | 宿主环境直接调用回调 | bind或箭头函数 |
| 嵌套普通函数 | this与外层不一致 | 普通函数不继承外层this | 箭头函数或var that = this |
| new调用bind返回函数 | 指向新对象而非bind目标 | new优先级高于bind | 无需修复,理解即可 |
| 类方法作为事件回调 | this丢失 | 方法未绑定实例 | 构造函数里bind或箭头函数 |
这张表覆盖了我在实际项目中遇到的绝大多数this问题。如果你的场景不在表里,大概率属于“函数被以某种特殊方式间接调用”的情况,回到调用者视角去分析就行。
5. 写在最后:我的练习方法与经验总结
我教新人学this,从来不让他们背规则,我让他们做一件事:找一段别人写的代码,把里面所有函数的调用画出来,标出每个调用点的调用者,然后输出预测结果,再实际运行验证。坚持练上十几道题,基本就能形成肌肉记忆。
这个方法听起来简单,但真的比看十篇文章都管用。因为一旦你养成了“先看调用者,再判断this”的习惯,以后写代码时就会下意识地避免写出容易被误解的this绑定,代码质量自然会提升。
还有一个小技巧是,团队项目里如果this问题反复出现,我建议在代码规范里做一条约定:回调函数优先使用箭头函数;类方法如果作为回调使用,要么在构造函数里bind,要么就在调用点用箭头函数包一层。别小看这个约定,它能直接砍掉一多半this相关的debug时间。
最后再说一句:别被“this”这个名字带偏了,它并不是“这个函数所属的对象”,而是“这次调用所属的对象”。区别在于,前者是静态的、确定的,后者是动态的、取决于调用方式的。理解了“每次调用都是一次独立的现场”,你就能彻底掌握this了。