面试时,只要聊到 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. 作用域:变量查找的规则到底是啥?🔍
作用域是三件事的合集:
- 变量查找的规则
- 作用域链是变量的查找路线
- 变量在哪里定义,就决定了它在哪里可见 & 何时销毁
一句标准定义:
作用域指在程序中定义变量的区域,该位置决定了变量的可见性和生命周期。
作用域控制着变量和函数的可见性和生命周期。
常见三种作用域:
-
全局作用域
- 在任何地方都能访问
- 生命周期 ≈ 页面 / 进程 的生命周期
-
函数局部作用域
- 只能在函数内部访问
- 生命周期 = 函数执行周期
-
块级作用域
- ES5 不支持
- ES6 通过
let / const支持
当你在某行代码里访问一个变量时,查找流程是:
- 先在当前块级作用域中找(当前词法环境栈顶那一层)
- 找不到 → 往外一层作用域找(外层块 / 函数 / 全局)
- 一直找到全局
- 还找不到 → 抛
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 = undefinedfunction foo(){}→foo = <函数对象>
-
-
词法环境
- 收集所有
let/const - 只登记名字,暂不初始化
- 这些绑定此时处于 暂时性死区(TDZ)
- 收集所有
“第一步编译并创建执行上下文 → 变量环境:变量提升;词法环境:暂时性死区、块级作用域”。
7. 继续执行到块级作用域:词法环境里的“小型栈结构”📚
接下来,当代码执行到一个块 { ... },并且块里有 let/const 时,会发生什么?
-
为这个块创建一个新的词法环境(可以想象为“栈顶多了一层”)
-
块内的
let/const声明,全都放在这层里 -
在块中访问变量时:
- 先查栈顶(当前块级词法环境)
- 再往下查外层(函数级 / 全局)的词法环境
-
块执行完
}:- 这层词法环境整体“出栈”
- 块里的变量对外界不可访问
用你写过的一段典型代码来抽象一下(去掉具体名字):
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(块级环境出栈)
}
刚创建 fun 的执行上下文(“编译/准备阶段”)
执行过前两行,并且刚“进入块”,创建了块级词法环境
执行 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 是通过“栈结构的词法环境”来支持块级作用域的。
展开来说就是:
-
在每个执行上下文中维护一个 词法环境链。
-
进入一个有
let/const的块{}:- 新建一个块级词法环境对象(环境记录)
- 指向上一层的词法作用域
- 压栈
-
声明
let/const时:- 把绑定记录在当前块级环境中
-
块执行完
}:- 出栈
- 块内变量随之失效
这样:
- 旧代码:继续用
var,在变量环境里享受变量提升,行为完全兼容。 - 新代码:用
let/const,由词法环境 + TDZ + 块级作用域来保护。
这就是“在不打破历史的前提下,让变量提升和块级作用域统一起来”的核心思路。
10. 面试小抄:看到这些代码应该想到什么?📌
可以给自己留一份简短 checklist:
-
看到“变量在声明前被使用”:
var→ 提升,undefinedlet/const→ TDZ,ReferenceError
-
看到
if/for/while+var:- 记住:没有块级作用域,只看整个函数 / 全局
-
看到同名的
var和let出现在不同块里:- 想到:变量环境 VS 词法环境,哪个会“遮蔽”哪个?
-
被问:
- “为什么变量提升有缺陷,但还要这么设计?”
- “JS 如何在现在让变量提升和块级作用域统一和谐?”
- “ES6 是如何支持块级作用域的?”
- ——直接从 执行上下文 → 变量环境 / 词法环境 → 栈结构 → TDZ 这条线回答,就已经是“高级玩家”了。