JS 作用域 & 执行上下文:从“看不见的变量”聊到面试高频考点💡

121 阅读9分钟

面试时,只要聊到 JS 基础,面试官八成会绕不开这几个问题:

  • 变量提升到底是怎么回事?
  • 为什么 var 这么“反直觉”,但 JS 却一直没把它干掉?
  • ES6 的 let / const、块级作用域、暂时性死区,到底怎么和老的 var 和平共处?

这篇文章就沿着这条主线,从 执行机制 → 作用域 → 变量提升的问题 → ES6 一国两制,一步步把这些问题聊清楚。

1. JS 执行机制:代码是怎么“跑”起来的?🏃‍♂️

先把“底层世界观”立住:JS 在 V8 里是这么跑的——

  • V8 引擎:负责 JS 代码的 编译 + 执行

  • 两个阶段

    • 编译阶段(执行前一刹那)

      • 语法检查
      • 收集变量声明、函数声明
      • 处理 变量提升(hoisting)
    • 执行阶段

      • 按顺序一行一行跑代码
      • 读/写变量时,从当前的 执行上下文 里查
  • 调用栈(Call Stack)

    • 函数为单位入栈,执行完出栈,局部变量被回收。
    • 栈底永远是 全局执行上下文
    • 每次调用一个函数,就会创建一个新的执行上下文压入栈顶。

可以用一句话记住:

JS = 单线程 + 调用栈 + 一个个执行上下文对象。

2. 一国两制:var 和 let/const 分治天下 🏛️

到了 ES6 之后,执行上下文内部大致长这样:

  • 变量环境(Variable Environment)

    • 存:var、函数声明、形参
    • 特点:变量提升,在编译阶段就被创建并初始化为 undefined 或函数对象
  • 词法环境(Lexical Environment)

    • 存:let / const / class

    • 特点:

      • 只在 声明的块 {} 内有效 → 块级作用域
      • 声明前不可访问 → 暂时性死区(TDZ)

“一国两制:let/const 放在词法环境中,var 放在变量环境中。”

用表格对比更直观一点 👇

