JS执行机制(一)

80 阅读5分钟

🧠 深入理解 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() 能提前调用?
  • myNameundefined
  • 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('...'); }

当我们不想让函数被提升时我们可以使用函数表达式

image.png

原因如下:
  • funclet 声明的函数表达式
  • 词法环境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);

执行结果:

image.png

那么其背后的规则是什么?

执行上下文创建时:

  1. 执行全局上下文:提升变量a、函数fn()
  2. fn(3),此时形参的提升大于var,所以a=3
  3. 执行函数上下文,由于a在全局中已经被声明此时函数中var a= 2会被忽略声明,只会在代码执行阶段对其进行赋值操作,但函数 function a() {}会被提升,所以此时a为函数,至此所有声明结束。
  4. 执行到第三步时:函数内部代码就像这样:
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++; 

先看结果:

image.png

  • 基本类型(string, number):存储在 栈内存,赋值是值拷贝
  • 引用类型(object, array):存储在 堆内存,变量保存的是地址

这虽不属于执行上下文,但解释了“为什么 obj2 修改会影响 obj”。


六、总结:JS 执行流程全景图

JS 代码加载
     ↓
【编译阶段】
  ├─ 语法检查
  ├─ 创建执行上下文
  │    ├─ 变量环境:处理 var / 函数声明提升
  │    └─ 词法环境:准备 let/const(TDZ)
  └─ 准备就绪
     ↓
【执行阶段】
  ├─ 全局上下文入栈
  ├─ 按序执行
  │    ├─ 遇函数 → 新上下文入栈
  │    └─ 变量访问 → 查词法环境(沿作用域链向上)
  └─ 函数结束 → 上下文出栈 → GC 回收

✅ 七、最佳实践建议

  1. 优先使用 let/const:避免 var 的意外提升和作用域问题
  2. 不要在块级作用域写函数声明:行为复杂且易混淆
  3. 理解 TDZlet/const 不是“不提升”,而是“提升但不可访问”
  4. 函数声明 vs 表达式:需要提升用声明,否则用箭头函数或 const fn = ...

📚 结语

JavaScript 的执行机制看似简单,实则精妙。
词法环境和变量环境的分离设计,既兼容了历史(var),又引入了现代特性(块级作用域、TDZ)。

理解它们,你就掌握了:

  • 变量提升的本质
  • 闭包的原理
  • 作用域链的查找规则
  • 为什么某些代码会报错而另一些不会