【JS进阶】别再背“谁调用指向谁”了!从内存视角彻底通关 this

47 阅读10分钟

前言

在 JavaScript 的江湖里,this 是一个让无数英雄尽折腰的关键词。
你以为它是 Java 里的 this(永远指向实例)?错。
你以为它由函数定义的位置决定?错。
它像一只变色龙,环境变了,颜色也就变了。

很多同学背诵了无数遍“谁调用指向谁”,但一遇到回调函数、箭头函数或者复杂的面试题,依然会蒙圈。今天,我们不背死记硬背口诀,而是结合底层执行上下文JS 设计历史,彻底扒光 this 的底裤。


image.png

一、 为什么我们需要 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:是动态的。在执行阶段(函数被调用时)才确定。谁调用你,你就指向谁。
从执行上下文角度看

alt text

变量环境,词法环境,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(); // 输出什么?

深度解析:

  1. _printName()  :

    • foo() 返回了 bar.printName 的函数引用,赋值给了 _printName。
    • 当调用 _printName() 时,它是光杆司令,前面没有对象。
    • 金主爸爸:全局对象(Window)。
    • 结果:"极客邦"。
  2. 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 件事:

  1. 创建一个全新的对象 temObj = {}。
  2. 将新对象的原型链 proto 指向构造函数的 prototype。
  3. 关键一步:将构造函数中的 this 绑定到这个新对象上(apply(temObj))。
  4. 如果构造函数没有返回其他对象,就自动返回这个新对象。
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 查找

要讲清楚箭头函数,必须深入到 执行上下文 的内存结构中:

  1. 没有 this 槽位
    在普通函数的执行上下文中,引擎会专门留一个槽位(Internal Slot)来存储 this。但是,箭头函数的执行上下文里,压根就没有这个 this 槽位!它极其“轻量化”。

  2. 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 灵活性的体现。

  1. 执行上下文:时刻记住,this 是在函数执行那一刻决定的(除了箭头函数)。

  2. 箭头函数:本质不是继承,而是没有 this 槽位,通过 outer 指针 沿词法作用域链查找。

  3. 回调必丢:凡是传入回调函数,警惕 this 丢失,优先考虑 箭头函数 或 Bind

  4. 判断优先级

    • new -> 新对象
    • call/apply/bind -> 指定对象
    • obj.func() -> obj
    • 箭头函数 -> 外层 outer 作用域
    • 独立调用 / 回调 -> window 或 undefined

希望这篇文章能帮你彻底解决 JS 里的“身份认同危机”。下次面试官再问 this,你可以自信地告诉他:  “这不过是执行上下文中的一个动态指针罢了。”