特性var(变量环境)let / const(词法环境)
提升行为声明 + 初始化为 undefined只登记名字,不初始化(TDZ)
作用域函数作用域 / 全局作用域块级作用域(最近的一层 {}
声明重复允许重复声明不允许同一作用域内重复声明
是否挂到全局对象在全局下会挂到 window/global不会

3. 作用域:变量查找的规则到底是啥?🔍

作用域是三件事的合集:

  1. 变量查找的规则
  2. 作用域链是变量的查找路线
  3. 变量在哪里定义,就决定了它在哪里可见 & 何时销毁

一句标准定义:

作用域指在程序中定义变量的区域,该位置决定了变量的可见性和生命周期。
作用域控制着变量和函数的可见性和生命周期。

常见三种作用域:

  • 全局作用域

    • 在任何地方都能访问
    • 生命周期 ≈ 页面 / 进程 的生命周期
  • 函数局部作用域

    • 只能在函数内部访问
    • 生命周期 = 函数执行周期
  • 块级作用域

    • ES5 不支持
    • ES6 通过 let / const 支持

当你在某行代码里访问一个变量时,查找流程是:

  1. 先在当前块级作用域中找(当前词法环境栈顶那一层)
  2. 找不到 → 往外一层作用域找(外层块 / 函数 / 全局)
  3. 一直找到全局
  4. 还找不到 → 抛 ReferenceError

这条从“当前作用域”一路向外找的路线,就是 作用域链

4. 变量提升 hoisting:设计缺陷还是时代产物?⚙️

“正式由于 JS 存在变量提升特性,从而导致很多与直觉不符的代码,是 JS 一个设计缺陷。”

举个最典型的小例子:


console.log(a); // ?
var a = 1;

执行结果是:

undefined

从执行上下文角度看:

  • 编译阶段:

    • 在变量环境中创建绑定 a,并初始化为 undefined
  • 执行阶段:

    • 第一行访问 a → 读到 undefined
    • 第二行给 a 赋值为 1

再比如:

showName();
console.log(myname); // undefined
var myname = '路明非';
function showName() {
  console.log('showName 执行了');
}

函数声明也会被提升,而且优先级更高,于是函数可以在声明之前调用,而 myname 只被提升为 undefined

🔥 1:为什么变量提升有缺陷,但还要这么设计呢?

这一问其实直接戳到了 JS 的历史:

  • JavaScript 当年只是一个“KPI 项目”,为了浏览器大战仓促上线,设计周期很短。

  • 初衷只是给页面加一点动态效果,没人预料它会变成今天这么大的生态。

  • 为了尽快实现一个“够用的”语言,减少复杂度

    • 没有块级作用域(和其他语言不同)
    • 统一把函数体里的 var 在执行前全部“提升”到函数的执行上下文顶部
    • 这是当时最快、最简单的实现方案

缺点当然也很明显:

  • 变量容易在不被察觉的情况下被覆盖
  • 本应该销毁的变量没有被销毁
  • 很多代码与直觉不符,学习成本高

但这些“缺陷”一旦被写进了无数老项目,就变成历史包袱
不能简单改,否则老代码全挂。于是 ES6 只能在这个前提下“补锅”。

5. 变量提升带来的问题(配合例子看得更清)💣

问题 1:变量容易在不被察觉的情况下被覆盖

var name = '外面的';

function demo() {
  console.log(name); // undefined
  if (true) {
    var name = '里面的';
  }
  console.log(name); // '里面的'
}

demo();
  • 你以为 if 里有“块级作用域”,实际上没有。

  • var name 在整个函数作用域内都被提升,导致:

    • 第一次打印:undefined
    • 第二次打印:里面的

问题 2:本应该销毁的变量没有被销毁

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

输出是:

3
3
3
  • 因为 i 是 var,属于整个函数 / 全局作用域。
  • 循环结束时 i === 3,所有回调里读到的都是同一个 i

这些都是面试高频坑题,本质都指向一句话:

var 只有函数作用域 + 变量提升,没有块级作用域

问题 3:ES5 的坑:只有函数作用域,没有块级作用域

var name = '刘 jm';  
  
function showName() {  
console.log(name); // ?  
if (true) { // 这里你以为有“块级作用域”  
var name = '苗子';  
}  
console.log(name); // ?  
}  
  
showName();

实际输出是:

undefined
苗子

【面试高频】
“为什么 if 里面 var 声明的变量,if 外面还能访问?”

考点:var 没有块级作用域 + 变量提升 + 函数作用域

6. 🔥 JS 如何在现在让变量提升和块级作用域统一和谐?

这就是 ES6 的“补锅思路”:从执行上下文的角度下手

  • 保留 var 的老行为
    → 继续放在 变量环境,继续有变量提升
  • 新增 let/const
    → 放在 词法环境,引入暂时性死区 + 块级作用域

第一步:编译并创建执行上下文 🧱

仍然以“一段函数代码”为单位看:

  • 变量环境

    • 收集所有 var、函数声明、形参

    • 全部提升,初始化:

      • var x; → x = undefined
      • function foo(){} → foo = <函数对象>
  • 词法环境

    • 收集所有 let/const
    • 只登记名字,暂不初始化
    • 这些绑定此时处于 暂时性死区(TDZ)

“第一步编译并创建执行上下文 → 变量环境:变量提升;词法环境:暂时性死区、块级作用域”。

7. 继续执行到块级作用域:词法环境里的“小型栈结构”📚

接下来,当代码执行到一个块 { ... },并且块里有 let/const 时,会发生什么?

  1. 为这个块创建一个新的词法环境(可以想象为“栈顶多了一层”)

  2. 块内的 let/const 声明,全都放在这层里

  3. 在块中访问变量时:

    • 先查栈顶(当前块级词法环境)
    • 再往下查外层(函数级 / 全局)的词法环境
  4. 块执行完 }

    • 这层词法环境整体“出栈”
    • 块里的变量对外界不可访问

