全方位解释 JavaScript 执行机制(从底层到实战)

196 阅读7分钟

在 JavaScript 学习中,变量提升、作用域屏蔽等问题常常让初学者困惑。比如一段看似简单的代码,却能引出关于执行机制的深层思考。本文将以如下代码为例,从执行上下文调用栈的底层视角等等,完整拆解代码的执行流程,带你看透 JS 代码运行的核心逻辑。

一、JS 是如何执行的?

在 Chrome 浏览器中,JavaScript 的执行由 V8 引擎负责。
V8 在运行 JS 代码时分为两个阶段:

1️⃣ 编译阶段

在代码执行前的一刹那,V8 会:

工作内容:

  1. 语法分析
    检查语法错误(比如括号、花括号是否配对)。

  2. 变量提升(Hoisting)

    • var 声明的变量 → 提前创建并赋值为 undefined
    • 函数声明(function xxx(){}) → 整体提升(优先级最高)
  3. 创建执行上下文对象 (Execution Context Object)

    • 包含三部分:

      • 变量环境
      • 词法环境
      • 可执行代码
  4. 把执行上下文压入调用栈 (Call Stack)

    • 全局上下文 → 首先压栈
    • 函数被调用 → 创建新的函数上下文 → 压栈

2️⃣ 执行阶段

编译完后开始执行:

  1. 变量和函数声明已准备好
  2. 按代码顺序逐行执行
  3. 函数调用 → 创建新上下文 → 压栈
  4. 函数执行完毕 → 上下文出栈(销毁,等待垃圾回收)

二、执行上下文与调用栈

V8 通过一个叫做 调用栈(Call Stack) 的结构来管理代码执行过程。

我们可以把它想象成一个「任务清单」:

  1. 全局执行上下文(Global Execution Context) 首先创建并压入栈底;
  2. 当执行函数时,会创建一个新的函数执行上下文,并压入栈顶;
  3. 函数执行完毕后,从栈顶弹出(出栈);
  4. 栈顶总是代表当前正在执行的上下文。

JS 引擎启动后,会自动创建一个 全局执行上下文

此时,执行栈中只有它一个上下文

┌────────────────────┐ ← 栈顶
│ 全局执行上下文      │
└────────────────────┘ ← 栈底

✅ 所以,在创建全局执行上下文时,它既是第一个入栈的
也是当前栈顶的上下文

var a = 1;
function fn(a) {
  var a = 2;
  function a() {}
  var b = a;
  console.log(a);
}
fn(3);

调用栈变化示意:

阶段栈顶内容说明
初始全局上下文代码准备执行
调用 fn(3)fn 执行上下文函数被调用,压入栈顶
执行完 fn全局上下文函数上下文出栈
程序结束全局上下文销毁页面关闭或脚本结束

① 程序开始 → 创建全局执行上下文

[ 全局执行上下文 ]
变量环境: { a: undefined, fn: <function> }
词法环境: {}
代码: 全局代码

执行到 a = 1; fn(3); 时:

名称
a1
fnfunction

② 调用 fn(3) → 创建新的函数执行上下文

┌────────────────────┐ ← 栈顶(当前执行环境)
│ fn 执行上下文       │
├────────────────────┤
│ 全局执行上下文      │ ← 栈底
└────────────────────┘

JS 引擎调用函数 fn,于是创建 fn 的执行上下文,并压入栈顶

此时:

  • 全局还在栈中(没被销毁);

  • 但栈顶变成了 fn

  • JS 正在执行 fn 函数体的代码。

编译阶段:

逐步提升分析:

  1. 形参 a → 先在环境中占位

    a = 3 (调用时传入的参数)
    
  2. 发现函数声明 function a() {}
    提升并覆盖前面的 a

    a = function a() {}
    
  3. 发现 var a = 2;
    var a 部分已存在(被提升过),此时不会再声明,只会在执行阶段再赋值。

  4. 发现 var b;
    b = undefined

编译阶段结束后:

名称
afunction a() {}
bundefined
fn 执行上下文
变量环境:
  a: function a(){}   // 函数声明覆盖形参
  b: undefined
词法环境:
  (空)
代码:
  var a = 2;
  function a() {}
  var b = a;
  console.log(a);

执行阶段:

  1. var a = 2; → a = 2(覆盖变量环境中的 a: function a(){})
  2. var b = a; → b = 2
  3. console.log(a); → 输出 2

然后函数执行完毕 → 出栈。


③ 回到全局上下文

调用栈恢复为:全局执行上下文

