JavaScript 执行机制深度解析:从 V8 引擎到作用域链、变量提升与闭包的全面剖析

前言:为什么你需要彻底理解 JS 的执行机制?

你是否曾遇到过以下"诡异"现象?

  • 函数在声明前就能调用?
  • console.log(x) 输出 undefined 而不是报错?
  • 循环中的 setTimeout 全部输出同一个值?
  • if 块中用 var 声明变量,却影响了整个函数?

这些看似"反直觉"的行为,其实都源于 JavaScript 引擎底层的 执行机制。而要真正掌握这门语言,不能只停留在"会用 API",必须深入理解:

  • JS 是如何运行的?
  • 什么是执行上下文(Execution Context)?
  • 变量提升(Hoisting)是怎么回事?
  • 作用域(Scope)到底是什么?
  • 为什么 let/constvar 更安全?
  • V8 引擎内部是如何处理这些概念的?

本文将结合基础代码,逐行注解、层层递进,带你从零构建对 JavaScript 执行机制的完整认知体系。全文基于现代 JS(ES6+)标准,并兼容历史背景,力求既讲清原理,又解决实际问题。


第一章:JavaScript 的运行模型------编译 + 执行

很多人误以为 JavaScript 是"解释型语言",一行一行直接执行。但事实是:现代 JavaScript 引擎(如 V8)采用"先编译,再执行"的两阶段模型

1.1 两阶段执行流程

当一段 JS 代码被加载时,V8 引擎会经历两个关键阶段:

阶段一:编译阶段(Compilation / Parsing)

  • 扫描整段代码
  • 识别所有 变量声明var, let, const)和 函数声明
  • 构建 执行上下文(Execution Context)
  • varfunction 进行 变量提升(Hoisting)

注意:只有声明被提升,赋值不会!

阶段二:执行阶段(Execution)

  • 按代码顺序执行赋值、函数调用、表达式求值等操作
  • 动态创建新的执行上下文(如调用函数时)
  • 管理 调用栈(Call Stack)

这种设计使得 JS 能在运行前"预知"有哪些变量和函数可用,但也带来了"变量提升"这一争议特性。


第二章:执行上下文(Execution Context)------JS 运行的"容器"

每当 JS 执行一段可执行代码(全局代码或函数),引擎会为其创建一个 执行上下文。它是 JS 代码运行的"沙盒环境"。

2.1 执行上下文的组成

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

组成 说明
变量环境(Variable Environment) 存放 var 声明的变量(函数级作用域)
词法环境(Lexical Environment) 存放 let/const 声明的变量(支持块级作用域)
this 绑定 当前上下文中的 this 指向

关键区分

  • var → 变量环境
  • let/const → 词法环境

这就是为什么 varlet 行为不同------它们被存放在执行上下文的不同"房间"里!

2.2 执行上下文的生命周期

  1. 创建阶段(编译阶段):

    • 确定作用域
    • 提升变量和函数
    • 初始化变量环境和词法环境
  2. 执行阶段

    • 逐行执行代码
    • 赋值、调用、修改变量
  3. 销毁阶段

    • 函数执行完毕后,上下文出栈
    • 变量被垃圾回收(除非被闭包引用)

第三章:变量提升(Hoisting)------JS 的"历史包袱"

3.1 什么是变量提升?

变量提升(Hoisting) 是指:在编译阶段,JS 引擎将 var 变量声明和 function 声明"移动"到当前作用域顶部的行为。

注意:只有声明被提升,赋值留在原地!

3.2 为什么会有变量提升?

因为JavaScript 当时是一个 KPI 项目,没想到会火起来,设计周期短......为了简单设计,没有块级作用域,再把变量统一提升到函数顶部,是最快最简单的设计。

早期 JS 为了快速实现浏览器脚本功能,牺牲了严谨性。变量提升虽简化了实现,却带来了大量陷阱。


3.3 实战分析:文件 1.js

javascript 复制代码
showName();           // (1)
console.log(myname);  // (2)
var myname = '张三';  // (3)
function showName() { 
    console.log(myname); // (4)
}

编译阶段(提升后等效代码):

javascript 复制代码
// 函数声明完整提升(包括函数体)
function showName() {
    console.log(myname);
}

// var 声明提升,但赋值不提升
var myname; // → undefined

// 执行阶段代码
showName();        // (1) 调用函数
console.log(myname); // (2) 输出 undefined
myname = '张三';   // (3) 赋值

