深入解析 JavaScript 执行机制:从代码到结果

4 阅读9分钟

深入解析 JavaScript 执行机制:从代码到结果

本文旨在系统性地阐述 JavaScript 代码在 V8 引擎中的执行机制。我们将以您提供的学习材料为基础,从原理层面,结合具体代码示例,深入剖析执行上下文变量环境词法环境编译阶段执行阶段等关键概念,构建完整的知识体系。

一、 核心模型:两阶段与执行上下文

与 C++/Java 等需要显式编译的语言不同,JavaScript 作为解释型语言,其“编译”发生在执行前的一刹那。V8 引擎处理任何一段可执行代码(如一个脚本文件或一个函数体)时,都遵循“先编译,后执行”的模型,并且这个模型以函数为单位,在调用栈的管理下循环往复。

这个模型的核心产物是执行上下文。你可以将其理解为一个为当前即将执行的代码所准备的“运行环境包裹”,里面包含了执行所需的所有信息。执行上下文主要分为两种:

  1. 全局执行上下文:在代码开始执行前创建,每个程序只有一个。
  2. 函数执行上下文:在每次调用函数时创建。

管理这些执行上下文的数据结构是调用栈。调用栈遵循后进先出的原则,负责将创建好的执行上下文压入栈中执行。正在栈顶执行的上下文拥有控制权,当其代码执行完毕后,该上下文会被销毁(弹出栈),控制权交还给下一个上下文。

二、 编译阶段:执行上下文的创建与填充

编译阶段的核心工作就是创建执行上下文对象,并为其内部的组件填充初始内容。一个执行上下文对象主要包含以下部分:

  • 变量环境:一个用于存储通过 var函数声明​ 定义的标识符的空间。
  • 词法环境:一个用于存储通过 letconst定义的标识符的空间。这是 ES6 引入的新概念,用于实现块级作用域和暂时性死区。
  • 可执行代码:经过编译后,去除了声明语句的、可直接执行的代码序列。

编译阶段的具体工作流程:

  1. 创建空的执行上下文对象

  2. 处理变量和函数声明(提升)

    • 扫描代码,找到所有 var声明的变量,将其变量名作为 key,值初始化为 undefined,存入变量环境
    • 扫描代码,找到所有函数声明(如 function showName() {}),将函数名作为 key,函数体作为 value直接存入变量环境。这意味着函数声明的优先级最高,会覆盖变量环境中同名的 undefined变量。
    • 扫描代码,找到所有 letconst声明的变量,将其变量名存入词法环境。但在编译阶段,它们不会被初始化,访问它们会触发“暂时性死区”错误。这就是 let/const不存在变量提升(或说提升但未初始化)的原理。
  3. 统一形参与实参的值(仅对函数执行上下文):

    在函数调用时,会将传入的实参与函数定义的形参在变量环境中进行绑定和赋值。

代码实例分析(编译阶段)

让我们结合您的代码文件,具体分析编译阶段的工作。

案例1:全局上下文的编译 (1.js)

// 文件 1.js
showName();
console.log(myName);
console.log(hero);
var myName='lcx';
let hero='钢铁侠';
function showName() {
    console.log('函数showName被执行');  
}
  • 编译阶段(创建全局执行上下文)

    • 变量环境

      • 找到 var myName,存入 { myName: undefined }
      • 找到函数声明 showName,存入 { showName: function() { ... } }。此时 myName被覆盖(但在此例中不冲突)。
    • 词法环境

      • 找到 let hero,存入标识符 hero,但未初始化,处于暂时性死区状态。
    • 可执行代码

      • 移除声明语句后,剩下:showName(); console.log(myName); console.log(hero); myName='lcx'; hero='钢铁侠';

案例2:函数上下文的编译 (3.js中的 fn(3))

function fn(a){ // 假设传入实参 3
    console.log(a);
    var a=2;
    function a() {};
    var b=a;
    console.log(a);
}
  • 编译阶段(创建 fn的函数执行上下文)

    • 变量环境

      1. 找到形参 a,绑定实参值:{ a: 3 }
      2. 找到 var a,由于 a已存在,var允许重复声明,但不改变当前值,所以仍是 { a: 3 }
      3. 找到 var b,存入 { a: 3, b: undefined }
      4. 找到函数声明 function a() {} 。这是关键步骤:将变量环境中 a的值替换为函数体 { a: function() {} }函数声明提升的优先级最高
    • 词法环境:无 let/const声明,为空。

    • 可执行代码console.log(a); a=2; b=a; console.log(a);

三、 执行阶段:代码的逐行运行

编译阶段结束后,进入执行阶段。JS 引擎开始逐行执行“可执行代码”部分的指令。

  • 访问规则:当需要访问一个变量时,引擎首先在当前执行上下文中查找。查找顺序是:先词法环境,后变量环境。如果当前上下文没有,则沿着作用域链去外层(创建该函数时的上下文)查找,直至全局上下文。
  • 赋值操作:对变量进行赋值,会修改其在对应环境(变量环境或词法环境)中的值。

