同一段代码,user.greet() 返回 hi ada,把 user.greet 传给 setTimeout 就变成 hi undefined,严格模式下直接抛 TypeError。
很多人背过「this 指向调用它的对象」,但这句话只对了一半。this 在函数被调用时确定,取决于调用点的形式,和函数写在哪里基本无关——箭头函数是唯一的例外。理解这一点,大部分「this 是 undefined」的报错都能当场解释。
现象:同一个函数,三种结果#
const user = {
name: 'ada',
greet() {
return `hi ${this.name}`;
},
};
console.log(user.greet()); // 'hi ada'
const g = user.greet;
console.log(g()); // 非严格:'hi undefined';严格/模块:TypeError
setTimeout(user.greet, 0); // 回调里的 this 已不是 user
把方法取出来单独调用,就丢掉了「谁在调用」这条信息。事件监听、数组遍历、Promise 回调都是同一个道理。
事件监听是第二个高发场景:el.addEventListener('click', this.handleClick) 里,普通函数的 this 是那个 DOM 元素;数组遍历也一样,items.map(obj.format) 的 this 是 undefined。只要方法离开了「被哪个对象调用」的位置,this 就断了线索。
第三个场景是 class。把 counter.inc 解构出来赋值给变量、再作为回调传出去,this 就没了;报错信息通常是 Cannot read properties of undefined (reading 'count'),看起来像字段没初始化,实际是 this 不对。
Promise 链也常中招:promise.then(obj.handle),then 只负责传值,不负责绑定接收者。需要 this 时写成 promise.then((v) => obj.handle(v)),行为才确定。
定位:先判断三件事#
第一,看调用形式:是 fn() 还是 obj.fn()?有没有经过 call、bind、new?
第二,看函数是不是箭头函数。箭头函数没有自己的 this,bind 也改不了它。
第三,看运行在严格模式还是模块里。ES Module 和 class 内部默认严格,此时默认绑定的 this 是 undefined,不是全局对象。
运行时可用的几个探针:
function probe() {
console.log(this); // 先看实际值
console.log(typeof this, this?.constructor?.name);
}
probe(); // undefined(模块中)或 globalThis(脚本中)
probe.call({ a: 1 }); // { a: 1 }
如果 this 是 undefined,报错通常是 Cannot read properties of undefined;如果是全局对象,症状是不报错但读到 undefined 字段——后者更难查,因为代码不崩,只是数据不对。
在浏览器里更快的方式是断点:在可疑函数第一行下断点,看右侧 call stack 里的调用形式,obj.fn() 和 fn() 长得完全不一样。再配合 console.trace(),能直接看到是谁调用的。
顺带记一下顶层 this:模块顶层的 this 在 ESM 里是 undefined,在 CommonJS 里是 module.exports,在普通 script 里是 window。同一段代码换个加载方式,结果就变了。
根因:六种绑定与一条优先级#
| 调用形式 | this 指向 | 说明 |
|---|---|---|
fn() |
undefined(严格/模块)或 globalThis |
默认绑定 |
obj.fn() |
obj |
隐式绑定,看调用点左侧 |
const g = obj.fn; g() |
同默认绑定 | 隐式丢失,最常见的坑 |
fn.call(x) / apply / bind |
x |
显式绑定,bind 生成新函数且不可再改 |
new Fn() |
新创建的实例 | new 绑定,优先级最高 |
() => this |
外层词法作用域的 this | 箭头函数,无法被改变 |
优先级从高到低:new > bind > call/apply > 隐式 > 默认。一个直观的验证:
function Person(name) { this.name = name; }
const Bound = Person.bind({ name: 'bound' });
console.log(new Bound('ada').name); // 'ada',new 忽略了 bind 的 this
const arrow = () => this;
console.log(arrow.call({ a: 1 }) === this); // true,箭头函数不认 call
new (() => {})(); // TypeError: not a constructor
机制上可以这么理解:obj.fn() 在规范里叫「引用」,调用时会把 obj 作为 this 传入。一旦把这个引用赋给变量、作为参数传出去,引用就退化成普通值,this 的线索随之丢失。(obj.fn)() 因为括号不改变引用,仍然是隐式绑定;而 (0, obj.fn)() 经过逗号运算符,引用就没了。
严格模式是另一个变量。非严格模式下,默认绑定的 this 会被替换成全局对象,于是 this.name 读到 undefined 而不报错;严格模式下保持 undefined,调用 this.xxx 立即抛错。同一份代码在不同打包配置下表现不同,多半就是这个原因。
class 内部默认严格模式,所以 const inc = counter.inc; inc() 不会静默读到全局变量,而是直接抛 TypeError。这算是好事:错误更早暴露。
修复:四种写法,各有取舍#
const user = { name: 'ada', greet() { return `hi ${this.name}`; } };
// 1) bind:生成一个 this 固定的新函数
const bound = user.greet.bind(user);
// 2) 箭头包装:延迟到调用时再取 user.greet
setTimeout(() => user.greet(), 0);
// 3) 显式传入:适合工具函数
function greetWith(ctx) { return `hi ${ctx.name}`; }
greetWith(user);
// 4) class 字段箭头:实例方法自带 this,适合事件处理器
class Counter {
count = 0;
inc = () => { this.count += 1; return this.count; };
}
const c = new Counter();
const inc = c.inc;
inc(); // 1,不再依赖调用点
取舍很清楚:bind 在构造函数里绑一次,开销小,但容易被忘记;class 字段箭头每个实例都创建一份函数,实例多时内存占用更高;箭头包装每次调用都新建闭包,适合回调但别放进热循环。
事件处理器用箭头函数最省事:el.addEventListener('click', () => this.handleClick()),this 来自定义处,与谁触发无关。反过来,如果确实需要触发元素,用普通函数配合 event.currentTarget,别依赖 this。
框架之间的差异也要记住:Vue 的 methods 会自动绑定到组件实例,React class 组件不会,必须自己 bind 或用箭头字段。跨框架迁移时,这类问题会成片出现。
还有一条现代替代:不再用 const self = this 这种写法,直接用箭头函数捕获外层 this,语义更清楚,也少一个变量。
call 与 apply 的区别只在传参:前者逐个传,后者传数组;bind 不执行函数,只返回一个新函数。临时借用一次用 call,需要长期固定 this 才用 bind。
防复发#
- ESLint 打开
no-invalid-this,能拦住把 this 用在普通函数里的写法。 - TypeScript 项目打开
@typescript-eslint/unbound-method,它会直接报「方法被解构后丢失 this」。这条规则依赖类型信息,配置成本略高,但收益最大。 - 单测里专门构造一次「把方法当回调传出去」的调用,断言不抛错。这一步比读代码有效。
- 事件监听、定时器、Promise 链是三个高发位置,review 时重点看这里是否直接传了
this.xxx。 - 公共方法如果需要被外部当回调使用,就在类里同时提供绑定好的箭头版本,别让调用方猜。
- 回调密集的模块(事件总线、定时任务、Promise 链)集中做一次 review,比逐处猜更有效。
结论#
this 由调用点决定:fn() 走默认绑定,obj.fn() 走隐式绑定,call/apply/bind 走显式绑定,new 走构造绑定,箭头函数则完全看外层作用域。
优先级记成一句话:new 最高,bind 次之,call/apply 再次,然后是隐式,最后是默认。
最常见的 bug 不是「this 指错了」,而是「方法被取出来当回调,隐式绑定丢了」。修法优先用箭头包装或 class 字段箭头,配合 unbound-method 这条 lint 规则,基本可以把它挡在上线之前。
最后提醒一句:this 是运行时概念,类型系统只能帮你到「这个方法可能被解构」这一层。真正兜底的,还是那句「谁在调用」——写回调时多问一遍,比事后调试省事。
箭头函数也不能当构造函数,没有 arguments,选它之前先确认这两点不影响使用场景。
完