🧠 深入理解 JavaScript 执行机制:词法环境与变量环境的区别
“为什么
let会报错而var不会?”
“函数声明到底提升到哪了?”
“JS 到底是怎么一行行执行代码的?”
如果你也曾被这些问题困扰,那么本文将带你从 V8 引擎的视角,彻底搞懂 JavaScript 的执行机制。
一、从一段“反直觉”代码说起
先看这个经典例子:
showName();
console.log(myName);
console.log(hero);
var myName = "张三";
let hero = '钢铁侠';
function showName() {
console.log('showName被执行');
}
输出结果:
showName被执行
undefined
ReferenceError: Cannot access 'hero' before initialization
为什么:
showName()能提前调用?myName是undefined?hero直接报错?
答案就藏在 JS 的 执行机制 中——尤其是 编译阶段 的两个关键角色:词法环境(Lexical Environment) 和 变量环境(Variable Environment) 。
二、JS 执行的两个阶段
Chrome 的 V8 引擎在执行 JS 时,并非“边读边跑”,而是分为两个阶段:
1. 编译阶段(Creation Phase)
- 检查语法错误
- 创建 执行上下文(Execution Context)
- 进行 变量提升(Hoisting)
2. 执行阶段(Execution Phase)
- 按书写顺序逐行执行
- 赋值、调用函数、访问变量
✅ 关键:提升的变量在执行阶段“早就准备好了” —— 但“准备好”的方式,取决于你是
var还是let/const。
三、执行上下文:JS 运行的“容器”
每当 JS 引擎准备执行一段代码(全局或函数),就会创建一个 执行上下文对象,它包含:
- 变量环境(Variable Environment)
- 词法环境(Lexical Environment)
- this 绑定
- 作用域链
这些信息决定了:变量在哪、能不能访问、this 是谁。
而所有执行上下文,由 调用栈(Call Stack) 管理:
- 全局上下文先入栈
- 函数调用 → 新上下文压栈
- 函数结束 → 出栈 → 内存回收
四、词法环境 vs 变量环境:核心区别
这是理解 JS 变量行为的关键!
| 特性 | 变量环境(Variable Environment) | 词法环境(Lexical Environment) |
|---|---|---|
| 主要用途 | 处理 var 和函数声明的提升 | 处理所有变量的运行时查找 |
| 管理的声明 | var, 函数声明 | let, const, class, 函数参数, 以及 var(用于查找) |
| 是否支持块级作用域 | ❌ 不支持 | ✅ 支持(每个 {} 创建新 LE) |
| 是否有 TDZ | ❌ 无 | ✅ let/const 有暂时性死区 |
| 更新时机 | 创建后基本不变 | 动态更新(赋值、进入块等) |
💡 简单记:
“提升看变量环境,查找看词法环境”
五、验证机制
示例 1:var 的提升
var a = 1;
var a = 2;
console.log(a); // 2
- 编译阶段:变量环境创建
a: undefined - 执行阶段:两次赋值,最终为
2 var允许重复声明 → 但会忽略后面的声明,只进行赋值操作
示例 2:函数表达式
func();
let func = () => { console.log('...'); }
当我们不想让函数被提升时我们可以使用函数表达式
原因如下:
func是let声明的函数表达式- 词法环境中
func处于 TDZ - 访问时报错:不能在初始化前使用
✅ 函数声明(
function fn(){})会提升,函数表达式(let fn = function(){})不会!
示例 3:复杂函数内部提升
既然多种形式的声明都会对变量进行提升,那么他们之间的关系又会是什么样的呢?
- 根据下面这个代码解开谜题:
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {}
console.log(a); /
}
fn(3);
执行结果:
那么其背后的规则是什么?
执行上下文创建时:
- 执行全局上下文:提升变量a、函数fn()
- fn(3),此时形参的提升大于var,所以a=3
- 执行函数上下文,由于a在全局中已经被声明此时函数中var a= 2会被忽略声明,只会在代码执行阶段对其进行赋值操作,但函数 function a() {}会被提升,所以此时a为函数,至此所有声明结束。
- 执行到第三步时:函数内部代码就像这样:
function fn(a) {
function a() {};//函数被提升
console.log(a); //此时a为函数
a=2//因为a只会忽略声明,但还会执行赋值操作
console.log(a); //a=2
}
5.于是最终就有了上述图中的结果
规则优先级:函数声明 > 形参 > var 声明
示例 4:引用类型 vs 基本类型
let str = 'hello';
let str2 = str;
str = 'nihao';
let obj = { name: '张三' };
let obj2 = obj;
obj2.age++;
先看结果:
- 基本类型(string, number):存储在 栈内存,赋值是值拷贝
- 引用类型(object, array):存储在 堆内存,变量保存的是地址
这虽不属于执行上下文,但解释了“为什么 obj2 修改会影响 obj”。
六、总结:JS 执行流程全景图
JS 代码加载
↓
【编译阶段】
├─ 语法检查
├─ 创建执行上下文
│ ├─ 变量环境:处理 var / 函数声明提升
│ └─ 词法环境:准备 let/const(TDZ)
└─ 准备就绪
↓
【执行阶段】
├─ 全局上下文入栈
├─ 按序执行
│ ├─ 遇函数 → 新上下文入栈
│ └─ 变量访问 → 查词法环境(沿作用域链向上)
└─ 函数结束 → 上下文出栈 → GC 回收
✅ 七、最佳实践建议
- 优先使用
let/const:避免var的意外提升和作用域问题 - 不要在块级作用域写函数声明:行为复杂且易混淆
- 理解 TDZ:
let/const不是“不提升”,而是“提升但不可访问” - 函数声明 vs 表达式:需要提升用声明,否则用箭头函数或
const fn = ...
📚 结语
JavaScript 的执行机制看似简单,实则精妙。
词法环境和变量环境的分离设计,既兼容了历史(var),又引入了现代特性(块级作用域、TDZ)。
理解它们,你就掌握了:
- 变量提升的本质
- 闭包的原理
- 作用域链的查找规则
- 为什么某些代码会报错而另一些不会