代码实例分析(执行阶段)

继续案例1 (1.js) 的执行阶段

  1. showName();:从变量环境中找到 showName是一个函数,调用它。这会创建一个新的 showName函数执行上下文并入栈执行,输出“函数showName被执行”。执行完后出栈。
  2. console.log(myName);:从变量环境中找到 myName,其值为 undefined,输出 undefined
  3. console.log(hero);:从词法环境中找到标识符 hero,但它在初始化前被访问,抛出 ReferenceError
  4. myName='lcx';:对变量环境中的 myName赋值为 'lcx'
  5. hero='钢铁侠';:对词法环境中的 hero进行初始化并赋值为 '钢铁侠'

继续案例2 (fn(3)的执行阶段)

  1. console.log(a);:从变量环境中找到 a,此时它的值是函数体,所以输出 function a() {}
  2. a=2;:这是一个赋值操作,将变量环境中的 a的值从函数改为数字 2
  3. b=a;:计算 a的值(现在是 2),然后赋值给变量环境中的 bb变为 2
  4. console.log(a);:再次访问 a,输出 2

四、 关键概念的深入与辨析

1. 变量环境 vs. 词法环境

  • 设计目的var的设计缺陷(如变量提升、无块级作用域)催生了 let/const。词法环境就是为了实现 let/const的块级作用域和暂时性死区而引入的。
  • 存储内容:变量环境存 var和函数声明;词法环境存 let/const
  • 初始化时机:变量环境中的项在编译阶段被初始化为 undefined;词法环境中的项在编译阶段仅“创建标识符”,在执行到声明语句时才初始化,在此之前访问会触发错误。

2. 函数声明 vs. 函数表达式 (6.js)

func(); // 调用
let func=() =>{ // 函数表达式赋值给变量 func
    console.log('函数表达式不会被提升');   
}
  • 函数声明(如 function foo() {}):在编译阶段会被整体提升到变量环境,函数名和函数体均可用。
  • 函数表达式(如 let func = function() {}或箭头函数):本质上是将一个函数赋值给一个变量。只有变量的声明(var funclet func)会被提升,而赋值操作(= function...)留在执行阶段。因此,在执行赋值语句之前访问该变量,得到的是 undefinedvar声明)或处于暂时性死区(let声明),调用它会报错。

3. 严格模式的影响 (5.js)

文档未详述此点,但基于我所掌握的知识,在严格模式下(‘use strict’;),诸如重复声明变量(var a=1; var a=2;)等不安全的操作会被引擎禁止,直接抛出语法错误,而不是像在非严格模式下那样允许声明但可能引发意外行为。

4. 内存分配:基本类型与引用类型 (7.js)

  • 基本类型(如 String, Number):值直接存储在栈内存中。变量赋值是“值拷贝”,创建一个全新的副本,两者互不影响。

    let str='hello';
    let str2=str; // 在栈中创建一个新的值 'hello' 并赋给 str2
    str2='你好'; // 修改 str2,不影响 str
    
  • 引用类型(如 Object, Array):值存储在堆内存中,变量中存储的是该值在堆中的内存地址。变量赋值是“地址拷贝”,两个变量指向同一个堆内存空间,因此通过其中一个变量修改堆中的内容,另一个变量访问到的内容也会改变。

    let arr1 = [1, 2, 3]; // arr1 保存堆地址,假设为 0x1000
    let arr2 = arr1; // arr2 也保存地址 0x1000
    arr2.push(4); // 通过地址 0x1000 修改堆中的数组
    console.log(arr1); // 通过 arr1 (地址 0x1000) 访问,看到 [1,2,3,4]
    

总结

JavaScript 的执行机制可以概括为:单线程、先编译后执行、依托于调用栈和以执行上下文为核心的环境管理

  1. 两阶段:对任何代码单元,V8 引擎都先进行编译,创建并填充执行上下文(变量环境、词法环境、可执行代码),然后才执行可执行代码。
  2. 调用栈:以栈的数据结构管理执行上下文的生命周期,确保执行顺序和资源回收。
  3. 作用域与提升var和函数声明在编译阶段被收集到变量环境,var初始化为 undefined,函数则保存整体。let/const被收集到词法环境但未初始化,形成暂时性死区。这解释了“提升”现象的本质差异。
  4. 执行流程:执行代码时,在当前的执行上下文中查找变量,修改变量值,遇到函数调用则创建新的函数执行上下文,并重复“编译-执行”流程。

通过理解执行上下文、变量环境、词法环境这些底层概念,我们就能从原理上解释包括变量提升、作用域链、闭包、this指向等在内的一系列 JavaScript 核心行为,从而编写出更可靠、可预测的代码。