深入解析 JavaScript 执行机制:从代码到结果
本文旨在系统性地阐述 JavaScript 代码在 V8 引擎中的执行机制。我们将以您提供的学习材料为基础,从原理层面,结合具体代码示例,深入剖析执行上下文、变量环境、词法环境、编译阶段和执行阶段等关键概念,构建完整的知识体系。
一、 核心模型:两阶段与执行上下文
与 C++/Java 等需要显式编译的语言不同,JavaScript 作为解释型语言,其“编译”发生在执行前的一刹那。V8 引擎处理任何一段可执行代码(如一个脚本文件或一个函数体)时,都遵循“先编译,后执行”的模型,并且这个模型以函数为单位,在调用栈的管理下循环往复。
这个模型的核心产物是执行上下文。你可以将其理解为一个为当前即将执行的代码所准备的“运行环境包裹”,里面包含了执行所需的所有信息。执行上下文主要分为两种:
- 全局执行上下文:在代码开始执行前创建,每个程序只有一个。
- 函数执行上下文:在每次调用函数时创建。
管理这些执行上下文的数据结构是调用栈。调用栈遵循后进先出的原则,负责将创建好的执行上下文压入栈中执行。正在栈顶执行的上下文拥有控制权,当其代码执行完毕后,该上下文会被销毁(弹出栈),控制权交还给下一个上下文。
二、 编译阶段:执行上下文的创建与填充
编译阶段的核心工作就是创建执行上下文对象,并为其内部的组件填充初始内容。一个执行上下文对象主要包含以下部分:
- 变量环境:一个用于存储通过
var和 函数声明 定义的标识符的空间。 - 词法环境:一个用于存储通过
let和const定义的标识符的空间。这是 ES6 引入的新概念,用于实现块级作用域和暂时性死区。 - 可执行代码:经过编译后,去除了声明语句的、可直接执行的代码序列。
编译阶段的具体工作流程:
-
创建空的执行上下文对象。
-
处理变量和函数声明(提升) :
- 扫描代码,找到所有
var声明的变量,将其变量名作为key,值初始化为undefined,存入变量环境。 - 扫描代码,找到所有函数声明(如
function showName() {}),将函数名作为key,函数体作为value,直接存入变量环境。这意味着函数声明的优先级最高,会覆盖变量环境中同名的undefined变量。 - 扫描代码,找到所有
let和const声明的变量,将其变量名存入词法环境。但在编译阶段,它们不会被初始化,访问它们会触发“暂时性死区”错误。这就是let/const不存在变量提升(或说提升但未初始化)的原理。
- 扫描代码,找到所有
-
统一形参与实参的值(仅对函数执行上下文):
在函数调用时,会将传入的实参与函数定义的形参在变量环境中进行绑定和赋值。
代码实例分析(编译阶段)
让我们结合您的代码文件,具体分析编译阶段的工作。
案例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的函数执行上下文) :-
变量环境:
- 找到形参
a,绑定实参值:{ a: 3 }。 - 找到
var a,由于a已存在,var允许重复声明,但不改变当前值,所以仍是{ a: 3 }。 - 找到
var b,存入{ a: 3, b: undefined }。 - 找到函数声明
function a() {}。这是关键步骤:将变量环境中a的值替换为函数体{ a: function() {} }。函数声明提升的优先级最高。
- 找到形参
-
词法环境:无
let/const声明,为空。 -
可执行代码:
console.log(a); a=2; b=a; console.log(a);
-
三、 执行阶段:代码的逐行运行
编译阶段结束后,进入执行阶段。JS 引擎开始逐行执行“可执行代码”部分的指令。
- 访问规则:当需要访问一个变量时,引擎首先在当前执行上下文中查找。查找顺序是:先词法环境,后变量环境。如果当前上下文没有,则沿着作用域链去外层(创建该函数时的上下文)查找,直至全局上下文。
- 赋值操作:对变量进行赋值,会修改其在对应环境(变量环境或词法环境)中的值。
代码实例分析(执行阶段)
继续案例1 (1.js) 的执行阶段:
showName();:从变量环境中找到showName是一个函数,调用它。这会创建一个新的showName函数执行上下文并入栈执行,输出“函数showName被执行”。执行完后出栈。console.log(myName);:从变量环境中找到myName,其值为undefined,输出undefined。console.log(hero);:从词法环境中找到标识符hero,但它在初始化前被访问,抛出 ReferenceError。myName='lcx';:对变量环境中的myName赋值为'lcx'。hero='钢铁侠';:对词法环境中的hero进行初始化并赋值为'钢铁侠'。
继续案例2 (fn(3)的执行阶段) :
console.log(a);:从变量环境中找到a,此时它的值是函数体,所以输出function a() {}。a=2;:这是一个赋值操作,将变量环境中的a的值从函数改为数字2。b=a;:计算a的值(现在是2),然后赋值给变量环境中的b。b变为2。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 func或let func)会被提升,而赋值操作(= function...)留在执行阶段。因此,在执行赋值语句之前访问该变量,得到的是undefined(var声明)或处于暂时性死区(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 的执行机制可以概括为:单线程、先编译后执行、依托于调用栈和以执行上下文为核心的环境管理。
- 两阶段:对任何代码单元,V8 引擎都先进行编译,创建并填充执行上下文(变量环境、词法环境、可执行代码),然后才执行可执行代码。
- 调用栈:以栈的数据结构管理执行上下文的生命周期,确保执行顺序和资源回收。
- 作用域与提升:
var和函数声明在编译阶段被收集到变量环境,var初始化为undefined,函数则保存整体。let/const被收集到词法环境但未初始化,形成暂时性死区。这解释了“提升”现象的本质差异。 - 执行流程:执行代码时,在当前的执行上下文中查找变量,修改变量值,遇到函数调用则创建新的函数执行上下文,并重复“编译-执行”流程。
通过理解执行上下文、变量环境、词法环境这些底层概念,我们就能从原理上解释包括变量提升、作用域链、闭包、this指向等在内的一系列 JavaScript 核心行为,从而编写出更可靠、可预测的代码。