前言
在 JavaScript 的江湖里,this 是一个让无数英雄尽折腰的关键词。
你以为它是 Java 里的 this(永远指向实例)?错。
你以为它由函数定义的位置决定?错。
它像一只变色龙,环境变了,颜色也就变了。
很多同学背诵了无数遍“谁调用指向谁”,但一遇到回调函数、箭头函数或者复杂的面试题,依然会蒙圈。今天,我们不背死记硬背口诀,而是结合底层执行上下文和JS 设计历史,彻底扒光 this 的底裤。
一、 为什么我们需要 this?
很多新手会问:“既然 JS 有作用域链(Scope Chain),我在函数里想用什么变量,直接沿着词法作用域往上找不就行了吗?为什么要搞出一个 this 来捣乱?”
1.1 面向对象的刚需
在早期的编程世界里,面向对象(OOP)是主流。虽然 JS 当时没有 class(现在有了),但它需要一种机制来让函数操作对象内部的数据。
试想一下,如果没有 this,我们如何让一个对象的方法访问自己的属性?
var bar = {
myName: "极客邦",
printName: function () {
// 如果没有 this,我们只能硬编码对象名
console.log(bar.myName);
}
};
看起来没问题?但如果我把 bar 改名为 foo,或者把 printName 赋值给另一个对象,这段代码就废了。
this 的出现,本质上是为了提供一种更优雅的对象内部属性访问机制。它是一个指针,指向调用该函数的对象。
二、 核心矛盾:词法作用域 VS 动态绑定的 this
这是理解 this 最关键的门槛。
- 作用域链(Scope Chain) :是静态的。在编译阶段就确定了。函数在哪里声明,你的“老家”(Outer 环境)就在哪里,跟你在哪调用没关系。
- this:是动态的。在执行阶段(函数被调用时)才确定。谁调用你,你就指向谁。
从执行上下文角度看
变量环境,词法环境,outer 指针 都在编译阶段就可以判断其规则,而 this 的调用则不同,其往往要等到执行阶段才能判断其指向。
一句话总结:作用域看“出生地”,this 看“金主爸爸(调用者)”。
我们来看看经典案例:
// 全局变量
var myName = "极客邦";
var bar = {
myName: "time.geekbang.com",
printName: function () {
console.log(this.myName);
},
};
function foo() {
let myName = "极客时间";
return bar.printName;
}
// 1. 普通函数调用
let _printName = foo();
_printName(); // 输出什么?
// 2. 对象方法调用
bar.printName(); // 输出什么?
深度解析:
-
_printName() :
- foo() 返回了 bar.printName 的函数引用,赋值给了 _printName。
- 当调用 _printName() 时,它是光杆司令,前面没有对象。
- 金主爸爸:全局对象(Window)。
- 结果:"极客邦"。
-
bar.printName() :
- 这次函数是被 bar 点出来的。
- 金主爸爸:bar 对象。
- 结果:"time.geekbang.com"。
注意:foo 函数内部的 let myName = "极客时间" 完全是干扰项。this 的查找不走作用域链,它只看调用方式!
三、 this 绑定的四大规则(含设计缺陷分析)
JS 作者 Brendan Eich 当年用了 10 天写出 JS,确实留下了一些“坑”,或者说“设计妥协”。我们将 this 的绑定规则分为四类:
3.1 默认绑定(设计缺陷?)
当一个函数独立调用,没有显式的调用对象时:
- 非严格模式:this 指向 window(浏览器)或 global(Node)。
- 严格模式:this 是 undefined。
File 2.html 揭示了这个隐患:
function foo() {
console.log(this);
}
foo(); // window.foo()
为什么说这是设计缺陷?
因为这极易造成全局污染!如果你在函数里不小心写了 this.count = 1,本意是修改对象属性,结果你在不知情的情况下修改了 window.count。
为了修补这个 Bug,JS 推出了 'use strict'(严格模式),强制让这里的 this 变成 undefined,让你直接报错,而不是悄悄地改全局变量。
3.2 隐式绑定(对象调用)
谁调用,指向谁。
bar.printName(),this 指向 bar。
隐式丢失陷阱:
这是面试最爱考的。一旦你把对象里的函数赋值给一个变量,绑定就丢失了(退化为默认绑定)。
3.3 显式绑定(Call, Apply, Bind)
既然 JS 搞得这么晕,能不能手动指定 this?当然可以。
这就是 call、apply 和 bind 的作用——强行篡改 this 指向。
let bar = {
myName: "极客邦",
};
function foo() {
this.myName = "极客时间";
}
// 强行把 foo 的 this 按头指向 bar
// 此处相当于 this.myName="极客时间" ->bar.myName="极客时间",进行了重新赋值
foo.apply(bar);
console.log(bar.myName); // "极客时间"
3.4 new 绑定(构造函数)
当使用 new 关键字时,JS 引擎在背后默默做了 4 件事:
- 创建一个全新的对象 temObj = {}。
- 将新对象的原型链 proto 指向构造函数的 prototype。
- 关键一步:将构造函数中的 this 绑定到这个新对象上(apply(temObj))。
- 如果构造函数没有返回其他对象,就自动返回这个新对象。
function CreateObj() {
this.name = "极客时间"; // 这里的 this 指向新创建的实例
}
var myObj = new CreateObj();
console.log(myObj.name); // "极客时间"
四、 那个特立独行的箭头函数 (Arrow Function)
在深入讲解复杂的“回调地狱”之前,我们必须先请出 ES6 的救世主——箭头函数。因为它彻底打破了上面的规则,往往是解决 this 问题的“终极武器”。
4.1 核心原理
很多教程会告诉你:“箭头函数的 this 是继承自父级”。
这句话没错,但它掩盖了底层的查找真相。实际上,箭头函数并没有什么“继承”魔法,它只是把 this 当作一个普通的变量,遵循标准的作用域链(Scope Chain) 查找规则。
口诀:箭头函数没有自己的 this,它只认“死理”——它的 this 由 outer 指针决定。
4.2 深度解析:没有 this 槽位与 outer 查找
要讲清楚箭头函数,必须深入到 执行上下文 的内存结构中:
-
没有 this 槽位:
在普通函数的执行上下文中,引擎会专门留一个槽位(Internal Slot)来存储 this。但是,箭头函数的执行上下文里,压根就没有这个 this 槽位!它极其“轻量化”。 -
outer 指针上溯:
当你在箭头函数里写 console.log(this) 时,引擎的查找过程是这样的:- Step 1:问箭头函数自己:“你有 this 吗?” 答:“我没有。”
- Step 2:沿着 outer 指针 去找上一级词法环境(Lexical Environment)。
- Step 3:如果上一级是普通函数,就用它的 this;如果上一级还是箭头函数,继续往上找,直到找到全局 Global 为止。
关键点: outer 指针是在函数定义(编译)阶段就确定的,这就是为什么说箭头函数的 this 是词法作用域(静态) 的。
代码实战分析:
function foo() {
// context_foo: {
// this: obj (因为是 obj.foo() 调用的),
// outer: globalContext
// }
return () => {
// context_arrow: {
// this: (无槽位),
// outer: context_foo (因为是在 foo 内部定义的)
// }
console.log(this);
};
}
const obj = { name: "极客时间" };
// 1. foo 被调用,创建 foo 的执行上下文,this 被绑定为 obj
const arrowFn = foo.call(obj);
// 2. arrowFn 被独立调用
// 查找路径:arrowFn 本身无 this -> outer 指向 foo 环境 -> 找到 foo 的 this (obj)
arrowFn(); // 输出:obj
这就是为什么 call/apply/bind 无法改变箭头函数的 this——因为箭头函数本身没有 this 让你改,你改不了它 outer 爸爸环境里的 this。
4.3 经典误区:对象字面量不是作用域
理解了 outer 指针,就能破解下面这个经典坑:
var name = "window";
var obj = {
name: "obj",
// ❌ 错误写法:
arrow: () => {
// 这里的 outer 指向谁?
console.log(this.name)
}
}
obj.arrow(); // 输出 "window"
为什么?
很多同学误以为 outer 指向 obj。错!
JS 中只有函数和全局(以及 let/const 的块级)才构成词法作用域。对象字面量 {} 不是一个作用域。
所以,arrow 箭头函数是在全局作用域中定义的(虽然它写在对象里,但在编译层面,它的 outer 直接指向 Global)。Global 的 this 指向 window。
五、 深度解密:回调函数中 this 的“离奇失踪”
这是面试中最高频的考点,也是开发中最常见的 Bug 来源。
你是否写过这样的代码?明明在对象里定义好了方法,一传入 setTimeout 或者 forEach,this 就莫名其妙变成了 window 或者 undefined。
5.1 为什么会丢失?
核心原因:this 的绑定取决于函数调用方式,而不是函数定义位置。
当你把一个对象的方法作为回调函数传递时(例如 setTimeout(obj.greet, 1000)),你传递的仅仅是函数的引用(内存地址) ,而不是“对象+函数”的组合。
当回调函数真正被执行时,它通常是作为独立函数调用的(触发默认绑定规则)。
const obj = {
name: 'Original',
greet: function() {
console.log(`Hello, ${this.name}`);
}
};
// 1. 作为对象方法调用:this 指向 obj
obj.greet(); // "Hello, Original"
// 2. 作为回调传递:失去原始上下文
function executeCallback(callback) {
// 这里没有任何对象修饰,就是光秃秃的 callback()
callback();
}
executeCallback(obj.greet);
// 输出:"Hello, undefined"(严格模式)或 "Hello, [global]"(非严格模式)
5.2 常见场景与解决方案
场景一:定时器陷阱 (setTimeout / setInterval)
setInterval 和 setTimeout 会在全局环境中执行回调。
const timerObj = {
count: 0,
start: function() {
setInterval(function() {
// ❌ 错误:这里的 this 指向 window
this.count++;
console.log(this.count);
}, 1000);
}
};
timerObj.start(); // 输出 NaN
✅ 解决方案 A:箭头函数(推荐)
利用箭头函数 outer 指针继承外层 this 的特性。
start: function() {
// 箭头函数 outer -> start 函数作用域 -> timerObj
setInterval(() => {
this.count++;
console.log(this.count);
}, 1000);
}
✅ 解决方案 B:保存引用(Legacy 方案)
在老旧代码中常见的 self 或 that 写法。
start: function() {
const self = this; // 在闭包中保存 snapshot
setInterval(function() {
self.count++;
}, 1000);
}
场景二:DOM 事件处理的“双重人格”
DOM 事件回调有一个特殊机制:addEventListener 会强行将 this 绑定到触发事件的 DOM 元素上。
const handler = {
message: 'Click me!',
handleClick: function() {
console.log(this.message);
}
};
const button = document.querySelector('button');
// 点击后输出 undefined。
// 因为 this 指向了 button 元素,而 button 上没有 message 属性
button.addEventListener('click', handler.handleClick);
✅ 解决方案 A:.bind() 硬绑定
如果你需要复用函数,或者不方便用箭头函数,可以用 bind 锁死 this。
// 创建一个永久绑定 handler 的新函数
button.addEventListener('click', handler.handleClick.bind(handler));
✅ 解决方案 B:箭头函数包一层
button.addEventListener('click', () => handler.handleClick());
场景三:数组方法 (map, forEach)
很多同学不知道,数组方法其实自带了“修正 this”的接口。
const processor = {
factor: 2,
process: function(numbers) {
// ❌ 错误:回调里的 this 是 window
return numbers.map(function(num) {
return num * this.factor;
});
}
};
✅ 解决方案 A:使用 thisArg 参数
大多数高阶函数(map, forEach, filter)都接受第二个参数,用于指定回调执行时的 this。
process: function(numbers) {
return numbers.map(function(num) {
return num * this.factor;
}, this); // 👈 这里的 this 就是 processor,传进去!
}
✅ 解决方案 B:箭头函数
process: function(numbers) {
return numbers.map(num => num * this.factor);
}
六、 面试题实战:你能过几关?
我们来看一道面试题。
题目:
var name = "windowName";
var myObj = {
name: "极客时间",
showThis: function () {
console.log(this.name);
},
wait: function () {
// 箭头函数
setTimeout(() => {
console.log(this.name);
}, 1000);
}
};
var foo = myObj.showThis;
// 1. 赋值后调用
foo();
// 2. 对象调用
myObj.showThis();
// 3. 嵌套箭头函数
myObj.wait();
// 4. 赋值表达式(高难度)
(myObj.showThis = myObj.showThis)();
解析:
-
1. foo() :独立调用,默认绑定。非严格模式下输出 "windowName"。
-
2. myObj.showThis() :隐式绑定,调用者是 myObj。输出 "极客时间"。
-
3. myObj.wait() :setTimeout 里的箭头函数继承自 wait 函数的作用域(通过 outer 指针)。wait 是被 myObj 调用的,所以 wait 里的 this 是 myObj。箭头函数跟随它,输出 "极客时间"。
-
4. (myObj.showThis = ... )() :这是一个赋值表达式。赋值表达式的返回值是函数本身(引用) 。所以这里变成了 (function)() 的立即调用形式,前面失去了对象修饰。this 变成了 window,输出 "windowName"。
七、 总结
this 的设计虽然被诟病为“混乱”,但本质上它是 JS 灵活性的体现。
-
执行上下文:时刻记住,this 是在函数执行那一刻决定的(除了箭头函数)。
-
箭头函数:本质不是继承,而是没有 this 槽位,通过 outer 指针 沿词法作用域链查找。
-
回调必丢:凡是传入回调函数,警惕 this 丢失,优先考虑 箭头函数 或 Bind。
-
判断优先级:
- new -> 新对象
- call/apply/bind -> 指定对象
- obj.func() -> obj
- 箭头函数 -> 外层 outer 作用域
- 独立调用 / 回调 -> window 或 undefined
希望这篇文章能帮你彻底解决 JS 里的“身份认同危机”。下次面试官再问 this,你可以自信地告诉他: “这不过是执行上下文中的一个动态指针罢了。”