用你写过的一段典型代码来抽象一下(去掉具体名字):

function fun() {
  var a = 1;   // 变量环境:a
  let b = 2;   // 词法环境(函数级):b

  {
    let b = 3; // 块级词法环境:b
    var c = 4; // 变量环境:c
    let d = 5; // 块级词法环境:d
    console.log(a); // 1(从变量环境)
    console.log(b); // 3(从块级词法环境)
  }

  console.log(b); // 2(从函数级词法环境)
  console.log(c); // 4(从变量环境)
  console.log(d); // ReferenceError(块级环境出栈)
}

0e8cc3414f3c4d74805dfc3413325251.png

刚创建 fun 的执行上下文(“编译/准备阶段”)

a516264f546f4450bb8f444431fa2a69.png

执行过前两行,并且刚“进入块”,创建了块级词法环境

ec31b4eeabbb4a9e9ea4b735e580efb6.png

执行 console.log(a) 流程

  • “块级作用域中通过 let/const 声明的变量会被放在词法环境的一个单独的区域”
  • “在词法环境内部,维护了一个小型的栈结构”
  • “块级作用域执行完后,要出栈,可以确保外界不可访问”

8. 暂时性死区:词法环境的“护身符”🛡️

再看一个典型的 TDZ 例子:

let name = '苗子';

{
  console.log(name);      // ReferenceError
  let name = '路明非';
}
  • 进入块 {} 时:

    • 为块创建一个新的词法环境
    • 在其中登记一个名字叫 name 的绑定,但不初始化(TDZ)
  • 执行 console.log(name)

    • 先在块级词法环境中命中这个“未初始化”的 name
    • 规范规定:一旦当前环境中存在这个绑定,就不会去外层环境找
    • 于是抛出 ReferenceError: Cannot access 'name' before initialization

这正是“暂时性死区”的语义:禁止在声明前使用 let/const 变量
结合块级作用域和 TDZ,ES6 给了我们一个更安全、更贴近直觉的变量系统。

9. 🔥 ES6 是如何支持块级作用域的?

可以用一句话来回答这道面试题:

从执行上下文的角度看,ES6 是通过“栈结构的词法环境”来支持块级作用域的。

展开来说就是:

  1. 在每个执行上下文中维护一个 词法环境链

  2. 进入一个有 let/const 的块 {}

    • 新建一个块级词法环境对象(环境记录)
    • 指向上一层的词法作用域
    • 压栈
  3. 声明 let/const 时:

    • 把绑定记录在当前块级环境中
  4. 块执行完 }

    • 出栈
    • 块内变量随之失效

这样:

  • 旧代码:继续用 var,在变量环境里享受变量提升,行为完全兼容。
  • 新代码:用 let/const,由词法环境 + TDZ + 块级作用域来保护。

这就是“在不打破历史的前提下,让变量提升和块级作用域统一起来”的核心思路。

10. 面试小抄:看到这些代码应该想到什么?📌

可以给自己留一份简短 checklist:

  • 看到“变量在声明前被使用”:

    • var → 提升,undefined
    • let/const → TDZ,ReferenceError
  • 看到 if/for/while + var

    • 记住:没有块级作用域,只看整个函数 / 全局
  • 看到同名的 var 和 let 出现在不同块里:

    • 想到:变量环境 VS 词法环境,哪个会“遮蔽”哪个?
  • 被问:

    • “为什么变量提升有缺陷,但还要这么设计?”
    • “JS 如何在现在让变量提升和块级作用域统一和谐?”
    • “ES6 是如何支持块级作用域的?”
    • ——直接从 执行上下文 → 变量环境 / 词法环境 → 栈结构 → TDZ 这条线回答,就已经是“高级玩家”了。