1. 从“指向”说起:this 为什么让这么多人翻车
前阵子接了个需求,要给一个内部工具加一个事件上报模块。代码写得挺顺手,结果一上线,数据全没报上来。打开控制台一看,this指向了undefined,当场血压就上来了。
这个场景你大概率也经历过。JavaScript 里的this堪称语言中最容易让人懵圈的机制之一,面试必考、实战必踩。每次你以为摸清了它的脾气,换个调用方式它立刻翻脸。更有意思的是,“指向”这个词在别的领域也常常出现——CAD 画图时所有线条汇聚到同一个锚点、卫星仿真系统里天线要对准某颗星的指向逻辑,本质上都在解决“让某某对准某某”的问题。JS 的this也是这么回事:它决定了函数执行的时候,代码里的this到底“对准”哪个对象。
这份学习包的主题就是 it——this指向。我会把四种绑定规则、箭头函数的特殊逻辑、实战里最常见的翻车点、以及面试常考的那几道题一次性讲透。不管你是刚学完基础、还没被this毒打过的初学者,还是已经写了一阵子代码但遇到回调就心里发虚的开发者,这篇内容都可以直接拿来当参考。
在动手写之前,先把一个最关键的认知刻进脑子里:this不是在函数定义的时候确定的,而是在函数调用的时候确定的。这句话是理解后面所有内容的地基。很多人之所以困惑,就是下意识觉得“这个函数写在哪个对象里面,this就应该指向哪个对象”。真相并不是这样。函数只是定义在某个位置,但它被谁调用、怎么调用,才真正决定this落向何处。
举个最直白的例子。你写了一个对象,里面有个方法:
const user = { name: '张三', greet: function () { console.log(this.name) } } user.greet() // 输出 张三这时候this指向user,没问题。但如果你把方法单独拿出来再调用:
const fn = user.greet fn() // 输出 undefined(非严格模式下是 window.name)同一个函数,定义没变,但调用方式变了,this的指向就完全不同。所以接下来要做的,就是搞清楚 JavaScript 在函数调用那一刻,到底按什么规则来决定this指向谁。
2. 四条绑定规则:默认、隐式、显式、new
2.1 默认绑定:没人管的时候,this 归谁
先来看最简单的情况。直接调用一个普通函数,没有人给它指定“调用者”,也没有任何修饰,这时候this走的是默认绑定规则。
function sayHi() { console.log(this) } sayHi()在浏览器环境里,上面这段代码的this指向window;如果代码跑了严格模式('use strict'),this就是undefined。很多人第一次栽跟头就是栽在这——他们以为函数里有个this,那this就应该指向“函数本身”,或者至少指向某个合理的东西,结果非严格模式下指向了全局对象,严格模式下直接是个 undefined。
这里补充一个容易混淆的点:默认绑定里的“默认”只针对普通函数调用,方法调用、call调用都不走这条规则。还有,ES6 模块本身就是严格模式,所以在现代前端工程里(比如用 Vite、Webpack 构建的项目),直接调用一个普通函数,this往往就是undefined。这也是为什么“回调函数里访问this拿到 undefined”的新人问题比比皆是——不是框架的错,是默认绑定规则在起作用。
2.2 隐式绑定:谁调用,this 指向谁
如果一个函数作为某个对象的方法来调用,比如前面例子里的user.greet(),那this就会隐式绑定到这个对象身上。规则一句话:看调用时点号左边是谁。点号左边的那个对象,就是this的指向目标。
听起来很简单对吧?但这里埋伏着 JS 里最著名的坑之一:链式调用只认最后一次点号左边的对象。
const objA = { name: 'A', method: function () { console.log(this.name) } } const objB = { name: 'B', inner: objA } objB.inner.method() // 输出 A,不是 BobjB.inner.method()里,真正作为“调用者”出现的是objB.inner,也就是objA本身,所以this指向objA。你从直觉上可能觉得“这个方法写在objB里面嘛”,但规则不是按嵌套层级来算的,只看最后一个点号前面的对象。
隐式绑定的另一个大坑是丢失。回到开头那个例子:
const user = { name: '张三', greet: function () { console.log(this.name) } } const fn = user.greet fn() // undefined 或 window.name把方法赋值给一个变量再调用,点号没了,隐式绑定就失效了,退化成默认绑定。类似的场景还有把方法当作参数传给别的函数、塞进setTimeout里回调等,都会导致“方法还在,但this丢了”。
2.3 显式绑定:用 call、apply、bind 强行指定 this
既然隐式绑定会因为“没点号”而失效,那有没有办法绕开“必须通过对象调用”这个限制,直接指定this?有,这就是显式绑定——call、apply、bind三兄弟登场。
function introduce(age, city) { console.log(`我是${this.name},今年${age}岁,来自${city}`) } const person = { name: '李四' } introduce.call(person, 28, '北京') introduce.apply(person, [28, '北京'])call和apply的区别只在参数传递方式上:call用一个个参数,apply用数组。而bind更特别,它不会立即执行函数,而是返回一个新函数,这个新函数的this被永久绑定到传入的对象上:
const boundIntroduce = introduce.bind(person) boundIntroduce(28, '北京')注意bind的“永久”二字——一旦绑定了,后面你再call、再apply都改不回来。这在某些场景下是优点,但也意味着你要小心,别在需要动态切换this的地方用了bind,导致踩出新的坑。
2.4 new 绑定:构造函数调用时的特殊待遇
最后一个规则是new绑定。当使用new关键字调用一个函数时(这个函数此时被称为构造函数),this会指向新创建的那个对象:
function Person(name) { this.name = name } const p = new Person('王五') console.log(p.name) // 王五new调用过程中,JavaScript 引擎在后台做了几件事:创建一个新对象、把这个新对象的原型链接到构造函数的prototype、把构造函数里的this绑定到这个新对象上、最后如果构造函数没有显式返回对象则返回这个新对象。我们只要记住结果:用new调用,this就指向新实例。
2.5 优先级排排坐
四种规则不是平等的,它们有严格的优先级顺序。从低到高分别是:
| 规则 | 优先级 | 典型写法 |
|---|---|---|
| 默认绑定 | 最低 | fn() |
| 隐式绑定 | 中等 | obj.fn() |
| 显式绑定 | 较高 | fn.call(obj) |
| new 绑定 | 最高 | new Fn() |
这个顺序怎么验证?最简单的方法是制造冲突。比如一个函数同时用call指定了this,又用new来调用,最终this指向的是 new 出来的新对象,而不是call传进去的那个:
function Test() { console.log(this) } const fakeObj = { tag: '我是假的' } const result = new Test.call(fakeObj) // 这行直接会报错,因为 new 和 call 不能这么写在一起实际操作中冲突不会那么明显,但理解优先级的意义在于,面试题里经常出现“既用了obj.fn又把函数传给bind”的混合写法,这时候就是优先级规则发挥作用的时候。一道典型的题:
const obj = { name: '对象', fn: function () { console.log(this.name) } } const boundFn = obj.fn.bind({ name: '绑定对象' }) boundFn() // 输出 绑定对象obj.fn虽然拿到了一个“来自 obj 的方法”,但bind的优先级高于隐式绑定,所以this指向了bind传入的对象。很多人在这一步开始犯迷糊,就是因为没有建立清晰的优先级意识。
3. 箭头函数:唯一跳出规则的例外
3.1 词法 this:箭头函数没有自己的 this
上面说的四条规则,对箭头函数统统不适用。箭头函数压根没有自己的this,它里面的this是从外层作用域继承下来的。说得再直白一点:箭头函数定义在哪个作用域,它的this就跟那个作用域里的this一模一样。
const obj = { name: '箭头测试', regularFn: function () { console.log(this.name) // 普通函数,this 指向 obj }, arrowFn: () => { console.log(this.name) // 箭头函数,this 继承自外层,即 window/undefined } } obj.regularFn() // 箭头测试 obj.arrowFn() // undefined在这个例子里,obj对象字面量本身不产生新的作用域,所以arrowFn的this继承的是全局作用域。这就是很多人吐槽“箭头函数里的 this 怎么也是 undefined”的原因——不是箭头函数不能用,而是它的this本来就不由调用方式决定。
3.2 箭头函数什么时候香,什么时候别碰
箭头函数最香的场景,是那些需要“保住外层 this”的回调。比如setTimeout内部:
const timer = { count: 0, start: function () { setTimeout(() => { this.count++ console.log(this.count) }, 1000) } } timer.start() // 1如果这里不用箭头函数,写成普通函数,setTimeout里的this会遵循默认绑定规则变成undefined(严格模式下),this.count直接报错。箭头函数把外层start方法里的this(也就是timer)成功地“传递”了进去,这就是它在实战中最大的价值。
但箭头函数也有一些必须避开的地方:
- 不能作为构造函数,用
new调用直接报错; - 不适合需要动态
this的方法,比如事件处理器里你可能需要根据事件源来确定this; - 在对象方法里用箭头函数要三思,因为对象字面量不构成作用域,
this往往不是你以为的那个对象。
3.3 一个常见误解:箭头函数能“改 this”?
网上常有人说“用箭头函数绑定 this”,这话不准确。箭头函数不是“绑定”了一个 this,而是根本没有自己的 this,它只会向上层找。所以call、apply、bind对箭头函数都是无效的:
const arrow = () => { console.log(this) } arrow.call({ name: '试图绑定' }) // 无效,this 还是外层那个理解了这一点,你就不会再在箭头函数上白费力气做显式绑定了。
4. 实战排坑:项目中最常见的 this 事故现场
4.1 setTimeout 里的 this 流亡
项目中最高频的 this 事故,出现在定时器回调里。普通函数作为回调传入setTimeout时,它被“剥”离了原对象,只能走默认绑定。
const player = { current: 0, update: function () { setTimeout(function () { this.current++ console.log(this.current) }, 100) } } player.update() // 报错或者输出 NaN修复方案有两个:一是用箭头函数把外层的this传进回调;二是在外面先把this存成一个变量(经典的var self = this手法):
// 方案一:箭头函数 update: function () { setTimeout(() => { this.current++ console.log(this.current) }, 100) } // 方案二:保存 this update: function () { const self = this setTimeout(function () { self.current++ console.log(self.current) }, 100) }方案二看着土,但在一些需要动态this的复杂场景里反而更灵活。两种方案我都用过,优先推荐箭头函数,代码简洁且语义清晰。
4.2 事件回调:this 指向事件源而不是组件
在 DOM 事件回调里,this默认指向触发事件的元素。这在原生事件处理没问题,但一旦涉及组件开发,就很容易出现“我想访问组件数据,结果this是那个按钮”的尴尬。
button.addEventListener('click', function () { console.log(this) // 按钮元素 })在 Vue 或 React 的世界里,这个问题通常由框架解决(Vue 的 methods 里的this指向组件实例,React 类组件需要手动 bind 或者用箭头函数),但如果你在原生环境下自己封装一个小工具,就一定得记得这一层差异。
4.3 方法被当成回调传递:隐式绑定说丢就丢
这是所有 this 坑里最隐蔽的一个。你明明把一个带this的方法传给了别人,别人一调用,this就丢了。经典案例是数组的forEach、map:
const calculator = { factor: 2, doubleAll: function (arr) { return arr.map(function (item) { return item * this.factor // this 在这里是 undefined(严格模式)或 window }) } } calculator.doubleAll([1, 2, 3]) // 报错或得到 NaN注意,这里不是map本身把this弄丢了,而是map的回调函数在调用时没有任何“点号前缀”,走了默认绑定。解法依然是箭头函数或者在外层保存this。另外,map、forEach其实都支持第二个可选参数来指定回调里的this,这也是一种显式绑定思路:
arr.map(function (item) { return item * this.factor }, this) // 第二参数指定 this不过这个特性用的人不多,而且语义上不如箭头函数直观,所以我更推荐直接写箭头函数。
4.4 排查方法:一条清晰的定位思路
遇到this不对,先别急着改代码,按这个顺序过一遍:
- 看调用形式:函数是直接调用还是作为方法调用?前面有没有点号?
- 看是否用了 call/apply/bind:显式绑定会覆盖隐式绑定,但会被 new 覆盖。
- 看函数类型:是不是箭头函数?箭头函数不看调用方式,只看定义位置。
- 看是否传入回调:如果函数被作为参数传给另一个函数,它在回调里被调用时很可能走默认绑定。
- 打开控制台:在函数第一行打印
this,看它到底是谁。
这个方法我每次排查 this 相关 bug 都会用,五分钟内基本能定位问题。记住一点:不要凭感觉猜 this,永远是“看调用现场”。
5. 面试必考题:高频 this 指向题全拆解
5.1 经典题一:五连问
下面这段代码是面试中出现频率很高的题,每一步都问一次输出:
const obj = { name: 'obj', fn: function () { console.log(this.name) } } const fn = obj.fn obj.fn() // 1. ? fn() // 2. ? obj.fn.call({ name: 'other' }) // 3. ? const bound = obj.fn.bind({ name: 'bound' }) bound() // 4. ? const arrowFn = () => console.log(this.name) arrowFn.call({ name: 'arrowCall' }) // 5. ?逐一拆解:
obj.fn()—— 隐式绑定,this指向obj,输出obj。fn()—— 方法提取后调用,隐式绑定丢失,默认绑定,严格模式下this是undefined,会报错;非严格模式下是全局对象,输出undefined(因为没有全局name)或全局的name。obj.fn.call({...})—— 显式绑定优先于隐式绑定,输出other。bound()——bind的结果,this被永久绑定到{ name: 'bound' },输出bound。arrowFn.call(...)—— 箭头函数不受call影响,this是外层作用域的this,在模块环境里是undefined,会报错。
这道题覆盖了优先级、丢失、箭头函数三个核心知识点,能独立答对,this 的掌握程度就超过了大多数初级开发者。
5.2 经典题二:new 和显式绑定的碰撞
function Foo(name) { this.name = name } const obj = { name: 'obj' } const BoundFoo = Foo.bind(obj) const instance = new BoundFoo('newName') console.log(instance.name) // ?按照优先级,new绑定高于bind显式绑定,所以即使BoundFoo是Foo.bind(obj)的产物,new BoundFoo()依然会创建一个新对象,并把this绑定到这个新对象上。输出newName,而不是obj。这是 MDN 官方文档里也明确提到过的特例。顺带一提,bind有一个隐藏特性:如果绑定函数的返回值是一个对象,new操作符会忽略这个对象的this指向,但仍然会返回这个对象。这点不需要死记,了解即可。
5.3 手写 bind:理解的不只是 API 而是机制
面试里还有一个进阶问题:手写一个bind。你能写出来,说明对this的调用关系有真理解。一个简化版实现:
Function.prototype.myBind = function (context, ...args) { const originalFn = this return function (...innerArgs) { return originalFn.apply(context, [...args, ...innerArgs]) } }核心就一步:用apply把context显式绑定到原函数上。这里要注意两点:一是this在myBind里指向调用它的那个函数(因为它是方法调用),二是返回的新函数要保留参数拼接的能力。如果再严谨一点,还得处理“新函数被new调用”的情况,不过那属于进阶中的进阶,面试能写出上面这个版本已经够用了。
5.4 实际面试中的加分回答
面试官问到this时,除了答规则,还有一个加分项:主动说出“this的指向是在调用时确定的,而不是定义时确定的”,并且能结合严格模式和箭头函数的差异做对比。这一句话能体现出你对执行上下文的理解深度,而不是背了一堆结论。我面过不少候选人,能主动把“调用栈”“执行上下文”这些词说出来的人,基本都对 JS 引擎的运行机制有更扎实的认识。
6. 综合案例:在一个业务场景里看 this 的流动
最后用一个贴近日常开发的综合案例来收尾,看看this是怎么在真实代码里流动的。假设我们要写一个小模块:一个游戏角色管理器,它要能记录自己的血量、攻击敌人、并且在延时后自动回血。
const hero = { hp: 100, name: '战士', attack: function (target) { console.log(`${this.name} 对 ${target} 发起攻击`) target.hp -= 10 }, heal: function () { console.log(`${this.name} 开始回血`) setTimeout(() => { this.hp += 20 console.log(`${this.name} 回血成功,当前 HP:${this.hp}`) }, 1000) } } const monster = { name: '哥布林', hp: 50 } hero.attack(monster) hero.heal()这段代码里有几个值得注意的地方:
hero.attack(monster)是标准的隐式绑定,this指向hero,所以能正确读到hero.name。heal方法里的setTimeout回调用了箭头函数,所以this继承自heal方法执行时的this(也就是hero)。如果把箭头函数换成普通函数,这里就会报错——一个非常典型的“箭头函数救场”场景。- 如果我想让
attack方法被单独提取出来用,比如在某个列表渲染时绑定事件:
const standaloneAttack = hero.attack standaloneAttack(monster) // 报错:this 是 undefined这时候就需要显式绑定了:
const safeAttack = hero.attack.bind(hero) safeAttack(monster) // 正常看到没有?同一个方法,三种不同用法,this走了三条完全不同的路。实际项目里的this问题,基本都是这三种形态的组合。
再往深一层,这个案例还揭示了一个设计层面的思路:当一个函数内部依赖this时,它其实是在依赖调用时的上下文,这使得函数本身并不“自包含”。所以很多成熟的设计会选择不依赖this,而是把依赖的数据作为参数传入(纯函数思想),或者用闭包持有状态。了解this的规则,不仅能帮你 debug,还能帮你判断“这个函数到底该不该用this”。
我自己的习惯是:在写代码时就提前判断这个函数将来可能被怎样调用。如果它大概率会被提取出来单独用、被当作回调传递,那我会优先考虑用闭包变量替代this,或者直接在内部做一次bind,提前把炸点拆掉。这比事后排查要省力得多。
7. 一句口诀和最后的经验
如果要把这篇文章浓缩成一句话,那就是:看调用方式,不看定义位置;看有没有点号,看有没有 call/bind,看是不是箭头函数,看是不是 new 调用。这四眼看下来,90% 的this问题都能当场解决。
最后再分享一个我实际工作里的偏好。很多同学学this时会陷入一个误区——试图给每种情况找一个“直觉上合理的解释”。但我建议你反过来,以规则本身为准,把直觉摁住。this不像人类语言那样讲“道理”,它只有一条硬邦邦的运行时规则。你越早接受“它就是这样绑定的”,就越少在这种问题上内耗。等你哪天写代码时对this的预期已经变成一种肌肉记忆,不会再刻意去想规则,那就说明你真的理解了。