执行结果:

  • (1) showName() → 打印 undefined(因为此时 myname 已声明但未赋值)
  • (2) console.log(myname)undefined
  • (4) 函数内 console.log(myname) → 同样 undefined

💡 结论 :函数可以提前调用,但变量值在赋值前是 undefined


第四章:作用域(Scope)------变量的"可见范围"

作用域决定了变量在哪些地方可以被访问,以及何时被销毁。

4.1 三种作用域类型

类型 描述 生命周期
全局作用域 在任何地方都能访问 页面存活期间
函数作用域 仅在函数内部可见(ES5 主要作用域) 函数调用期间
块级作用域 仅在 {} 块内可见(ES6 新增) 块执行期间

ES5 不支持块级作用域 ,这是 var 问题的根源!


4.2 文件 2.js:全局 vs 局部变量

javascript 复制代码
var globalVar = "全局变量";        // (1) 全局作用域
function myFunction() { 
  var localVar = "局部变量";        // (2) 函数作用域
  console.log(globalVar);         // (3) 访问全局变量
  console.log(localVar);          // (4) 访问局部变量
}
myFunction();                     // (5) 调用函数
console.log(globalVar);           // (6) 全局可访问
console.log(localVar);            // (7) 报错!

分析:

  • (1) globalVar 在全局作用域,任何地方可访问。
  • (2) localVarmyFunction 内部,属于函数作用域。
  • (7) 尝试在函数外访问 localVarReferenceError: localVar is not defined

作用域隔离:函数内部变量对外部不可见,这是封装的基础。


第五章:var 的致命缺陷------无视块级作用域

5.1 问题根源

在 ES5 中,var 只认函数边界,不认 {} 。即使你在 ifforwhile 中用 var,变量仍属于整个函数!


5.2 文件 3.jsvar 在 if 块中的灾难

javascript 复制代码
var name = '张三';                // (1) 全局变量
function showName() { 
    console.log(name);            // (2) 输出什么?
    if (true) { 
        var name = '李四';        // (3) var 声明!
    }
    console.log(name);            // (4) 输出什么?
}
showName();

编译阶段(提升后):

javascript 复制代码
var name = '张三';

function showName() {
    var name; // 提升!遮蔽全局 name
    console.log(name); // → undefined
    if (true) {
        name = '李四'; // 赋值
    }
    console.log(name); // → '李四'
}

执行结果:

  • (2) undefined(不是 '张三'!)
  • (4) '李四'

为什么不是 '张三'

因为函数内部的 var name 遮蔽(shadowing) 了全局变量,并且提升后初始值为 undefined

这就是 var 的典型陷阱:你以为在操作全局变量,其实创建了一个同名的局部变量!


第六章:ES6 救星------let/const 与块级作用域

为了解决 var 的问题,ES6 引入了:

  • let:块级作用域变量
  • const:块级作用域常量
  • 暂时性死区(Temporal Dead Zone, TDZ)

6.1 暂时性死区(TDZ)

在块内,let/const 声明的变量在声明前处于 TDZ ,访问会抛出 ReferenceError

ini 复制代码
console.log(x); // ReferenceError
let x = 1;

这比 varundefined 更安全------早报错,早修复


6.2 文件 4.jslet 的块级作用域

javascript 复制代码
let name = '张三';                // (1) 全局 let 变量
function showName() { 
    console.log(name);            // (2) → '张三'
    if (false) { 
        let name = '李四';        // (3) 块级变量(但 if 为 false,不执行)
    }
    console.log(name);            // (4) → '张三'
}
showName();

分析:

  • (1) 全局 namelet,块级作用域。
  • (3) if (false) 块未执行,let name = '李四' 从未创建。
  • (2)(4) 都访问全局 name'张三'

无污染、无遮蔽let 完美隔离作用域。


6.3 对比 var vs let:循环中的经典问题

文件 6.jsvar 版):

css 复制代码
function foo(){
    for (var i = 0; i < 100; i++) { } 
    console.log(i); // 输出 100!因为 var i 被提升到函数顶部
}
foo();

如果换成 let

ini 复制代码
for (let i = 0; i < 100; i++) { }
console.log(i); // ReferenceError: i is not defined

let i 仅存在于 for 循环的块级作用域中,循环结束后自动销毁。

闭包陷阱示例:

javascript 复制代码
// var 版本(错误)
for (var i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0); // 输出 3, 3, 3
}

