JavaScript 的作用域是前端开发者必须扎实掌握的基础。
但很多人学到 ES6 后发现一个现象:
同为声明变量,var 和 let/const 的行为却完全不同。
这背后不是语法层面的差异,而是 JS 引擎内部机制 的差异。
尤其是 ES6 之后,JS 的变量模型正式进入 “一国两制” :
var→ 早期历史设计,基于 变量环境(Variable Environment)let/const→ ES6 新机制,基于 词法环境(Lexical Environment)
本文将从 JavaScript 引擎执行过程入手,深入解析作用域、作用域链、变量提升、TDZ、块级作用域栈等底层机制。阅读完成后,你会真正理解 JS 为什么“看上去怪怪的”,以及 ES6 如何弥补历史缺陷。
1. JS 的运行机制:编译阶段 + 执行阶段
JavaScript 并不是传统意义上的解释型语言,现代 JS 引擎(如 V8)执行代码分两步:
① 编译阶段
- 生成 AST(语法树)
- 创建执行上下文(Execution Context)
- 构建变量环境 & 词法环境
- 完成变量提升(Hoisting)
- 建立作用域链
② 执行阶段
- 自上而下运行代码
- 根据作用域链查找变量
- 创建/销毁块级作用域
理解这两个阶段,是理解 JS 作用域与提升现象的关键。
2. “变量提升”:JS 早期的设计妥协
看这段经典代码:
showName();
console.log(myname); // undefined
var myname = '张三';
function showName(){
console.log("函数showName执行了");
}
输出:
函数showName执行了
undefined
很多初学者以为 JS 是“从上到下运行”,但这是执行阶段的行为;
在编译阶段,JS 会将代码变成如下形态:
function showName(){ ... }
var myname; // 提升并初始化 undefined
showName();
console.log(myname);
myname = '张三';
这带来了几个问题:
- 变量可在声明前访问(不合理)
- 变量可能被悄悄覆盖
- 缺乏块级作用域
这些都是 ES5 的设计遗留问题。
3. ES6 引入词法环境:JS 进入“一国两制”时代
为了解决 ES5 的问题,ES6 引入:
- let / const
- 块级作用域 {}
- 暂时性死区(TDZ)
底层的变化是多了一个词法环境(Lexical Environment) :
| 声明方式 | 存放位置 | 是否提升 | 是否初始化 | 是否有 TDZ | 是否支持块级作用域 |
|---|---|---|---|---|---|
| var | 变量环境 | ✔ | undefined | ❌ | ❌ |
| let | 词法环境 | ✔ | ❌(不初始化) | ✔ | ✔ |
| const | 词法环境 | ✔ | ❌ | ✔ | ✔ |
所以 JS 变成了:
var 走老路(变量环境),let/const 走新路(词法环境) —— 典型的一国两制。
4. 执行上下文:作用域的根源结构
每当代码执行到“可运行节点”(如全局、函数)时,引擎会创建一个执行上下文,包括两个重要区域:
执行上下文(Execution Context)
│
├── 变量环境(Variable Environment) // var 在这里
│
└── 词法环境(Lexical Environment) // let/const、块级作用域在这里
这两者共同构成了作用域查找链,也决定了提升规则。
5. 作用域链:变量查找路径
当你访问一个变量时,JS 按以下顺序查找:
- 当前词法环境(可能是块级环境,也可能是函数环境)
- 外层词法环境
- 再外层……
- 最终到达全局环境
可视化为一个栈结构:
词法环境栈(从上到下)
│
├── 块级作用域({ })
├── for/if 等循环块
├── 当前函数作用域
└── 全局作用域
而 var 不在这个栈中,它在 “变量环境” 内,不受块级作用域影响。
6. 暂时性死区(TDZ):让 let/const 更安全
let name = "张三";
{
console.log(name); // ReferenceError
let name = "李四";
}
为什么不是 “张三”?
原因是:块内一旦声明 let name,就会屏蔽外层同名变量。
执行过程:
- 进入
{}→ 创建新的块级词法环境(提升 name) - name 被提升但未初始化
- 执行
console.log(name)→ 访问未初始化变量 → 抛错
TDZ 的作用:
- 禁止在声明前使用 let/const
- 防止变量被意外覆盖
- 增强语言的安全性
7. var 在 if 中声明会提升到函数顶部
var name = "张三";
function showName(){
console.log(name); // undefined
if(false){
var name = "李四";
}
console.log(name); // undefined
}
showName();
因为在编译阶段,函数内部会被处理成这样:
function showName(){
var name; // 提升
console.log(name);
if(false){
name = "李四";
}
console.log(name);
}
无论 if 是否执行,声明一定提升到函数顶部。
8. let 支持块级作用域,不提升到函数顶部
let name = "张三";
function showName(){
console.log(name); // 张三
if(false){
let name = "李四"; // 块级环境,不会污染外层
}
}
showName();
因此 let 完全避免了 var 的提升陷阱。
9. for(let) 循环:每次迭代都是一个全新块级作用域
function foo(){
for(let i = 0; i < 7; i++){}
console.log(i); // ReferenceError
}
foo();
底层机制是:每一次循环都会创建独立的词法环境。
与之对比:
for(var i = 0; i < 7; i++){}
console.log(i); // 7
因为 var 完全没有块级概念。
10. 块级作用域的栈结构示例(非常关键)
例如:
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a); // 1
console.log(b); // 3
}
console.log(b); // 2
console.log(c); // 4
console.log(d); // 报错
}
foo();
原因分析:
块级作用域执行时会创建词法环境栈:
栈顶 → { b=3, d=5 } // block 作用域
{ b=2 } // 函数作用域
栈底 → 全局作用域
变量查找规则:从栈顶往下
- 块内
b→ 找到的是3 - 块外
b→ 块级环境已出栈,所以是2 c→ var,不在栈结构中,而在变量环境,所以可访问d→ 已随块级作用域出栈,访问报错
这正是 ES6 块级作用域的底层实现原理。
11. 总结:作用域的一张全图
执行上下文(Execution Context)
/ \
Variable Environment Lexical Environment
(存 var) (存 let/const/函数/块级环境)
|
块级作用域(栈结构)
|
暂时性死区(TDZ)
核心结论:
- JS 引擎先编译再执行
- var 基于“变量环境”,存在提升,不支持块级作用域
- let/const 基于“词法环境”,有 TDZ,支持块级作用域
- 块级作用域由词法环境的“栈结构”实现
- 作用域链按词法嵌套,而不是函数调用位置