【 前端三剑客-11/Lesson22(2025-11-06)】JavaScript 执行机制全解:从 V8 引擎到调用栈,从变量提升到闭包内存管理🔥

119 阅读8分钟

🔥本文献给所有在深夜调试 undefinedReferenceError 的你。JavaScript 并非“随便写写就能跑”的玩具语言——它的执行机制精妙、严谨,甚至充满哲学意味。掌握它,是成为 AI+全栈工程师 的必经之路。


🌐 为什么你需要彻底理解 JS 执行机制?

你是否曾遇到以下场景:

  • 明明函数写在后面,却能在前面调用?
  • var 声明的变量输出 undefined,而 let 直接报错?
  • 函数名和变量名冲突时,到底谁赢?
  • 修改一个对象,另一个“看似无关”的对象也变了?

这些不是玄学,而是 JavaScript 执行机制 的自然结果。
JS 虽然是解释型脚本语言,但现代引擎(如 Chrome 的 V8)早已采用 即时编译(JIT) 技术,在执行前进行深度分析。理解这一过程,不仅能写出更健壮的代码,还能在面试中秒杀 90% 的候选人。


⚙️ 一、JavaScript 的两大阶段:编译 + 执行

很多人误以为 JS 是“边解释边执行”,其实不然。
根据《你不知道的 JavaScript》(You Don’t Know JS)系列作者 Kyle Simpson 的观点,JavaScript 在执行前会经历一个“编译阶段”,尽管这个阶段极短,但至关重要。

JavaScript 虽然是解释型脚本语言,但现代引擎(如 Chrome 的 V8)早已采用 即时编译(JIT) 技术,在执行前进行深度分析。理解这一过程,不仅能写出更健壮的代码,还能在面试中秒杀 90% 的候选人。

下图展示了 JavaScript 从源码到执行的完整生命周期:

image.png

如图所示,JS 代码并非“直接运行”,而是经历 词法分析 → 语法分析 → 编译(含提升)→ 执行上下文创建 → 调用栈执行 的完整链路。

✅ 编译阶段(Compilation Phase)

