《this 指向迷局:从“我是谁”到“我在哪”,JS 面试官最爱问的 this 全解析》

48 阅读12分钟

《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 绑定规则、全局污染全部交织其中。


二、关键前置知识:varlet 的本质差异 —— 为什么 _printName 的声明方式至关重要?

你可能会问:把 var _printName = foo() 改成 let _printName = foo()console.log(_printName) 不都一样吗?

表面上是的,底层却天差地别。

🔍 表面现象:控制台输出相同

无论用 var 还是 letconsole.log(_printName) 都会显示:

ƒ () {
    console.log(myName);
    console.log(bar.myName);
    console.log(this);
    console.log(this.myName);
}

因为 _printName 只是对 bar.printName 函数的引用,控制台展示的是函数体源码。值相同,但“归属”不同。


🧱 深度对比:五大核心差异

特性var _printNamelet _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:能访问到(因为挂载了),造成隐式耦合
  • letthis._printNameundefined,更符合预期。

更重要的是: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 === window
  • bar.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
  • callapplybind 都可以强制指定函数内部的 this 指向,统称为 显式绑定
  • 它们的核心区别在于参数传递方式是否立即执行
🆚 三者详细对比
方法语法参数形式是否立即执行返回值
callfn.call(thisArg, arg1, arg2, ...)逐个列出参数✅ 是函数执行结果
applyfn.apply(thisArg, [arg1, arg2, ...])参数以数组形式传入✅ 是函数执行结果
bindfn.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(),引擎执行四步:

  1. 创建空对象{}

  2. 链接原型obj.__proto__ = Fn.prototype

  3. 绑定 this 并执行Fn.call(obj, ...args)

  4. 决定返回值

    • 若返回引用类型(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 被简单映射为“调用者”。
  • 代价:动态绑定带来灵活性,也带来不确定性。

现代解决方案

  1. 箭头函数:无 this,继承词法作用域;
  2. class 语法糖:语义清晰,默认严格模式;
  3. 严格模式 + ESLint:禁止隐式全局,强制显式处理 this

七、高频面试题关联(大厂真题 · 深度解析版)

1️⃣ Q:`var a = 1; let b = 2; window.a 和 window.b 分别是什么?

A

  • window.a === 1
  • window.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:callapplybind 有什么区别?

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

🔍 完整链路还原

  1. var myName = '极客邦' → 等价于 window.myName = '极客邦'
  2. _printName = bar.printName → 获取函数引用(无绑定)
  3. _printName() → 普通调用 → this === window
  4. this.myName → window.myName → '极客邦'

⚠️ 关键前提:必须使用 var!若改为 let myName = '极客邦',则 window.myName 不存在,this.myNameundefined

💡 调试技巧:在函数开头加 console.log(this === window) 快速判断是否发生默认绑定。

工程建议:避免依赖全局变量;在对象方法中始终通过 this.xxx 访问自身属性。


5️⃣ Q:如何避免 this 丢失?

Athis 丢失通常发生在函数被赋值、传参或作为回调使用时(如 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老项目❌ 否

终极建议:在现代项目中,优先使用箭头函数定义方法或回调;若需动态绑定(如事件委托),再考虑 bindcall

结语:this 不是魔法,而是规则

this 的混乱源于对“调用上下文”的忽视。一旦你理解了:

  • 自由变量 → 看定义(词法作用域)
  • this → 看调用(动态绑定)
  • var 污染全局,let 安全隔离 + 有 TDZ 保护
  • call/apply/bind 是显式掌控 this 的三大利器

你就能在任何场景下预判它的指向。

下次面试官再问 this,你可以微笑着说:

“我知道它在哪,也知道它是谁——而且我绝不用 var 给它添乱,也不会在 TDZ 里踩雷。”