执行栈状态:
┌────────────────────────┐
│ fn 函数执行上下文       │ ← 出栈(弹出)
├────────────────────────┤
│ 全局执行上下文          │ ← 回到全局
└────────────────────────┘

最终执行栈:
┌────────────────────────┐
│ 全局执行上下文          │
└────────────────────────┘

程序执行结束。

三、函数表达式不会被提升

我们来看一个非常经典的坑:

func(); // ❌ ReferenceError
let func = () => {
  console.log('函数表达式不会提升');
}

1️⃣ 编译阶段:

  • 变量 func 被登记进 词法环境
  • 但由于是 let 声明,它尚未初始化
  • 此时 func 处于 暂时性死区(TDZ)

2️⃣ 执行阶段:

  • 执行到 func(); 时,JS 发现 func 尚未初始化;

  • 于是抛出:

    ReferenceError: Cannot access 'func' before initialization
    

对比 var

func(); // ❌ TypeError: func is not a function
var func = function() {}
  • var 提升会使 func 被初始化为 undefined
  • 调用时相当于 undefined()
  • 所以报的是 TypeError

✅ 结论:let / const 存在暂时性死区;var 会变量提升。

四、严格模式下的执行机制

'use strict';
var a = 1;
var a = 2;

许多人以为“严格模式会禁止重复声明”,但其实不然。

严格模式下:

  • var 依然允许重复声明
  • 只是禁止未声明变量直接使用;
  • 禁止 this 自动绑定到全局对象;
  • 禁止删除变量;
  • 禁止函数参数重名等。

所以上面的代码仍然能正常执行,最终 a = 2

只有 letconst 声明时,重复定义才会抛出错误。


五、拓展:严格模式的其他影响

特性普通模式严格模式
未声明直接赋值自动创建全局变量❌ 报错
重复声明 var✅ 允许✅ 允许
重复声明 let/const❌ 报错❌ 报错
this 指向全局对象(window)undefined
删除变量静默失败❌ 报错
函数参数重名✅ 允许❌ 报错

六、JS 底层机制(内存):值类型与引用类型详解

// 基本数据类型(Number):存储在栈内存中
let num = 1;

// 引用数据类型(Object):栈内存存储引用地址,堆内存存储实际对象
let obj = { age: 18 };

image.png

1.简单数据类型

let num1 = 10;
let num2 = 20;
num1 = num2;
console.log(num1);

1️⃣ 编译阶段

  • JS 引擎在栈内存中为 num1num2 各分配一块空间;
  • 它们都属于简单数据类型(number)
  • 值直接存在栈中。

2️⃣ 执行阶段

num1 = num2;

这一步只是把 num2值 20 拷贝一份赋给 num1
它们之间完全没有引用关系

2.复杂数据类型

let obj1 = {age:18};

let obj2 = obj1;
console.log(obj2);

image.png

1️⃣ 编译阶段

JavaScript 引擎在栈内存中登记两个变量名:

obj1 → undefined
obj2 → undefined

(此时只是变量声明,还未赋值)


2️⃣ 执行阶段

开始一行行执行代码👇

let obj1 = { age: 18 };
  • 堆内存中创建一个对象 { age: 18 }
  • 假设它在堆内存中的地址是 0x12312
  • 然后在栈中保存 obj1 → 0x12312(也就是对象的引用地址)。

当前内存图:

栈内存:
obj1 → 0x12312

堆内存:
0x12312 → { age: 18 }
let obj2 = obj1;

并不会在堆中创建新对象;

只是把 obj1 的地址拷贝一份给 obj2;

所以现在两个变量都指向同一个堆内存对象。

内存示意图:

栈内存:
obj1 → 0x12312
obj2 → 0x12312

堆内存:
0x12312 → { age: 18 }
console.log(obj2);
  • 输出 obj2 当前指向的对象,即堆内存中地址 0x001 里的数据;
  • 结果:{ age: 18 }

🚨七、 JS 执行机制与内存总结

1️⃣ 执行机制

  • JS 由 V8 引擎执行,分为 编译阶段执行阶段
  • 编译阶段:创建执行上下文、变量提升、语法检查。
  • 执行阶段:按顺序执行代码,遇到函数会创建新的执行上下文压入调用栈
  • 函数执行完毕后,执行上下文从栈中弹出(退栈,释放内存)。

2️⃣ 数据类型与内存

类型存储位置保存内容拷贝方式是否共享
简单类型(Number、String、Boolean、null、undefined、Symbol、BigInt)值拷贝❌ 否
复杂类型(Object、Array、Function)栈 + 堆地址引用拷贝✅ 是

🔍参考文档:mdn