1. 从一道面试题说起:为什么 this 总让人栽跟头
如果你问十个前端开发者,JavaScript 里哪个概念最容易在面试和实际编码中翻车,我敢打赌至少七个会说是this。这不是没有原因的——this的行为不像普通变量那样遵循词法作用域,它的指向在代码运行的那一刻才最终确定,而且确定规则还不止一条。很多人背了“谁调用指向谁”的口诀,结果一遇到箭头函数、回调传参、事件绑定就全乱套了。
我自己早期也在这个坑里挣扎过很久。最典型的一次是写一个计时器组件,内部用setTimeout做倒计时,回调里访问this.count死活拿不到值,打印出来是undefined。当时第一反应是“变量没定义”,查了半天才发现是this指向变了。后来我才明白,要真正搞懂this,不能停留在“口诀”层面,必须回到 JavaScript 执行上下文这个底层机制去看。
这篇文章就是把我这些年理解this的完整思路拆开来讲。核心视角只有一个:this是执行上下文的一个组成部分,它的值在函数被调用时由调用方式决定。我会从执行上下文的创建过程讲起,把this的绑定规则、箭头函数的特殊处理、常见误区和排查方法全部串起来。不管你是刚接触 JavaScript 的新手,还是想彻底理清这块知识的老手,读完应该都能有一套自己的判断框架,而不是靠死记硬背。
文章会涉及不少代码示例和对比表格,建议你边看边在控制台里敲一遍,效果比纯看要好得多。
2. 执行上下文里到底存了什么:this 的“户口”在哪
2.1 执行上下文的三个核心组成部分
要理解this,先得知道它住在哪。JavaScript 引擎在执行一段可执行代码时,会创建一个执行上下文(Execution Context)。你可以把它想象成一个“工作台”,引擎在这个台子上摆放了当前代码运行所需的所有信息。这个工作台主要有三块内容:
- 变量环境(Variable Environment):存放
var声明的变量和函数声明,也就是我们常说的变量对象。 - 词法环境(Lexical Environment):存放
let、const声明的变量,以及块级作用域的信息。 - this 绑定(ThisBinding):记录当前执行上下文中
this指向哪个对象。
看到没有,this不是凭空冒出来的,它是执行上下文的一个正式成员,和变量环境、词法环境平级。这就解释了为什么this的行为和普通变量不一样——普通变量走的是作用域链查找,而this是每个执行上下文自带的、在创建阶段就确定好的一个“绑定值”。
这里有个关键点:执行上下文分创建阶段和执行阶段。在创建阶段,引擎会做三件事:确定this的值、创建词法环境、创建变量环境。也就是说,this的指向在函数被调用、上下文创建的那一刻就已经定下来了,而不是等到函数体里的代码一行行执行时才决定。这个时间点的差异,是理解后面所有绑定规则的基础。
2.2 全局执行上下文里的 this
最外层的那层执行上下文叫全局执行上下文。在浏览器环境里,全局上下文中this指向window对象;在 Node.js 环境里,指向module.exports(空对象{})。这也是为什么你在全局作用域里打印this,浏览器里会看到一堆window的属性和方法。
// 浏览器环境 console.log(this === window); // true // Node.js 环境 console.log(this); // {},即 module.exports console.log(this === global); // false很多人第一次接触this就是从这里开始的,然后误以为this永远指向全局对象。实际上全局上下文只是最简单的一种情况,函数上下文才是真正复杂的地方。
2.3 函数执行上下文中 this 的确定时机
每次函数被调用,引擎都会为这次调用创建一个新的函数执行上下文。注意,是每次调用都创建,而不是函数定义时创建。同一个函数,用不同的方式调用,会创建出this指向不同的执行上下文。
function showThis() { console.log(this); } const obj = { name: 'A', showThis: showThis }; showThis(); // 浏览器里是 window obj.showThis(); // obj 对象同一个showThis函数,两次调用产生了两个不同的执行上下文,this分别指向window和obj。这就是为什么说this是“运行时决定”的——它取决于调用表达式长什么样,而不是函数在哪里定义。
理解了这一点,你就抓住了this问题的命门:不要问“这个函数的 this 是什么”,要问“这个函数是怎么被调用的”。接下来我们就按调用方式来拆解绑定规则。
3. 四条绑定规则:按调用方式给 this 定位
3.1 默认绑定:光杆调用指向全局
最朴素的调用方式就是直接写函数名加括号,没有任何前缀对象。这种情况下this走默认绑定,在非严格模式下指向全局对象,严格模式下是undefined。
function foo() { 'use strict'; console.log(this); // undefined } foo(); function bar() { console.log(this); // window(浏览器) } bar();这里有个容易忽略的细节:严格模式判断的是函数自身是否处于严格模式,而不是调用它的地方。也就是说,一个非严格模式的函数里调用一个严格模式的函数,严格模式函数里的this依然是undefined。
function strictFn() { 'use strict'; console.log(this); // undefined } function looseFn() { strictFn(); // 调用位置在非严格函数里,但 strictFn 内部是严格模式 } looseFn();实际开发中,默认绑定是最容易出问题的地方。比如你把一个对象的方法单独赋值给一个变量再调用,this就从对象变成了全局:
const counter = { count: 0, increment: function() { this.count++; } }; const inc = counter.increment; inc(); // this 是 window,window.count 变成 NaN console.log(counter.count); // 0,没变这个坑我在实际项目里见过太多次了,尤其是把方法作为回调传递的时候。
3.2 隐式绑定:谁调用指向谁
当函数作为对象的属性被调用时,this指向那个对象,这就是隐式绑定。判断的关键是看调用表达式里,函数名前面有没有一个对象加点号。
const user = { name: '张三', greet: function() { console.log(this.name); } }; user.greet(); // 张三隐式绑定有个经典的“绑定丢失”问题:当函数被赋值给另一个变量,或者作为参数传递时,调用位置就不再是原来的对象了。
const user = { name: '张三', greet: function() { console.log(this.name); } }; const g = user.greet; g(); // undefined(this 是 window,window.name 通常是空字符串) setTimeout(user.greet, 100); // undefinedsetTimeout那个例子特别典型。你传进去的是user.greet这个函数本身,而不是user.greet()这个调用表达式。到了定时器触发的时候,是引擎在调用这个函数,前面没有任何对象,所以走默认绑定。
还有一个更隐蔽的情况是对象链式调用,this只认最后一层:
const a = { name: 'a', b: { name: 'b', getName: function() { return this.name; } } }; console.log(a.b.getName()); // b,不是 a因为调用表达式是a.b.getName(),点号前面紧挨着的是b,所以this是b。
3.3 显式绑定:call、apply、bind 三兄弟
如果我想强行指定this,就用call、apply、bind。这三个方法都定义在Function.prototype上,作用是改变函数执行时的this指向。
function introduce() { console.log(`我是 ${this.name}`); } const person = { name: '李四' }; introduce.call(person); // 我是 李四 introduce.apply(person); // 我是 李四 const bound = introduce.bind(person); bound(); // 我是 李四三者的区别用一句话概括:call和apply立即执行,区别只在传参方式(call逐个传,apply传数组);bind不立即执行,返回一个绑定了this的新函数。
| 方法 | 是否立即执行 | 参数形式 | 返回值 |
|---|---|---|---|
| call | 是 | 逐个参数 | 函数执行结果 |
| apply | 是 | 数组 | 函数执行结果 |
| bind | 否 | 逐个参数 | 绑定后的新函数 |
bind有一个容易被忽略的特性:被 bind 绑定过的函数,this 无法再被改变,即使对它再用call或apply也没用。
function foo() { console.log(this.name); } const bound = foo.bind({ name: '固定' }); bound.call({ name: '想改' }); // 固定这个特性在需要固定回调上下文时非常有用,比如在类的方法里绑定事件处理器。
3.4 new 绑定:构造函数里的 this
用new调用函数时,会发生四件事:创建一个新对象、把新对象的原型指向函数的prototype、把this绑定到这个新对象、如果函数没有返回对象则返回这个新对象。
function Person(name) { this.name = name; } const p = new Person('王五'); console.log(p.name); // 王五new绑定的优先级是最高的。如果构造函数里显式返回了一个对象,那么this绑定的那个新对象会被丢弃,返回的是显式返回的对象。
function Weird() { this.a = 1; return { b: 2 }; } const w = new Weird(); console.log(w.a); // undefined console.log(w.b); // 23.5 优先级排序与判断流程
四条规则的优先级从高到低是:new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定。判断一个函数调用里this指向什么,可以按这个流程走:
- 函数是用
new调用的吗?是则this是新创建的对象。 - 函数是用
call、apply或bind调用的吗?是则this是指定的对象。 - 函数是作为对象方法调用的吗(
obj.fn())?是则this是那个对象。 - 以上都不是,走默认绑定,严格模式是
undefined,非严格模式是全局对象。
这个流程我建议你背下来,遇到任何this问题都按这个顺序过一遍,基本不会错。
4. 箭头函数:this 规则里的“异类”
4.1 箭头函数没有自己的 this
前面讲的四条规则,对箭头函数全部不适用。箭头函数在定义时就把this固定下来了,它捕获的是定义时所在外层作用域的 this,而且一旦确定就永远不会变。
const obj = { name: '箭头测试', regular: function() { console.log(this.name); // 箭头测试 }, arrow: () => { console.log(this.name); // undefined,this 是外层作用域的 } }; obj.regular(); obj.arrow();obj.arrow()虽然看起来是作为对象方法调用的,但箭头函数不认这套,它的this在定义时就锁死为外层(这里是全局)的this。
4.2 为什么箭头函数要这样设计
这个设计不是拍脑袋定的,而是为了解决回调函数里this丢失的问题。在箭头函数出现之前,我们写回调经常要这样:
const timer = { seconds: 0, start: function() { const self = this; // 保存 this setInterval(function() { self.seconds++; console.log(self.seconds); }, 1000); } };或者用bind:
const timer = { seconds: 0, start: function() { setInterval(function() { this.seconds++; console.log(this.seconds); }.bind(this), 1000); } };有了箭头函数,直接写就行:
const timer = { seconds: 0, start: function() { setInterval(() => { this.seconds++; console.log(this.seconds); }, 1000); } };箭头函数里的this直接继承start方法的this,也就是timer对象,干净利落。这就是箭头函数被设计出来的核心动机——让回调里的 this 和外面的 this 保持一致。
4.3 箭头函数不能当构造函数
因为箭头函数没有自己的this,所以它不能用作构造函数,用new调用会直接报错。
const Arrow = () => {}; new Arrow(); // TypeError: Arrow is not a constructor同样,箭头函数也没有prototype属性,不能用call、apply、bind改变this(传了也没用,它只认定义时的外层this)。
const arrow = () => { console.log(this); }; const bound = arrow.bind({ name: '无效' }); bound(); // 还是外层 this,bind 无效4.4 箭头函数在对象字面量里的陷阱
一个特别容易踩的坑是在对象字面量里用箭头函数定义方法:
const obj = { name: '陷阱', getName: () => { return this.name; } }; console.log(obj.getName()); // undefined这里的this不是obj,而是定义obj时所在的外层作用域(通常是全局)。因为对象字面量本身不构成作用域,箭头函数捕获的是更外层的this。所以对象方法不要用箭头函数定义,除非你确实需要外层this。
5. 实战中最容易翻车的几个场景
5.1 事件处理器里的 this
DOM 事件处理器里的this默认指向触发事件的元素,这是浏览器给的特殊待遇。
const button = document.querySelector('#btn'); button.addEventListener('click', function() { console.log(this); // button 元素 });但如果你在处理器里用了箭头函数,this就变成外层作用域的了:
button.addEventListener('click', () => { console.log(this); // window,不是 button });这个差异在实际开发中经常导致 bug。我的建议是:需要访问触发元素就用普通函数,需要访问外层 this 就用箭头函数,别混着来。
5.2 类方法作为回调传递
在 ES6 class 里,方法默认是严格模式,而且不会自动绑定this。把方法作为回调传递时,this会丢失。
class Counter { constructor() { this.count = 0; } increment() { this.count++; console.log(this.count); } } const c = new Counter(); setTimeout(c.increment, 100); // TypeError: Cannot read property 'count' of undefined因为setTimeout调用increment时,this是undefined(class 内部是严格模式)。解决办法有三种:
// 方案一:bind setTimeout(c.increment.bind(c), 100); // 方案二:箭头函数包裹 setTimeout(() => c.increment(), 100); // 方案三:类字段 + 箭头函数 class Counter2 { count = 0; increment = () => { this.count++; console.log(this.count); }; }方案三用类字段语法把方法定义成箭头函数,this在实例创建时就绑定好了,是最省心的写法,也是现在 React 类组件里最常见的模式。
5.3 回调函数里的 this 丢失排查
遇到this相关 bug,我的排查步骤是这样的:
- 打印 this:在出问题的函数第一行加
console.log(this),看它到底是什么。 - 看调用表达式:找到这个函数被调用的地方,看前面有没有对象、有没有用
new、有没有call/apply/bind。 - 判断函数类型:是普通函数还是箭头函数?箭头函数的话往外层找
this。 - 检查是否被传递:函数是不是被赋值给变量或作为参数传递了?传递过程中
this会丢失。
举个实际例子。之前有个同事写了个数组遍历,回调里访问this.list一直报错:
const manager = { list: [1, 2, 3], process: function() { this.list.forEach(function(item) { console.log(this.list); // undefined }); } };问题出在forEach的回调是普通函数,this走默认绑定。改成箭头函数就好了:
process: function() { this.list.forEach((item) => { console.log(this.list); // [1, 2, 3] }); }5.4 多层嵌套里的 this 追踪
嵌套深了之后,this的追踪会更麻烦。看这个例子:
const app = { name: 'App', init: function() { const self = this; function inner() { console.log(this.name); // undefined console.log(self.name); // App } inner(); } }; app.init();inner是普通函数,光杆调用,this是全局。而self保存了init的this,所以能拿到App。这种const self = this的写法在 ES5 时代非常流行,现在基本被箭头函数取代了。
如果嵌套多层,建议直接用箭头函数,让this一路继承下来,比手动保存self清晰得多。
6. 把 this 彻底钉死的几种工程手段
6.1 用 bind 在构造函数里预绑定
在 class 的构造函数里统一 bind 所有需要作为回调的方法,是 React 类组件时代的经典做法:
class Widget { constructor() { this.name = 'Widget'; this.handleClick = this.handleClick.bind(this); this.handleChange = this.handleChange.bind(this); } handleClick() { console.log(this.name); } handleChange() { console.log(this.name); } }这样无论方法怎么传递,this都指向实例。缺点是每个实例都会创建新的绑定函数,内存开销略大,但对于组件级别的方法数量来说可以忽略。
6.2 类字段箭头函数:更现代的方案
用类字段语法定义箭头函数方法,this在实例初始化时自动绑定,代码更简洁:
class Widget { name = 'Widget'; handleClick = () => { console.log(this.name); }; handleChange = () => { console.log(this.name); }; }这个方案现在已经是主流,Babel 和 TypeScript 都支持。唯一需要注意的是,箭头函数方法定义在实例上而不是原型上,所以每个实例都有一份,如果方法特别多、实例特别多,内存占用会比原型方法高一些。
6.3 用闭包替代 this
有些场景下,与其纠结this,不如直接用闭包把需要的变量包起来:
function createCounter() { let count = 0; return { increment() { count++; return count; }, getCount() { return count; } }; } const counter = createCounter(); counter.increment(); counter.increment(); console.log(counter.getCount()); // 2这里count是闭包变量,完全不依赖this,也就不存在this指向的问题。模块化开发里这种模式很常见,尤其是需要隐藏内部状态的时候。
6.4 各种方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 构造函数 bind | 类组件、需要动态绑定 | 兼容性好 | 代码冗长,每实例一份 |
| 类字段箭头函数 | 现代类组件 | 简洁,自动绑定 | 每实例一份,内存略高 |
| 箭头函数包裹 | 回调传参 | 灵活,可传参 | 多一层函数调用 |
| 闭包 | 模块化、状态隐藏 | 不依赖 this | 不适合需要实例方法的场景 |
| call/apply | 临时改变 this | 直接 | 只影响单次调用 |
选择哪种方案,主要看你的场景:如果是类的方法需要作为回调,优先用类字段箭头函数;如果是临时借用方法,用call或apply;如果是模块内部状态,用闭包最干净。
7. 几个反直觉的边界情况
7.1 严格模式下的默认绑定
前面提过,严格模式下默认绑定的this是undefined。这个差异在模块化开发里特别重要,因为 ES Module 默认就是严格模式。
// 在 ES Module 里 function foo() { console.log(this); // undefined } foo();如果你从 CommonJS 迁移到 ES Module,原来能跑的代码可能因为this变成undefined而报错。这是迁移时的一个常见坑。
7.2 赋值表达式里的 this
有一个很冷门但面试常考的情况:赋值表达式返回的是值,不是引用,所以this会丢失。
const obj = { name: 'obj', getName: function() { return this.name; } }; const another = {}; another.getName = obj.getName; console.log(another.getName()); // obj,正常隐式绑定 // 但如果是这样: (another.getName = obj.getName)(); // undefined,因为赋值表达式返回的是函数值,不是引用最后那个(another.getName = obj.getName)()的写法,括号里是赋值表达式,返回的是函数本身,调用时前面没有对象,走默认绑定。这种写法在实际代码里很少见,但面试里经常拿来考对引用和值的理解。
7.3 逗号表达式里的 this
逗号表达式返回最后一个值,同样会丢失引用:
const obj = { name: 'obj', getName: function() { return this.name; } }; console.log((0, obj.getName)()); // undefined(0, obj.getName)的结果是函数本身,调用时没有对象前缀,this走默认绑定。这个和上面的赋值表达式是同一类问题——只有obj.method()这种直接的成员访问调用,才会触发隐式绑定。
7.4 立即执行函数里的 this
IIFE(立即执行函数表达式)里的this取决于它在哪里定义:
// 全局作用域里的 IIFE (function() { console.log(this); // window(非严格模式) })(); // 对象方法里的 IIFE const obj = { name: 'obj', init: function() { (function() { console.log(this.name); // undefined })(); } }; obj.init();对象方法里的 IIFE 是光杆调用,this是全局,拿不到obj.name。要解决的话,要么用箭头函数,要么把this存到变量里。
8. 我总结的一套 this 判断心法
8.1 三步定位法
经过这么多年的使用和踩坑,我总结了一个三步定位法,遇到任何this问题都可以套:
第一步:看函数类型。是箭头函数吗?是的话,this就是定义时外层作用域的this,往外层找,找到最近的非箭头函数或全局作用域为止。
第二步:看调用方式。不是箭头函数的话,看调用表达式:有new吗?有call/apply/bind吗?前面有点号对象吗?都没有就是默认绑定。
第三步:看运行环境。严格模式还是非严格模式?浏览器还是 Node.js?这决定了默认绑定的具体值。
这三步走完,this指向基本就确定了。我建议你在遇到问题时,不要凭感觉猜,老老实实按这三步过一遍,养成习惯之后判断会越来越快。
8.2 常见误区清单
下面这些是我见过最多的this误区,列出来供你对照:
- 误区一:
this指向函数自身。实际上this和函数本身没有关系,它指向的是调用上下文。 - 误区二:
this指向函数定义时所在的对象。实际上this是运行时决定的,和定义位置无关(箭头函数除外)。 - 误区三:箭头函数的
this可以用bind改变。实际上箭头函数的this一旦确定就不可更改。 - 误区四:对象方法里的嵌套普通函数能继承外层
this。实际上嵌套的普通函数走自己的绑定规则,不会继承。 - 误区五:
this在函数执行过程中会变。实际上this在执行上下文创建时就确定了,函数体内不会变(除非你手动用call等改变)。
8.3 调试 this 的实用技巧
最后分享几个我常用的调试技巧:
技巧一:在函数第一行打印 this。最简单也最有效,直接看this是什么,比推理快得多。
function debug() { console.log('this is:', this); // ... }技巧二:用console.trace()看调用栈。当你不确定函数是从哪里被调用的时候,console.trace()会打印完整的调用栈,帮你找到调用位置。
function foo() { console.trace('追踪调用'); }技巧三:在严格模式下开发。严格模式会让默认绑定的this变成undefined,而不是悄悄指向全局。这样一旦this丢失,你会立刻收到报错,而不是拿到一个错误的全局对象继续跑,问题被掩盖。
技巧四:用 TypeScript 的noImplicitThis。如果你用 TypeScript,开启noImplicitThis编译选项,编译器会帮你检查this的隐式 any 类型,很多this问题在编译阶段就能发现。
{ "compilerOptions": { "noImplicitThis": true, "strict": true } }这些技巧配合前面的三步定位法,基本能覆盖日常开发中 95% 以上的this问题。剩下的 5% 是那些极其冷门的边界情况,遇到了再具体分析就行。
说到底,this不是什么玄学,它就是执行上下文的一个属性,由调用方式决定。把执行上下文的创建过程、四条绑定规则、箭头函数的特殊处理这三块吃透,再配合实际编码中的反复验证,你就能从“背口诀”进化到“真理解”。我在带新人的时候,最常强调的就是别死记“谁调用指向谁”,而是去理解背后的执行上下文机制——理解了机制,规则就是自然推导出来的结果,而不是需要记忆的教条。