// let 版本(正确)
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 0); // 输出 0, 1, 2
}

原因let 在每次循环迭代中创建一个新的绑定(binding),而 var 只有一个共享变量。


第七章:执行上下文视角------varlet 如何共存?

现代 JS 必须同时支持 var(向下兼容)和 let/const(新标准)。V8 引擎如何做到?

答案: "一国两制" ------ 在同一个执行上下文中,使用两个独立的存储空间。


7.1 文件 7.js:融合示例

csharp 复制代码
// 执行上下文的角度,var/let 融合
function foo(){ 
    var a = 1;     // → 存入"变量环境"
    let b = 2;     // → 存入"词法环境"
    { 
        let b = 3; // → 新建块级词法环境,b=3 遮蔽外层 b=2
    }
}

执行过程:

  1. 进入 foo,创建执行上下文
  2. 扫描 var a → 放入 变量环境 (初始 undefined
  3. 扫描 let b → 放入 词法环境(处于 TDZ)
  4. 执行 var a = 1 → 变量环境中的 a 赋值为 1
  5. 执行 let b = 2 → 词法环境中的 b 赋值为 2
  6. 进入 {} 块 → 创建 新的词法环境
  7. 在新环境中 let b = 3 → 不影响外层 b

关键设计

  • 变量查找时,先查词法环境,再查变量环境
  • let/const 优先级更高,避免与 var 冲突

第八章:其他控制结构的作用域行为

8.1 文件 5.js:空块与循环

scss 复制代码
if (1){}               // 空 if 块(合法,但无意义)
while (1){}            // 无限循环(语法合法,但会卡死)
function foo(){}       // 函数声明(会被提升)
for (let i = 0; i < 100; i++) { 
  console.log(i);      // let i 仅在此 for 块中有效
}

重点:

  • ifwhilefor{} 都是 块级作用域
  • 但只有使用 let/const 才能体现其作用
  • function foo(){} 是函数声明,会被提升

即使块为空,JS 也为其创建作用域(虽然没变量,但结构存在)。


第九章:作用域链(Scope Chain)------变量查找的完整路径

9.1 什么是作用域链?

想象你在一个多层办公楼里找一个人:

  • 你先在自己办公室找(当前作用域)
  • 找不到,就去你部门的公共区域找(父级作用域)
  • 还找不到,就去整栋楼的大厅找(全局作用域)
  • 如果大厅也没有 → "查无此人!"

作用域链(Scope Chain)就是 JavaScript 引擎查找变量时,沿着"嵌套作用域"一层层向上搜索的路径。

关键规则

  • 查找顺序:从内向外(当前 → 父级 → ... → 全局)
  • 路径在函数声明时就固定了 (不是调用时!)→ 这叫 词法作用域(Lexical Scope)

9.2 作用域链是如何构建的?

每当一个函数被声明(不是调用!),JS 引擎就会记录:

"这个函数是在哪个作用域里写的?它的'爸爸'是谁?"

这个"家族关系链"就是作用域链。

举个生活化例子:

javascript 复制代码
// 全局作用域(大楼大厅)
var myName = '李四';

function foo() {
    // 函数作用域(foo 办公室)
    var myName = '张三';
    
    function bar() {
        // 函数作用域(bar 小隔间)
        console.log(myName);
    }
    
    bar(); // 调用 bar
}

foo();

问题:console.log(myName) 输出什么?

很多人以为:bar 是在 foo 里面调用的,所以应该输出 '张三' ------ 这是对的,但原因更重要!

真正原因

  • bar 函数声明的位置foo 内部

  • 所以 bar 的作用域链是:bar 自身 → foo → 全局

  • 查找 myName 时:

    1. bar 自己没有 myName
    2. foo 里找 → 找到 var myName = '张三'
    3. 停止查找!返回 '张三'

注意:即使 bar 被拿到全局调用,结果也一样!因为作用域链在声明时就锁定了


9.3 文件 1.js 再分析:为什么输出 李四

1.js 是一个经典反直觉案例:

ini 复制代码
function bar(){
    console.log(myName);
}
function foo(){
    var myName = '张三';
    bar(); //运行时查找,先在当前作用域查找,没有找到,就去父作用域查找
}
var myName = '李四';
foo(); // 李四

为什么不是 '张三'

关键点bar 函数是在全局作用域中声明的!

所以 bar 的作用域链是:bar → 全局根本不包含 foo

执行过程:

  1. foo() 被调用

  2. foo 内部创建局部变量 myName = '张三'

  3. 调用 bar()

  4. bar 开始查找 myName

    • 自己没有
    • 看"爸爸"是谁 → 全局作用域
    • 在全局找到 myName = '李四'
  5. 输出 '李四'

💡 记住 :作用域链看函数在哪写的,不是在哪调用的!
"作用域链查找的规则 一定要知道作用域链 变量的查找路径按函数申明的时候,即在编译阶段就确定了,不会改变词法作用域"


9.4 作用域链查找优先级:词法环境 vs 变量环境

当查找一个变量时,JS 引擎会按以下顺序:

  1. 当前词法环境let/const
  2. 当前变量环境var
  3. 外层函数的词法/变量环境
  4. 全局环境
  5. 找不到 → ReferenceError

✅ 因此,let name 会遮蔽 var name,因为词法环境优先。


第十章:最佳实践与总结

10.1 为什么变量提升是"设计缺陷"?

  • 导致代码行为与直觉不符
  • 容易造成变量覆盖
  • 本应销毁的变量未被销毁(内存泄漏风险)
  • 无法实现真正的块级逻辑隔离

但早期 JS 为了快速实现,选择了最简单的方案。


10.2 ES6 如何解决?

问题 ES5 (var) ES6 (let/const)
作用域 函数级 块级 ✅
提升行为 声明提升,值为 undefined 暂时性死区(TDZ)✅
重复声明 允许(静默覆盖) 报错 ✅
循环变量 共享同一变量 每次迭代新绑定 ✅

10.3 开发建议

  1. 永远不要使用 var(除非维护老项目)
  2. 优先使用 const ,只有需要重赋值时才用 let
  3. 理解 TDZ :不要在声明前访问 let/const 变量
  4. 利用块级作用域 :用 {} 显式隔离逻辑
  5. 避免全局变量污染:使用模块化(ESM)或 IIFE

第十一章:附录------所有文件逐行注解汇总

📄 1.js

javascript 复制代码
showName();           // 函数声明被提升,可提前调用
console.log(myname);  // var 提升 → undefined
var myname = '张三';  // 声明提升,赋值在执行阶段
function showName() { 
    console.log(myname); // 函数内访问提升后的 myname → undefined
}

📄 2.js

javascript 复制代码
var globalVar = "全局变量";        // 全局作用域
function myFunction() { 
  var localVar = "局部变量";        // 函数作用域
  console.log(globalVar);         // 访问全局
  console.log(localVar);          // 访问局部
}
myFunction();                     // 调用
console.log(globalVar);           // 全局可访问
console.log(localVar);            // 局部变量不可见

📄 3.js

javascript 复制代码
var name = '张三';
function showName() { 
    console.log(name); // undefined(函数内 var name 提升遮蔽全局)
    if (true) { 
        var name = '李四'; // var 无视块级,提升到函数顶部
    }
    console.log(name); // '李四'
}

📄 4.js

javascript 复制代码
let name = '张三';
function showName() { 
    console.log(name); // '张三'(无遮蔽)
    if (false) { 
        let name = '李四'; // 块级作用域,但未执行
    }
    console.log(name); // '张三'
}

📄 5.js

scss 复制代码
if (1){}               // 空块
while (1){}            // 无限循环(慎用)
function foo(){}       // 函数声明(提升)
for (let i = 0; i < 100; i++) { 
  console.log(i);      // let i 块级作用域
}

📄 6.js

css 复制代码
function foo(){
    for (var i = 0; i < 100; i++) { } 
    console.log(i); // 100(var 提升到函数顶部)
}

📄 7.js

ini 复制代码
function foo(){ 
    var a = 1;     // → 变量环境
    let b = 2;     // → 词法环境
    { 
        let b = 3; // → 新词法环境,遮蔽外层 b
    }
}

第十二章:闭包(Closure)------让函数"记住"它出生的地方

12.1 什么是闭包?

闭包(Closure) 是指:

一个函数能够访问并操作其词法作用域外部的变量 ,即使这个函数在外部被调用,这些变量依然存在。

简单说:函数带着它的"出生证明"(作用域)一起旅行。


12.2 闭包形成的两个必要条件

闭包的形成条件

  1. 函数嵌套
  2. 内部函数引用了外部函数的变量

二者缺一不可!


12.3闭包实战

以下是闭包的经典范例:

ini 复制代码
// 特殊的地方
function foo() { 
  var myName = '张三'; 
  let test1 = 1; 
  const test2 = 2; 
  var innerBar = { 
    getName: function() { 
        console.log(test1); 
        return myName; 
    }, 
    setName: function(newName) { 
      myName = newName; 
    } 
  } 
  // return 可以被外部访问 
  return innerBar; // 闭包形成的条件------函数嵌套函数
}

var bar = foo();  // 出栈
bar.setName('李四'); // setName 执行上下文创建
bar.getName();
console.log(bar.getName()); // 李四

逐行拆解:

  1. foo() 被调用

    • 创建执行上下文
    • 初始化 myName = '张三', test1 = 1, test2 = 2
    • 定义对象 innerBar,其方法 getNamesetName 引用了 myNametest1
  2. return innerBar

    • foo 执行完毕,执行上下文本应销毁
    • innerBar 的两个方法仍然引用着 myNametest1
    • JS 引擎发现:"这些变量还有人用!" → 不回收!
  3. var bar = foo()

    • bar 拿到了 innerBar 对象
    • 此时 foo 已出栈,但 myNametest1 仍在内存中
  4. bar.setName('李四')

    • 调用 setName,它修改了 foo 内部的 myName
    • myName'张三' 变成 '李四'
  5. bar.getName()

    • 访问 test1(输出 1
    • 返回 myName(现在是 '李四'

💡 这就是闭包!

即使 foo 已经执行完、出栈,它的局部变量 myNametest1 依然活着,因为被返回的函数"背"着它们。


12.4 闭包的内存模型:专属"背包"比喻

readme.md 有一个绝妙比喻:

"有点像给 getName,setName 方法背的一个专属背包。这个闭包里面的变量叫自由变量"

  • 每个闭包函数都有一个隐藏的背包([[Scope]] 内部属性)
  • 背包里装着它声明时所在作用域的所有自由变量(被引用但不在自己作用域内的变量)
  • 无论函数在哪里被调用,打开背包就能拿到这些变量

12.5 闭包的作用与风险

作用:

  • 封装私有变量 (如 myName 外部无法直接访问,只能通过 getName/setName
  • 延长变量生命周期
  • 实现模块模式、工厂函数等高级模式

风险:

  • 内存泄漏:如果闭包长期持有大对象,且未释放,会导致内存占用过高
  • 循环引用:闭包引用 DOM,DOM 又引用闭包 → 垃圾回收失败

最佳实践:

  • 不再需要时,将引用设为 nullbar = null
  • 避免不必要的闭包

12.6 闭包 + 作用域链 = JS 的灵魂

闭包之所以能工作,完全依赖于作用域链的静态性

  • 函数声明时,作用域链就固定了
  • 即使函数被传递到任何地方,它的作用域链不变
  • 因此总能找到"老家"的变量

🌟 一句话总结闭包

"函数 + 它声明时的作用域 = 闭包"


结语:掌握机制,方能驾驭语言

JavaScript 的执行机制看似复杂,但一旦理解 执行上下文、作用域链、变量提升、块级作用域、闭包 这五大支柱,你就能:

  • 写出更安全、可预测的代码
  • 轻松调试"诡异"问题
  • 深入理解 this、异步、模块等高级概念
  • 在面试中展现扎实功底

记住:JS 不是魔法,只是你还没看清它的执行上下文。

相关推荐
一水鉴天1 小时前
整体设计 定稿 之19 拼语言表述体系之2(codebuddy)
大数据·前端·人工智能·架构
低代码的未来1 小时前
React CVE-2025-55182漏洞排查与修复指南
前端
软件技术NINI1 小时前
html css js网页制作成品——陈都灵html+css 5页附源码
javascript·css·html
脾气有点小暴1 小时前
CSS position 属性
前端·css
ohyeah1 小时前
用原生 JS 手写一个“就地编辑”组件:EditInPlace 的 OOP 实践
前端·javascript
绝无仅有1 小时前
面试之高级实战:在大型项目中如何利用AOP、Redis及缓存设计
后端·面试·架构
毕设源码-邱学长1 小时前
【开题答辩全过程】以 基于JavaScript的图书销售网站为例,包含答辩的问题和答案
开发语言·javascript·ecmascript
timeweaver1 小时前
React Server Components 的致命漏洞CVE-2025-55182
前端·安全
绝无仅有1 小时前
redis缓存功能结合实际项目面试之问题与解析
后端·面试·架构