《this 指向迷局:从“我是谁”到“我在哪”,JS 面试官最爱问的 this 全解析》
“我写 this 的时候,this 是谁?”
——每个前端工程师都曾深夜对着控制台发问。
在 JavaScript 的世界里,this 就像一个“薛定谔的指针”——不到运行那一刻,你永远不知道它指向谁。它是面试高频题、代码 Bug 源头、也是理解 JS 执行上下文的关键钥匙。今天,我们就用 一线大厂面试标准 的视角,彻底拆解 this 的行为逻辑,并结合真实代码逐行剖析其背后的执行机制、作用域链、调用方式与设计哲学。
一、问题引入:一段看似简单的代码,为何输出五花八门?
先看这段代码:
var bar = {
myName: 'time.geekbang.com',
printName: function () {
console.log(myName); // ❓ 极客邦?
console.log(bar.myName); // ✅ time.geekbang.com
console.log(this); // ❓ window 还是 bar?
console.log(this.myName); // ❓ undefined 还是 极客邦?
}
}
function foo() {
let myName = '极客时间';
return bar.printName;
}
var myName = '极客邦';
var _printName = foo(); // ← 注意这里用的是 var
console.log(_printName);
_printName(); // 普通函数调用
bar.printName(); // 对象方法调用
表面看只是打印几个值,实则暗藏玄机:变量声明方式(var vs let)、自由变量查找、this 绑定规则、全局污染全部交织其中。
二、关键前置知识:var 与 let 的本质差异 —— 为什么 _printName 的声明方式至关重要?
你可能会问:把 var _printName = foo() 改成 let _printName = foo(),console.log(_printName) 不都一样吗?
表面上是的,底层却天差地别。
🔍 表面现象:控制台输出相同
无论用 var 还是 let,console.log(_printName) 都会显示:
ƒ () {
console.log(myName);
console.log(bar.myName);
console.log(this);
console.log(this.myName);
}
因为 _printName 只是对 bar.printName 函数的引用,控制台展示的是函数体源码。值相同,但“归属”不同。
🧱 深度对比:五大核心差异
| 特性 | var _printName | let _printName |
|---|---|---|
| 作用域 | 全局/函数作用域 | 块级作用域(script 级) |
| 变量提升 | ✅ 声明 + 初始化均提升(初始化为 undefined) | ✅ 声明提升,❌ 初始化不提升 |
| 暂时性死区(TDZ) | 无 | ⚠️ 有(声明前访问抛出 ReferenceError) |
是否挂载到 window | ✅ 是 | ❌ 否 |
能否通过 window._printName 访问 | 能 | 不能 |
🔥 深入解析:let 的变量提升与“暂时性死区”
很多人误以为 let 没有变量提升,这是错误的!
实际上:
let的声明会被提升到块级作用域顶部;- 但初始化(赋值)不会提升;
- 在声明语句之前访问该变量,会进入 “暂时性死区”(Temporal Dead Zone, TDZ) ,并抛出
ReferenceError。
示例对比:
// var:提升 + 初始化为 undefined
console.log(a); // undefined
var a = 1;
// let:TDZ —— 声明前访问直接报错
console.log(b); // ❌ ReferenceError: Cannot access 'b' before initialization
let b = 2;
// let:声明后正常使用
let c = 3;
console.log(c); // ✅ 3
💡 工程建议:这种设计强制开发者写出更清晰的代码。配合 ESLint 的
"no-var": "error"规则,可从根本上规避变量提升与全局污染问题。
🌐 最关键区别:是否污染全局对象 window
使用 var:
var _printName = foo();
console.log(window._printName === _printName); // true!
var在全局声明的变量会自动成为window的属性。- 这意味着你的
_printName已经“污染”了全局命名空间。
使用 let:
let _printName = foo();
console.log(window._printName); // undefined
console.log(_printName); // 正常函数引用
let属于 脚本作用域(Script Scope) ,不会挂载到window。- 即使在全局,也不会成为全局对象的属性。
💡 实验验证:
var a = 1; console.log(window.a); // 1 let b = 2; console.log(window.b); // undefined
⚠️ 为什么这和 this 有关?
当 _printName() 作为普通函数调用时,this 指向 window:
_printName(); // this → window
如果函数内部访问 this._printName:
- 用
var:能访问到(因为挂载了),造成隐式耦合; - 用
let:this._printName为undefined,更符合预期。
更重要的是:
var myName = '极客邦'会挂载到window.myName,所以this.myName能读到;
但若写成let myName = '极客邦',this.myName就是undefined!
这说明:this 的行为 + 变量声明方式 = 共同决定程序结果。
三、自由变量 vs this:两个完全不同的查找体系
1. 自由变量:靠 词法作用域(Lexical Scope)
console.log(myName)中的myName是自由变量。- 查找路径:当前函数 → 外层函数 → 全局作用域。
- 注意:对象方法不创建新作用域!
printName的词法作用域仍是全局。 - 因此
myName找到的是全局var myName = '极客邦'。
✅ 关键点:自由变量的查找与 this 无关,只看函数在哪定义。
2. this:靠 调用方式(Call Site)
this在函数被调用时动态绑定。- 它不关心定义位置,只关心 “谁调用了它” 。
这就是为什么:
_printName()→ 普通函数调用 →this === windowbar.printName()→ 对象方法调用 →this === bar
⚠️ 设计缺陷?早期 JS 作者 Brendan Eich 为快速实现 OOP,将
this与调用方式强绑定,导致灵活性与混乱并存。
四、this 的五大绑定规则
规则 1️⃣:默认绑定(Default Binding)
function foo() { console.log(this); } // this → window(非严格模式)
foo(); // 普通调用
- 原因:等价于
window.foo()。 - 隐患:
var声明变量会挂载到window,造成污染。 - 改进:用
let/const或开启 严格模式(this === undefined)。
规则 2️⃣:隐式绑定(Implicit Binding)
var myObj = {
name: '极客时间',
showThis: function() {
this.name = '极客邦';
console.log(this); // myObj
}
};
myObj.showThis(); // ✅ this → myObj
-
陷阱:赋值后调用会丢失绑定!
var foo = myObj.showThis; foo(); // this → window!
规则 3️⃣:显式绑定(Explicit Binding)
function foo() { this.myName = "极客时间"; }
foo.call(bar); // this → bar
call、apply、bind都可以强制指定函数内部的this指向,统称为 显式绑定。- 它们的核心区别在于参数传递方式和是否立即执行。
🆚 三者详细对比
| 方法 | 语法 | 参数形式 | 是否立即执行 | 返回值 |
|---|---|---|---|---|
call | fn.call(thisArg, arg1, arg2, ...) | 逐个列出参数 | ✅ 是 | 函数执行结果 |
apply | fn.apply(thisArg, [arg1, arg2, ...]) | 参数以数组形式传入 | ✅ 是 | 函数执行结果 |
bind | fn.bind(thisArg, arg1, arg2, ...) | 可预设部分参数(柯里化) | ❌ 否 | 新函数(this 已绑定) |
🧪 使用示例
function greet(greeting, punctuation) {
console.log(`${greeting}, ${this.name}${punctuation}`);
}
const person = { name: '小明' };
greet.call(person, '你好', '!'); // 你好, 小明!
greet.apply(person, ['你好', '!']); // 你好, 小明!
const boundGreet = greet.bind(person, '你好');
boundGreet('!'); // 你好, 小明!(延迟调用)
💡 高频考点:
bind返回的新函数,其this永久绑定,即使再用call也无法改变(除非使用new);apply常用于“展开数组传参”,如Math.max.apply(null, [1,2,3])。
规则 4️⃣:new 绑定(Constructor Binding)——最可靠的 this 场景
当你执行 new CreateObj(),引擎执行四步:
-
创建空对象:
{}; -
链接原型:
obj.__proto__ = Fn.prototype; -
绑定 this 并执行:
Fn.call(obj, ...args); -
决定返回值:
- 若返回引用类型(object/array/function),则返回该值;
- 否则,返回新对象。
function CreateObj() {
this.name = '极客时间';
}
var myObj = new CreateObj(); // ✅ { name: '极客时间' }
🧪 边界测试
function A() { return {}; } // 返回 {}
function B() { return 'str'; } // 返回新实例
function C() { return null; } // 返回新实例(null 是基本类型!)
规则 5️⃣:事件处理中的 this
在浏览器环境中,DOM 事件回调函数的 this 绑定有特殊约定:
<a href="#" id="link">点击我</a>
<script>
document.getElementById('link').addEventListener('click', function () {
console.log(this);// ✅ <a id="link"> 元素
})
</script>
- 普通函数作为事件回调时,浏览器会自动将
this绑定为触发事件的 DOM 元素。 - 这是隐式绑定的一种特例,由事件系统内部通过类似
handler.call(element, event)的方式调用实现。
⚠️ 箭头函数的陷阱
如果改用箭头函数:
document.getElementById('link').addEventListener('click', () => {
console.log(this); // ❌ 指向外层作用域(通常是 window 或 undefined)
});
- 箭头函数没有自己的
this,它会继承定义时所在词法作用域的this。 - 在全局作用域中定义的箭头函数,
this通常是window(非严格模式)或undefined(严格模式)。 - 结果:你无法通过
this获取当前元素!
🔁 对比总结
| 回调类型 | this 指向 | 能否访问当前元素 | 适用场景 |
|---|---|---|---|
| 普通函数 | 触发事件的 DOM 元素 | ✅ 是 | 需要操作当前元素(如修改样式、读取属性) |
| 箭头函数 | 外层词法作用域的 this | ❌ 否 | 不依赖 this,或需保留外层上下文(如 React 类组件中避免 bind) |
💡 最佳实践建议:
如果你需要在事件回调中使用
this指向元素 → 用普通函数;如果你在类组件或闭包中需要保留外层
this(如this.setState)→ 用箭头函数,但通过event.currentTarget获取元素:const handleClick = (e) => { console.log(e.currentTarget); // ✅ 安全获取绑定元素 this.setState({ clicked: true }); // ✅ 保留外层 this };
五、如何拿到 time.geekbang.com?——正确访问对象属性的方式
console.log(myName); // ❌ 自由变量 → '极客邦'
console.log(bar.myName); // ✅ 直接访问
console.log(this.myName); // ⚠️ 看 this 是谁!
✅ 最佳实践:
- 在对象方法中,始终用
this.xxx访问自身属性; - 避免依赖自由变量查找对象属性;
- 若需解耦,使用参数或闭包。
六、延伸思考:this 的设计哲学与现代替代方案
为什么这样设计?
- 历史原因:1995 年,Brendan Eich 为模仿 Java OOP,10 天内设计 JS,
this被简单映射为“调用者”。 - 代价:动态绑定带来灵活性,也带来不确定性。
现代解决方案
- 箭头函数:无
this,继承词法作用域; - class 语法糖:语义清晰,默认严格模式;
- 严格模式 + ESLint:禁止隐式全局,强制显式处理
this。
七、高频面试题关联(大厂真题 · 深度解析版)
1️⃣ Q:`var a = 1; let b = 2; window.a 和 window.b 分别是什么?
A:
window.a === 1window.b === undefined
🔍 原理剖析:
var在全局作用域声明的变量会自动成为全局对象(浏览器中为window)的属性,这是 ES5 及之前的设计。let/const声明的变量属于 “脚本作用域”(Script Scope) ,虽然在全局可访问,但不会挂载到window对象上,这是 ES6 为避免全局污染引入的安全机制。
💡 延伸考点:
var c = 3;
let d = 4;
console.log('c' in window); // true
console.log('d' in window); // false
delete window.c; // true(可删除)
delete window.d; // false(d 根本不在 window 上)
✅ 工程建议:永远优先使用
let/const,并通过 ESLint 启用"no-var": "error"规则。
2️⃣ Q:以下代码输出什么?
console.log(x);
let x = 1;
A:抛出 ReferenceError: Cannot access 'x' before initialization
🔍 原理剖析:
let存在 “暂时性死区”(Temporal Dead Zone, TDZ) 。- 虽然
let x的声明会被提升到块级作用域顶部,但初始化(赋值)不会提升。 - 在执行到
let x = 1之前,x处于“已声明但未初始化”状态,任何读写操作都会报错。
⚠️ 常见误区:
很多人误以为 let “没有提升”,其实它有声明提升,只是不像 var 那样初始化为 undefined。
💡 对比记忆:
console.log(a); // undefined(var 提升 + 初始化)
var a = 1;
console.log(b); // ReferenceError(let 提升但未初始化 → TDZ)
let b = 2;
✅ 工程建议:养成“先声明后使用”的习惯,TDZ 是 JS 引擎帮你提前发现 bug 的安全网。
3️⃣ Q:call、apply、bind 有什么区别?
A:三者都用于显式绑定函数的 this,但行为不同:
| 方法 | 参数形式 | 是否立即执行 | 返回值 | 典型用途 |
|---|---|---|---|---|
call | 逐个传参:fn.call(obj, a, b) | ✅ 是 | 函数执行结果 | 临时指定上下文并调用 |
apply | 数组传参:fn.apply(obj, [a, b]) | ✅ 是 | 函数执行结果 | 适配数组参数(如 Math.max.apply(null, arr)) |
bind | 可预设部分参数:fn.bind(obj, a) | ❌ 否 | 新函数(this 已永久绑定) | 创建回调、事件处理器、柯里化 |
🔍 深度细节:
-
bind返回的新函数,其this无法被后续call/apply覆盖(除非用new调用,此时this指向新实例)。 -
bind支持部分应用(Partial Application) :function multiply(a, b) { return a * b; } const double = multiply.bind(null, 2); double(5); // 10
💡 高频陷阱题:
function foo() { console.log(this.val); }
const obj = { val: 1 };
const bound = foo.bind(obj);
bound.call({ val: 2 }); // 输出 1,不是 2!
✅ 工程建议:在 React 类组件中常用
this.handleClick = this.handleClick.bind(this);现代项目更推荐直接使用箭头函数避免绑定。
4️⃣ Q:_printName() 中 this.myName 为何是 '极客邦'?
A:因为 _printName() 是作为普通函数调用,此时 this 指向全局对象 window;而 var myName = '极客邦' 会挂载到 window.myName,所以 this.myName 实际访问的是 window.myName。
🔍 完整链路还原:
var myName = '极客邦'→ 等价于window.myName = '极客邦'_printName = bar.printName→ 获取函数引用(无绑定)_printName()→ 普通调用 →this === windowthis.myName→window.myName→'极客邦'
⚠️ 关键前提:必须使用 var!若改为 let myName = '极客邦',则 window.myName 不存在,this.myName 为 undefined。
💡 调试技巧:在函数开头加 console.log(this === window) 快速判断是否发生默认绑定。
✅ 工程建议:避免依赖全局变量;在对象方法中始终通过
this.xxx访问自身属性。
5️⃣ Q:如何避免 this 丢失?
A:this 丢失通常发生在函数被赋值、传参或作为回调使用时(如 setTimeout(fn, 100))。解决方案有三种:
✅ 方案一:使用 .bind(this)
class Timer {
constructor() {
this.seconds = 0;
setInterval(this.tick.bind(this), 1000); // 绑定 this
}
tick() { this.seconds++; }
}
- 优点:明确、兼容性好
- 缺点:每次
bind都创建新函数,可能影响性能(尤其在循环中)
✅ 方案二:使用箭头函数(推荐)
class Timer {
seconds = 0;
start = () => {
setInterval(() => {
this.seconds++; // 箭头函数继承外层 this(即实例)
}, 1000);
}
}
- 原理:箭头函数无
this,从定义时的词法作用域继承 - 优点:简洁、无额外函数创建、天然绑定
- 注意:不能用作构造函数,也不能用
arguments
✅ 方案三:缓存 this(传统方案)
var self = this;
setTimeout(function() {
self.doSomething(); // 通过闭包访问
}, 100);
- 适用场景:不支持 ES6 的老项目
- 缺点:代码冗余,易出错(如命名冲突)
🔍 对比总结:
| 方案 | 适用场景 | 是否创建新函数 | 可读性 | 现代推荐度 |
|---|---|---|---|---|
.bind(this) | 通用 | ✅ 是 | 中 | ⭐⭐ |
| 箭头函数 | ES6+、类方法、回调 | ❌ 否 | 高 | ⭐⭐⭐⭐⭐ |
缓存 self | 老项目 | ❌ 否 | 低 | ⭐ |
✅ 终极建议:在现代项目中,优先使用箭头函数定义方法或回调;若需动态绑定(如事件委托),再考虑
bind或call。
结语:this 不是魔法,而是规则
this 的混乱源于对“调用上下文”的忽视。一旦你理解了:
- 自由变量 → 看定义(词法作用域)
- this → 看调用(动态绑定)
var污染全局,let安全隔离 + 有 TDZ 保护call/apply/bind是显式掌控 this 的三大利器
你就能在任何场景下预判它的指向。
下次面试官再问 this,你可以微笑着说:
“我知道它在哪,也知道它是谁——而且我绝不用
var给它添乱,也不会在 TDZ 里踩雷。”