V8 引擎在真正执行代码前,会完成以下工作:

  1. 语法错误检测
    若代码存在语法错误(如 if (true {),V8 会在编译阶段直接抛出 SyntaxError根本不会进入执行阶段

  2. 词法分析(Lexical Analysis)
    将源码拆分为 词法单元(tokens),例如:

    var a = 1;
    // → ['var', 'a', '=', '1', ';']
    
  3. 语法分析(Parsing)
    将 tokens 构建成 抽象语法树(AST),这是后续优化和执行的基础。

  4. 作用域确定(Lexical Scope)
    根据《JavaScript 语言精粹》(JavaScript: The Good Parts)作者 Douglas Crockford 的强调:

    “JavaScript 使用 词法作用域(Lexical Scope),即作用域由代码书写位置决定,而非运行时调用位置。”

  5. 变量与函数提升(Hoisting)

    • var 声明的变量 → 提升至作用域顶部,初始化为 undefined
    • function 声明 → 整个函数体被提升
    • let/const 声明 → 提升但进入 暂时性死区(Temporal Dead Zone, TDZ)
  6. 创建执行上下文(Execution Context)
    每段可执行代码(全局或函数)都会生成一个 执行上下文对象,包含:

    • 变量环境(Variable Environment):存放 var、函数声明、this
    • 词法环境(Lexical Environment):存放 let/const、块级作用域变量

📦 二、执行上下文与调用栈:JS 如何“记住”自己在哪?

JavaScript 是单线程语言,靠 调用栈(Call Stack) 管理函数调用顺序。

🧱 执行上下文的结构(源自《你不知道的 JS》)

每个执行上下文包含三部分:

  1. 变量对象(Variable Object, VO)
    存储所有变量、函数声明。全局上下文中即为 global object(浏览器中是 window)。

  2. 作用域链(Scope Chain)
    一个链表结构,指向当前作用域及所有父级作用域的 VO。
    这就是闭包能访问外层变量的原因!

  3. this 绑定
    由调用方式决定(默认绑定、隐式绑定、显式绑定、new 绑定)。

下图直观展示了执行上下文的内部组成:

image.png

注意:变量环境与词法环境在 ES6 后被明确区分,分别管理不同类型的声明。

🗃️ 调用栈的工作流程

┌──────────────────────┐
│ fn() 执行上下文      │ ← 栈顶(先执行)
├──────────────────────┤
│ 全局执行上下文       │ ← 栈底(最后退出)
└──────────────────────┘

更直观的可视化如下:

image.png

每次函数调用都是一次“入栈”,返回则“出栈”。若递归过深,就会触发 栈溢出(Stack Overflow) 错误。

  • 全局代码执行 → 创建 全局执行上下文,压入栈底
  • 调用函数 → 创建 函数执行上下文,压入栈顶
  • 函数返回 → 上下文出栈,内存释放(除非被闭包引用)

💡 关键点:函数执行完毕后,其上下文通常被销毁。但若存在闭包,相关变量会被保留在堆内存中,直到无引用为止。


🔄 三、变量提升 vs 函数提升:谁优先?为什么?

来看经典案例(来自 1.js):

showName();        // ✅ 输出:函数showName被执行
console.log(myName); // ✅ 输出:undefined
console.log(hero);   // ❌ ReferenceError: Cannot access 'hero' before initialization

var myName = 'WJ';
let hero = '钢铁侠';

function showName() {
  console.log('函数showName被执行');
}

📌 分析:

  • showName() 能调用 → 函数声明整体提升
  • myName 输出 undefinedvar 提升但未赋值
  • hero 报错 → let 进入 TDZ,在声明前不可访问

📘 《你不知道的 JS》指出:
“提升的本质是 声明被移动到作用域顶部,但赋值仍留在原地。”

再看 2.js

var myName = 'WJ';
function myName() {
  console.log('函数myName被执行');
}
console.log(myName); // 输出:'WJ'

为什么不是函数?
因为:函数声明优先于变量声明,但在执行阶段,var myName = 'WJ' 会覆盖函数引用!
编译后等效于:

function myName() { ... }  // 先提升函数
var myName;                // 变量声明被忽略(同名)
myName = 'WJ';             // 执行阶段赋值,覆盖函数

🧪 四、函数参数、局部变量、函数声明的“三国杀”

3.js 的复杂案例:

var a = 1;
function fn(a) {
  console.log(a);     // [Function: a]
  var a = 2;
  function a() {}
  var b = a;
  console.log(a);     // 2
}
fn(3);

🧠 执行上下文构建过程(函数 fn 被调用时):

  1. 形参 a 被传入值 3
  2. 函数声明 function a(){} 被提升 → 覆盖形参 a
  3. var a 声明被忽略(同名)
  4. 执行阶段:
    • 第一次 console.log(a) → 输出函数 a
    • a = 2 → 赋值,覆盖函数
    • 第二次 console.log(a) → 输出 2

✅ 结论:函数声明 > 形参 > var 声明


🚫 五、var vs let/const:不仅仅是“能不能重复声明”

4.js 示例:

var a = 1;
console.log(a); // 1
var a = 2;      // ✅ 允许重复声明
console.log(a); // 2

// let b = 3;
// let b = 4;   // ❌ SyntaxError: Identifier 'b' has already been declared

关键区别:

特性varlet/const
作用域函数级块级({}
提升提升至顶部,值为 undefined提升但处于 TDZ
重复声明允许(静默忽略)禁止(语法错误)
全局属性是(挂载到 window

📚 《JavaScript 语言精粹》警告:
var 的设计缺陷导致了无数 bug,应尽量使用 let/const。”


🔒 六、严格模式(Strict Mode)真的能阻止重复声明吗?

5.js

'use strict';
var a = 1;
var a = 2; // ✅ 不报错!

很多人误以为严格模式禁止 var 重复声明——这是错的!
严格模式主要禁止:

  • 隐式全局变量(a = 1 会报错)
  • 删除不可删除属性
  • with 语句
  • arguments.callee

var 重复声明在严格模式下依然合法,因为这是语言规范允许的。


📉 七、函数表达式不会提升!

6.js

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

若你在声明前调用 func(),会得到:

func(); // ReferenceError: Cannot access 'func' before initialization
let func = () => { ... };

因为 func变量,使用 let 声明,处于 TDZ。
即使换成 var

func(); // TypeError: func is not a function
var func = function() { ... };

因为 var func 提升后值为 undefined,调用 undefined() 报错。

只有函数声明(function foo(){})会被提升!


💾 八、内存管理:栈 vs 堆,值拷贝 vs 地址引用

7.js 揭示了 JS 内存模型的核心:

let str = 'hello';
let str2 = str;
str2 = '你好';
console.log(str, str2); // 'hello' '你好'

let obj = { name: 'wj', age: 18 };
let obj2 = obj;
obj2.age++;
console.log(obj.age);   // 19

🧠 原理解析:

  • 基本类型(String, Number, Boolean, null, undefined, Symbol, BigInt)
    → 存储在 栈内存,赋值时 复制值

  • 引用类型(Object, Array, Function)
    → 实际数据存储在 堆内存,变量保存的是 内存地址(指针)
    → 赋值时 复制地址,多个变量指向同一对象

内存布局对比如下图所示:

image.png

正因如此,修改 obj2 会同步影响 obj——它们共享同一块堆内存。

📚 《你不知道的 JS》强调:
“JavaScript 中没有‘对象赋值’,只有‘引用赋值’。”

这也解释了为什么修改 obj2 会影响 obj——它们指向同一个堆内存地址。


♻️ 九、垃圾回收:V8 如何清理无用内存?

V8 使用 标记-清除(Mark-and-Sweep) 算法:

  1. 标记阶段:从根对象(如全局对象)出发,遍历所有可达对象,打上“存活”标记
  2. 清除阶段:回收未被标记的对象所占内存

💡 闭包会阻止垃圾回收!
如果内部函数引用了外部变量,即使外部函数已执行完毕,其上下文也不会被回收。


🎯 十、总结:JS 执行机制全景图

阶段关键动作数据结构引擎行为
编译阶段语法检查、提升、作用域确定AST、执行上下文创建变量/词法环境
执行阶段赋值、函数调用、表达式求值调用栈上下文入栈/出栈
内存管理值/引用存储、垃圾回收栈、堆标记-清除算法

🚀 给 AI+全栈工程师的建议

  1. 永远不要依赖提升写代码 → 声明前置,清晰可读
  2. 优先使用 const,其次 let,避免 var
  3. 理解闭包 = 函数 + 词法环境引用
  4. 调试时思考:“此刻调用栈里有什么?”
  5. 阅读经典:《你不知道的 JS》《JavaScript 语言精粹》《深入浅出 Node.js》

🌈 最后的话

JavaScript 的执行机制,是语言设计者在灵活性与性能之间权衡的艺术。
它既有历史包袱(如 var),也有现代智慧(如 TDZ、块级作用域)。
作为 AI+全栈工程师,我们不仅要会调 API、写 React、训模型,更要 理解底层逻辑——因为真正的高手,知其然,更知其所以然。

🕊️ 愿你从此不再惧怕 undefined
愿你的代码如 V8 引擎般高效、优雅、可靠。