有个 JavaScript 的类(或对象),里面有个方法,逻辑是处理点击事件、然后更新自己的一个属性。代码我写得清清爽爽:class Counter { count = 0; handleClick() { this.count++; render(this.count); } }。然后我把这个方法当回调,绑到了一个按钮上:button.addEventListener('click', counter.handleClick)。本地一测,点击按钮,"啪"——控制台一行红字:TypeError: Cannot read properties of undefined (reading 'count')。this.count 报错了,说 this 是 undefined。我盯着代码直发懵:handleClick 明明是 counter 对象的方法,我也是从 counter 上把它取出来的,怎么一执行,它里面的 this 就不是 counter 了?
我查了好一阵,才把这个 JavaScript 里最让人头疼、也最经典的坑彻底想明白:JavaScript 里的 this,它指向谁,不是由"方法定义在哪个对象上"决定的,而是由"这个函数是怎么被调用的"决定的。当我写 counter.handleClick() 时,是"通过 counter 调用",this 才指向 counter;可当我把 counter.handleClick 这个函数取出来、当作一个独立的回调传给 addEventListener 后,它就和 counter "脱钩"了——后来事件触发时,是事件系统在内部"独立地"调用这个函数(类似 fn()),而不是"通过 counter"调用它。于是这个函数里的 this,就不再指向 counter 了(在严格模式/模块里,它是 undefined),访问 this.count 自然就炸了。
这就是 JavaScript 里几乎每个人都会被绊倒的经典难题:this 的指向丢失。它的根源,是 JS 的 this 有一套和大多数语言都不同的、"由调用方式动态决定"的绑定规则;而当我们把一个对象的方法"剥离"出来、作为回调传递时,就极容易在不经意间,让它丢失了对原对象的指向。这篇文章,就从这次"方法当回调,this 就丢了"的事故出发,把 this 的绑定规则、以及如何正确地"锁住"它,一次讲透。
先摆几个关于 this 的想当然
动手复盘前,先把我自己曾经深信、后来被这个 this 教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "方法里的 this, 当然指向它所属的对象" | this 指向谁, 由"怎么调用"决定, 不是由"定义在哪"决定 |
| "从对象上取出方法, 它还记得自己的主人" | 取出来当独立函数调用, this 就和对象脱钩了 |
| "把方法当回调传过去, 没什么区别" | 区别巨大: 回调被独立调用, this 丢失 |
| "this 和别的语言一样, 是固定的" | JS 的 this 是动态的, 同一函数不同调用方式指向不同 |
| "箭头函数和普通函数, 就是写法不同" | 箭头函数没有自己的 this, 这是本质区别 |
这些念头的共同病根,是把 JavaScript 的 this,想当然地等同于了其它面向对象语言里那个"永远指向当前实例"的、固定的 this。可 JS 的 this 完全是另一套逻辑——它是动态的、由调用现场决定的。要看清这次事故,得先理解 this 的绑定规则到底是怎样的。
第一件事:this 由"调用方式"决定,而非"定义位置"
理解 this 的金钥匙,就一句话:在 JavaScript 里,一个普通函数内部的 this 指向谁,取决于这个函数"是如何被调用的",而不是它"定义在哪里"。同一个函数,用不同的方式去调用,它里面的 this 可以指向完全不同的东西。这套"调用时才确定"的动态绑定,正是 JS 的 this 让无数人困惑的根源。大致有几种调用方式,对应几种 this 指向:
function show() { console.log(this); }
const obj = { name: 'obj', show: show };
// 1. 作为对象的方法调用:this 指向那个对象(谁调用, 指向谁)
obj.show(); // this 是 obj
// 2. 作为独立函数调用:this 是 undefined(严格模式/模块)或 全局对象
show(); // this 是 undefined —— 这就是回调丢失的情形!
// 3. 把方法"取出来"再独立调用:同样丢失!
const fn = obj.show;
fn(); // this 是 undefined, 不再是 obj!
// 这正是 addEventListener(counter.handleClick) 的本质
// 4. 用 call/apply/bind 显式指定 this
show.call(obj); // this 是 obj(强行指定)
看懂这几种调用方式,我那次事故的根就清楚了:button.addEventListener('click', counter.handleClick) 这行,做的其实是"把 counter.handleClick 这个函数引用取出来,交给事件系统"——这等价于上面的第 3 种情形(const fn = obj.show; fn())。后来事件触发时,事件系统是"独立地"调用这个函数的,所以 this 丢了。下面这张图,把这个"脱钩"过程画出来:
看懂这张图,关键认知就立住了:"counter.handleClick()"(带括号调用)和"counter.handleClick"(只取函数引用)是两件完全不同的事——前者是"通过 counter 调用",this 正确;后者只是把函数本身拿出来,它一旦被别处独立调用,就和 counter 没关系了。把一个方法当回调传递,本质上就是"把函数引用取出来",所以它极易丢失 this。接下来,我们就看怎么把 this "焊死"在对象上。
第二件事:用 bind 把 this 焊死在对象上
既然问题是"函数脱离对象后 this 丢了",那解法的核心,就是想办法让这个函数无论被怎么调用,它的 this 都永远指向我们想要的那个对象。第一个经典工具是 bind:它会返回一个新函数,这个新函数的 this 被永久地"绑定"到了你指定的对象上,之后无论它被怎么调用、传到哪里,this 都雷打不动地指向那个对象。
// 反例:直接传方法引用, this 丢失
button.addEventListener('click', counter.handleClick); // this 丢, 报错
// 正解一:用 bind 创建一个 this 被绑定的新函数, 再传过去
button.addEventListener('click', counter.handleClick.bind(counter));
// bind(counter) 返回的新函数, 它的 this 永远是 counter, 怎么调都不丢
// 在 class 里, 常见的做法是在构造函数里把方法 bind 好
class Counter {
count = 0;
constructor() {
// 把方法绑定到实例, 覆盖原方法; 之后随便传都不丢 this
this.handleClick = this.handleClick.bind(this);
}
handleClick() { this.count++; render(this.count); }
}
const counter = new Counter();
button.addEventListener('click', counter.handleClick); // 现在安全了!
bind 的本质,是创造一个"this 已经被锁定"的函数副本。这是 React 类组件时代,大家在构造函数里写一堆 this.handleX = this.handleX.bind(this) 的原因。记住:当你要把一个对象方法当回调传出去、又希望它保留对原对象的 this 指向时,bind 是最经典、最明确的工具——它把"this 应该指向谁"这件事,从"靠调用现场碰运气"变成了"在绑定时就钉死"。
第三件事:更现代、更省心的方案——箭头函数
bind 有效但略显繁琐。现代 JavaScript 里,更优雅的解法是利用箭头函数的一个关键特性:箭头函数没有自己的 this——它不会因为"被怎么调用"而改变 this,而是直接"继承"它定义时所在作用域的 this。换句话说,箭头函数的 this 是"词法的"(看它写在哪),而不是"动态的"(看它怎么被调)。这恰好能完美地解决回调里 this 丢失的问题。
// 正解二:用箭头函数包一层, 它捕获外层的 this
button.addEventListener('click', () => counter.handleClick());
// 箭头函数没有自己的 this; 它内部调用 counter.handleClick() 是
// "通过 counter 调用", 所以 handleClick 里的 this 正确指向 counter
// 正解三(最现代、最推荐):用 class 字段 + 箭头函数定义方法
class Counter {
count = 0;
// 用箭头函数作为类字段:它的 this 永远绑定到实例, 天生不丢
handleClick = () => {
this.count++; // this 永远是这个实例!
render(this.count);
};
}
const counter = new Counter();
button.addEventListener('click', counter.handleClick); // 直接传, 不丢 this!
第三种写法(用箭头函数作为类字段)是现在最受推崇的方式:因为箭头函数继承定义时的 this,而类字段是在实例上初始化的,所以这个箭头函数的 this 就天生、永久地绑定到了实例,你可以随便把它当回调传来传去,this 永远不丢,连 bind 都省了。这也是为什么在写事件处理器、回调函数时,优先用箭头函数已经成了现代 JS 的共识。
但要划清一个重要的边界:箭头函数"没有自己的 this"是优点,也可能是陷阱——它不能用作需要动态 this 的场合。比如,你不应该用箭头函数去定义一个对象的方法(const obj = { name: 'x', show: () => console.log(this.name) }),因为这个箭头函数的 this 继承的是外层(通常是模块/全局)的 this,而不是 obj——你想要的"this 指向 obj"反而落空了。所以:需要 this 动态指向调用者的(如对象方法、原型方法),用普通函数;需要 this 固定指向定义处的(如回调、事件处理器),用箭头函数。分清这两种需求,是用好这两种函数的关键。
第四件事:call 与 apply——临时、显式地指定 this
除了 bind(返回一个 this 永久绑定的新函数),JavaScript 还有两个"亲兄弟":call 和 apply。它们的作用,是立即调用一个函数,并在这一次调用中,临时、显式地指定它的 this 指向谁。区别只在于传参方式:call 把参数一个个列出来,apply 把参数放在一个数组里。它们和 bind 构成了"显式绑定 this"的完整工具集。
function greet(greeting) { console.log(greeting + ', ' + this.name); }
const user = { name: '小明' };
// call:立即调用, 第一个参数指定 this, 后面的参数逐个传
greet.call(user, '你好'); // 你好, 小明(this 临时指向 user)
// apply:立即调用, 第一个参数指定 this, 参数放数组里传
greet.apply(user, ['你好']); // 你好, 小明
// bind:不立即调用, 返回一个 this 已绑定的新函数(供以后调用)
const boundGreet = greet.bind(user);
boundGreet('你好'); // 你好, 小明
// 三者对比:
// call/apply —— "现在就调, 这次的 this 是它"(一次性)
// bind —— "造个新函数, 它的 this 永远是它"(可重复用)
这三个方法,是 JavaScript 给我们的"手动接管 this"的方向盘:当 this 的默认绑定规则不符合你的需要时,你可以用它们显式地、强制地把 this 指向你想要的对象。它们在很多场景下都很有用——比如借用别的对象的方法、给回调函数预设上下文等。理解 call/apply(立即调用、临时指定)和 bind(返回新函数、永久绑定)的区别,你就掌握了精确控制 this 的全部手段。
第五件事:这些常见场景里,this 最容易丢
this 丢失,在几类常见场景里反复出现,认得它们能帮你提前避坑。除了前面的"方法当事件回调",还有几个高发地:setTimeout/setInterval 的回调、数组方法(forEach/map 等)的回调、以及 Promise 的 then 回调——它们都是"把函数交给别的系统去独立调用",所以普通函数写法里的 this 都会丢。
class Timer {
seconds = 0;
start() {
// 反例:setTimeout 的回调里, 普通 function 的 this 丢失
setTimeout(function () {
this.seconds++; // this 不是 Timer 实例! 报错
}, 1000);
// 正解:用箭头函数, 它继承 start 方法里的 this(即实例)
setTimeout(() => {
this.seconds++; // this 正确指向实例!
}, 1000);
}
process(items) {
// 反例:forEach 普通函数回调里 this 丢失
items.forEach(function (item) {
this.handle(item); // this 不对! 报错
});
// 正解:箭头函数继承外层 this
items.forEach((item) => this.handle(item)); // this 正确
}
handle(item) { /* ... */ }
}
这些场景的共同模式是:你把一个函数"交给"了另一个系统(定时器、数组方法、Promise),由它在未来某个时刻去"独立地"调用——而独立调用,this 就丢了。认清这个模式,你就有了一个简单的预警雷达:每当你要把一个函数作为参数"交出去"、让别人以后去调用时,就要警觉它里面的 this——而最省心的统一对策,就是用箭头函数。箭头函数继承定义处的 this,无论被谁、在何时独立调用,this 都稳稳地指向你定义它时所在的那个上下文。
第六件事:还有一种 this——new 调用与构造函数
为了让 this 的图景完整,还要提一种调用方式:用 new 调用一个函数(构造函数)时,this 指向那个新创建出来的实例对象。这是 this 绑定规则里的又一种情形。结合前面,this 的指向其实就是几条规则按优先级裁定的:
// this 绑定规则, 按优先级从高到低:
// 1. new 绑定:new Fn() —— this 是新创建的实例
function Person(name) { this.name = name; } // new Person('x') 时 this 是新对象
// 2. 显式绑定:call/apply/bind —— this 是你指定的对象
fn.call(obj); // this 是 obj
// 3. 隐式绑定:obj.fn() —— this 是点号前面的对象
obj.fn(); // this 是 obj
// 4. 默认绑定:fn() 独立调用 —— this 是 undefined(严格)或全局
fn(); // this 是 undefined
// 箭头函数是"例外":它没有自己的 this, 不参与上面的规则,
// 永远继承定义时所在作用域的 this(词法绑定)
把这几条规则记在心里,你就能在任何场景下,准确判断出 this 到底指向谁:先看是不是箭头函数(是则看定义处);不是的话,依次看是不是 new 调用、有没有 call/apply/bind 显式指定、是不是 obj.fn() 形式的隐式调用,最后才落到"独立调用 this 为 undefined"。这套"按调用方式判断"的思维,是彻底搞懂 this 的钥匙。到这儿,this 的方方面面就齐了。我把判断与应对思路收成一张决策图:
把这套理解建立起来,this 丢失这类经典 JS bug 就再也唬不住你了。最后,拧成几条可直接照做的铁律:
- this 由"调用方式"决定, 不由"定义位置"决定,这是理解 this 的总钥匙。
- 把方法当回调传出去, this 极易丢失,因为它会被别的系统独立调用。
- 回调/事件处理器优先用箭头函数,它继承定义处的 this, 怎么调都不丢。
- class 里的处理器方法用箭头函数类字段,this 天生绑定实例, 最省心。
- 需要永久绑定 this 用 bind, 临时指定用 call/apply,掌握显式绑定三件套。
- 别用箭头函数定义对象方法/原型方法,那里需要动态 this, 箭头函数会让它指向外层。
- 记住 this 绑定的优先级:new 大于 显式(call/bind) 大于 隐式(obj.fn) 大于 默认(独立调用)。
一张 this 指向速查表
把各种调用方式下的 this 指向汇成一张表,判断 this 时对照着查。
| 调用方式 | this 指向 | 例子 |
|---|---|---|
| obj.method() | obj(点号前的对象) | counter.handleClick() |
| 独立调用 fn() | undefined(严格)/ 全局 | 取出方法当回调, this 丢 |
| new Fn() | 新创建的实例 | new Person() |
| fn.call/apply(obj) | obj(显式临时指定) | greet.call(user) |
| fn.bind(obj)() | obj(永久绑定) | handler.bind(this) |
| 箭头函数 | 定义时外层的 this | () => this.x(继承) |
| 事件处理器(addEventListener) | 触发事件的 DOM 元素 | 普通函数里 this 是该元素 |
一个有趣的对比:为什么 JS 的 this 这么"特别"
修好之后我想了个更本质的问题:为什么 JavaScript 的 this,要设计得这么"动态"、这么容易让人困惑?在 Java、C++、Python 这些语言里,this(或 self)的指向是确定的、就是当前实例,从来不会"丢"。可 JS 偏偏要搞一套"由调用方式决定"的规则。这背后,其实和 JS 的"函数是一等公民"的设计哲学有关。
在 JavaScript 里,函数不是某个类的附属品,而是独立的、可以被自由传递的"值"——你可以把一个函数赋给变量、当参数传递、当返回值返回,它可以脱离任何对象而存在。正因为函数如此"自由",JS 就需要一套机制,在函数被调用的那一刻,动态地决定"这次调用,this 该是谁"——这就是 this 动态绑定的由来。可以说,this 的"善变",是 JavaScript "函数自由"这一强大特性所付出的代价。理解了这层因果,你就不会再觉得 this 是个莫名其妙的设计,而会明白它是 JS 灵活性的一体两面——它带来了函数式编程的极大自由,也带来了 this 指向的这份微妙。而箭头函数的出现(它放弃了动态 this、改为词法 this),正是语言演进中,为了在某些场景下消除这份微妙、给开发者提供更符合直觉的选择,而做出的一次贴心补充。
写在最后
这次"方法当回调,this 就丢了"的事故,给我最深的体会,是它再一次印证了那个朴素却深刻的道理:很多最磨人的 bug,都源于我们把"在别的语境里成立的直觉",想当然地搬到了一个有着不同规则的新语境里。我对 this 的直觉,是从那些"this 永远指向当前实例"的语言里带来的,它牢固、自然,以至于我从未怀疑过它。可 JavaScript 的 this,遵循的是一套截然不同的"由调用方式决定"的逻辑;当我带着旧直觉去写 JS 时,这套被我忽略的新规则,就在某个不经意的回调传递里,让我结结实实地栽了一跤。跨越语言的边界时,最危险的,从来不是那些我们知道自己不懂、会主动去查的东西;而是那些我们"自以为懂"、因而连查都不会去查的"想当然"。
而这件事更深一层的启示是关于"理解机制"与"记住规则"的区别。面对 this 这个难题,你当然可以去死记硬背一堆"什么情况下 this 指向谁"的规则。但那样很累,也容易在边界情况下出错。真正一劳永逸的办法,是去理解 this 背后那个统一的、根本的机制——"this 由调用方式动态决定"。一旦你抓住了这个内核,那些纷繁的规则、那些诡异的 bug,就都成了从这个内核自然推导出来的、合情合理的结果,你不再需要一条条去背,而是能从原理出发,在任何场景下都推断出正确的答案。这正是这个系列我反复想传递的信念:对于我们日复一日使用的工具和语言,与其满足于记住一堆"应该怎么做"的零散规则,不如沉下心去理解它"为什么是这样"的底层机制——因为前者让你应付常见情况,而后者,让你在任何意想不到的情况下,都能从容地洞察真相。愿你我在与 JavaScript 这门既灵活又微妙的语言相处时,都能少一分对"想当然"的依赖,多一分对其独特机制的理解与尊重——因为正是这份理解,让那些曾经令人抓狂的"诡异行为",一个个都变成了我们了然于心的"理所当然"。
如果你手上也有 JavaScript 项目,不妨今天就花二十分钟做两件小事自查。第一,全局搜一下 addEventListener、setTimeout、以及各种"把对象方法当回调传出去"的地方,逐个确认那些方法里有没有用到 this——如果用了、又是用普通函数写法直接传引用的,它大概率正埋着一颗 this 丢失的雷,改成箭头函数或 bind。第二,看看 class 组件/对象里那些会被当回调的处理器方法,把它们统一改成"箭头函数类字段"的写法,一劳永逸地锁住 this。这两件事都不难,却能帮你把一整类"点一下就报 undefined"的诡异 bug,从源头上清除掉。配合 ESLint 的相关规则,还能让这类问题在编码时就被提示出来。
回头看,this 这个让无数 JavaScript 开发者又爱又恨的关键字,其实是这门语言性格的一个缩影:它极度灵活、自由,把巨大的能力交到你手里,却也因此把很多需要你自己去理解、去把控的细节,留在了那份自由的背面。你可以用最随意的方式写 JS,很快就能跑起来;但要真正写好它、写得健壮,就必须去理解那些藏在灵活背后的规则——this 如此,闭包如此,异步如此,原型如此。这次 this 丢失的小事故,于我而言,不只是学会了"回调里要用箭头函数",更是又一次被提醒:对一门天天在用的语言,真正的精通,从来不在于你能多快地让代码跑起来,而在于你对它那些"灵活背后的规则"理解得有多深。愿你我都能带着这份探究的耐心,把 JavaScript 这门看似随和、实则深邃的语言,一个机制一个机制地吃透——因为正是这些被我们认真理解过的底层规则,最终汇成了我们写下每一行代码时,那份从容不迫的底气。
—— 别看了 · 2026