JavaScript 一面:作用域、闭包与 this 指向

很多面试一上来就会从“闭包/this”切一刀,这一篇试着把这几个高频问题盘清楚,避免只停留在背定义的层面。

在大多数 JS 引擎里,可以按照“变量在哪一块代码中可见”来理解作用域:

  • 全局作用域:在任何地方都能访问到的绑定;
  • 函数作用域:只在函数体内可见的绑定;
  • 块级作用域:let / const / class / try...catch 块产生的作用域。

当访问一个标识符时,引擎会沿着作用域链向上查找:

  1. 先看当前作用域是否声明过;
  2. 没有就去上层(外层函数、全局)找;
  3. 一直找到全局为止。

在一面里,常见的考法是:

  • 给出几层嵌套函数,问某个变量实际读到的是哪一层的值;
  • 混合使用 var / let / const,考察变量提升和暂时性死区(TDZ)。

关键点不是记所有细节,而是脑中有一个“从内到外、一层层找”的模型。

简单说,闭包就是“函数 + 它能访问到的外部变量的组合”。
一个典型的例子:

 1function createCounter() {
 2  let count = 0;
 3  return function () {
 4    count++;
 5    return count;
 6  };
 7}
 8
 9const counter = createCounter();
10counter(); // 1
11counter(); // 2

这里返回的匿名函数就形成了闭包:
createCounter 已经执行完,但 count 仍然活着,因为外层还有引用指向那个函数环境。

在一面里,闭包常见考点有:

  • 循环里的闭包for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i)); } 打印什么?为什么?
  • 私有变量:如何用闭包实现“外部无法直接访问的内部状态”?
  • 内存泄漏风险:长生命周期闭包引用了大对象/DOM,忘记释放。

一个比较好的回答方式是:

  • 先用一句话定义闭包;
  • 再用 1~2 个简单例子说明“它能做什么好事”;
  • 最后顺带提一下“用不好也会有代价(内存/调试复杂度)”。

容易混淆的一点是:this 和作用域、闭包没有直接关系
this 的值只取决于“函数是怎么被调用的”,而不是“写在哪里”。

常见的几种调用方式:

  • 直接调用:fn() → 在非严格模式下,this 指向全局对象(浏览器里是 window),严格模式下是 undefined
  • 作为对象方法:obj.method()this 指向 obj
  • 构造函数调用:new Fn()this 指向新创建的实例;
  • 显式绑定:fn.call(obj) / fn.apply(obj) / fn.bind(obj)

在一面题里,经常会把这几种用法混在一起,让你判断每种情况下 this 指向哪里。

箭头函数有一个关键特性:它的 this 来自定义时所在的外层词法环境,不会因为调用方式变化而改变。

例如:

 1const obj = {
 2  value: 42,
 3  method() {
 4    setTimeout(function () {
 5      console.log(this.value); // 取决于谁调用 setTimeout 里的函数
 6    }, 0);
 7
 8    setTimeout(() => {
 9      console.log(this.value); // 始终是 42
10    }, 0);
11  }
12};
13
14obj.method();

这里第一个 setTimeout 里的 function 调用时 this 不是 obj
而箭头函数里的 this 会从 method 的执行上下文里捕获,也就是 obj

一面经常会问:

  • “能不能用箭头函数当构造函数?”(不行)
  • “在对象方法里全用箭头函数有什么坑?”(如 this 不再指向对象本身)

面对一面常见的 this 题,可以用几条规则简化判断:

  1. 先看是不是箭头函数:
    • 是 → this 来自定义时的外层;
    • 否 → 继续往下看。
  2. 再看调用方式:
    • obj.method()this === obj
    • fn() → 非严格模式下是全局对象,严格模式下是 undefined
    • new Fn()this 是新实例;
    • fn.call/apply/bind(obj)this === obj
  3. 如果是回调(如 setTimeout(fn)),要看真正调用它的环境是谁(浏览器里多半是全局)。

面试时不需要把所有细节都背下来,关键是展示出你有一套稳定的分析顺序,而不是靠“死记输出结果”。

这里补几道一面里非常典型的题,以及可以参考的答题方式。

1for (var i = 0; i < 3; i++) {
2  setTimeout(() => {
3    console.log(i);
4  }, 0);
5}

参考答案思路:

  • 这段代码会输出 3, 3, 3
  • 因为 var 没有块级作用域,循环结束时 i 已经变成 3,三个定时器回调里的闭包都共享同一个 i
  • 到真正执行回调时(宏任务阶段),读到的都是同一个最终值 3

怎么改?

  • 使用 let 引入块级作用域:
1for (let i = 0; i < 3; i++) {
2  setTimeout(() => console.log(i), 0); // 0, 1, 2
3}
  • 或者用立即执行函数创建额外作用域:
1for (var i = 0; i < 3; i++) {
2  ((j) => {
3    setTimeout(() => console.log(j), 0);
4  })(i);
5}

讲清“为什么错 + 怎么修”比只背输出更能展示你的理解。

 1var name = "global";
 2
 3const obj = {
 4  name: "obj",
 5  getName() {
 6    console.log(this.name);
 7  }
 8};
 9
10const fn = obj.getName;
11
12obj.getName(); // ?
13fn();          // ?
14fn.call({ name: "call" }); // ?

参考答案:

  • obj.getName() 中,this === obj,输出 "obj"
  • fn() 是普通函数调用,在非严格模式下 this 指向全局对象,输出 "global"
  • fn.call({ name: "call" }) 显式绑定了 this,输出 "call"

可以顺带强调一句:“this 只和调用方式有关,不和函数定义位置、是否是对象属性直接相关”。

1const Foo = () => {
2  this.value = 42;
3};
4
5const obj = new Foo(); // 会发生什么?

参考答案:

  • 箭头函数不能作为构造函数使用,new Foo() 会抛出 TypeError:Foo is not a constructor
  • 箭头函数没有自己的 this,它的 this 来自定义时的外层词法作用域。

顺带可以提一句:
“在对象方法里一股脑用箭头函数,容易让 this 都变成外部作用域,而不是对象本身,这是实际开发中常见的坑。”

把这篇内容压缩到一面几分钟的回答,大致可以这样组织:

  • 先用一两句话解释作用域和作用域链的直觉;
  • 接着用一个简洁的例子说明什么是闭包、能做什么;
  • 再强调一句:this 和作用域无关,只和调用方式有关;
  • 用几条规则总结如何快速判断 this 指向;
  • 如果有时间,可以提一句闭包的内存/调试成本,表现出对工程实践的意识。

面试官通常不只是看你“记不记得概念”,更在意你能不能把这些东西串成一套自己真正理解过的判断逻辑。