深入理解JavaScript执行机制:编译与执行的奥秘

78 阅读9分钟

深入理解JavaScript执行机制:编译与执行的奥秘

在JavaScript的世界里,代码的编写顺序和执行顺序常常不一致,这让人感到困惑。为什么在函数声明之前调用函数不会报错?为什么var声明的变量会"提升"到作用域顶部?为什么let和const会有暂时性死区?这一切都源于JavaScript的执行机制。本文将带你深入理解JavaScript的执行机制,特别是V8引擎在编译和执行阶段所做的工作。

一、编译阶段:V8引擎的准备工作

JavaScript作为一门脚本语言,它的执行过程与C++/Java等编译型语言有本质区别。JavaScript不是逐行解释执行,而是在执行前进行编译,这个过程发生在代码执行前的"霎那"。

1. 语法检查

V8引擎首先会对代码进行语法检查,如果发现语法错误,会立即终止整个代码块的解析。例如,缺少分号、括号不匹配等问题都会在编译阶段被发现。

2. 变量提升与函数提升

编译阶段,V8引擎会做两件重要的事情:变量提升函数提升

变量提升:将var声明的变量提升到作用域顶部,并初始化为undefined。

函数提升:将函数整体提升到作用域顶部。

showName();
console.log(myName);
console.log(hero);

var myName = 'zhangsan';

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

在编译阶段,V8引擎会这样处理:

  1. 创建全局执行上下文对象
  2. 变量环境:myName = undefinedshowName = 函数体
  3. 词法环境:空

所以,当我们执行showName()时,函数已经"存在",不会报错。而myName在编译阶段被提升为undefined,所以console.log(myName)输出undefined

二、执行阶段:代码的实际执行

编译完成后,V8引擎进入执行阶段。执行阶段遵循"调用栈"机制,这是一个后进先出(LIFO)的数据结构。

1. 执行上下文与调用栈

执行上下文是代码执行时所处的具体环境,包含了执行代码所需的全部信息,如变量、函数和可访问的对象。

调用栈负责将执行上下文对象调入执行栈中执行。执行流程如下:

  1. 全局执行上下文首先被压入调用栈
  2. 遇到函数调用时,创建一个新的函数执行上下文并压入调用栈
  3. 函数执行完成后,执行上下文从栈中弹出,释放资源

2. 函数执行的生命周期

函数执行有三个阶段:

  1. 创建阶段:设置this值、处理函数参数、解析变量声明和函数声明
  2. 执行阶段:逐行执行代码
  3. 销毁阶段:函数执行完毕,执行上下文被销毁

三、var、let、const的区别

1. 作用域 (Scope)

  • var:

    • 函数作用域 (Function Scope)var 声明的变量只在其所在的函数内部可见。如果在函数外部声明,则为全局变量。

    • 没有块级作用域:这意味着在 if 语句、for 循环等代码块中使用 var 声明的变量,在代码块外部仍然可以访问。

    • 示例:

      javascript

      运行

      function test() {
        if (true) {
          var x = 10; // x 在整个 test 函数内都可见
        }
        console.log(x); // 输出: 10
      }
      test();
      console.log(x); // 输出: ReferenceError: x is not defined (在函数外部不可见)
      
  • let / const:

    • 块级作用域 (Block Scope)let 和 const 声明的变量只在其所在的代码块(由 {} 包裹)内部可见。

    • 示例:

      javascript

      运行

      function test() {
        if (true) {
          let y = 20; // y 只在这个 if 代码块内可见
          const z = 30; // z 只在这个 if 代码块内可见
          console.log(y); // 输出: 20
          console.log(z); // 输出: 30
        }
        console.log(y); // 输出: ReferenceError: y is not defined
        console.log(z); // 输出: ReferenceError: z is not defined
      }
      test();
      

2. 提升行为 (Hoisting)

  • var:

    • 变量提升 (Hoisted)var 声明的变量会被提升到其作用域的顶部。

    • 初始化 undefined:提升时,变量的声明被提升,但赋值不会。因此,在声明语句之前访问变量,其值为 undefined

    • 你的示例完全正确:

      javascript

      运行

      console.log(a); // 输出: undefined (变量 a 被提升了,但值还没赋)
      var a = 1;
      console.log(a); // 输出: 1
      
    • 可以理解为:

      javascript

      运行

      var a; // 声明被提升到顶部
      console.log(a); // undefined
      a = 1; // 赋值留在原地
      console.log(a); // 1
      
  • let / const:

    • 变量提升 (Not Hoisted in the same way)let 和 const 也会被提升,但它们不会被初始化。

    • 暂时性死区 (Temporal Dead Zone - TDZ) :从作用域开始到变量声明语句执行前,这段区域称为暂时性死区。在 TDZ 内访问该变量会抛出 ReferenceError

    • 示例:

      javascript

      运行

      console.log(b); // 输出: ReferenceError: Cannot access 'b' before initialization
                      // (因为 b 在 TDZ 内)
      let b = 1;
      console.log(b); // 输出: 1
      
    • const 的额外限制const 声明的变量必须在声明时立即初始化,且后续不能再重新赋值。

      javascript

      运行

      const c; // 输出: SyntaxError: Missing initializer in const declaration
      const d = 5;
      d = 6; // 输出: TypeError: Assignment to constant variable.
      

3. 重复声明 (Redeclaration)

  • var:

    • 允许重复声明:在同一个作用域内,可以多次使用 var 声明同一个变量,后面的声明会覆盖前面的,但不会报错。

    • 你的示例完全正确:

      javascript

      运行

      var a = 1;
      var a = 2; // 不会报错,a 的值被覆盖为 2
      console.log(a); // 输出: 2
      
  • let / const:

    • 不允许重复声明:在同一个作用域内,不允许使用 let 或 const 再次声明已经存在的变量(无论之前是用 varlet 还是 const 声明的)。

    • 示例:

      javascript

      运行

      let c = 1;
      let c = 2; // 输出: SyntaxError: Identifier 'c' has already been declared
      
      var d = 3;
      let d = 4; // 同样会报错: SyntaxError: Identifier 'd' has already been declared
      

总结表格

特性varletconst
作用域函数作用域 / 全局作用域块级作用域块级作用域
提升行为提升并初始化为 undefined存在暂时性死区 (TDZ),不允许提前访问存在暂时性死区 (TDZ),不允许提前访问
重复声明允许不允许不允许
重新赋值允许允许不允许
必须初始化

结论:

  • 尽量避免使用 var,因为它的函数作用域和提升行为容易导致难以预料的 bug。
  • 优先使用 const,当你声明的变量的值在未来不会改变时。这能让你的代码意图更清晰,也更安全。
  • 使用 let,当你确实需要一个可以重新赋值的变量时。

四、数据类型与内存分配

JavaScript中的数据类型分为简单数据类型和复杂数据类型,它们的内存分配方式不同:

1. 简单数据类型(栈内存)

简单数据类型(如number、string、boolean、null、undefined)存储在栈内存中,直接存储值。

let str = 'hello';
let str2 = str; // 值的拷贝

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

这里,str2str的值拷贝,修改str2不会影响str

2. 复杂数据类型(堆内存)

复杂数据类型(如对象、数组、函数)存储在堆内存中,存储的是引用(地址)。

let obj = { name: '薛老板', age: 21 };
let obj2 = obj; // 引用式拷贝

obj2.age++;
console.log(obj, obj2); // { name: '薛老板', age: 22 }, { name: '薛老板', age: 22 }

这里,obj2obj的引用,修改obj2的属性会影响obj

五、实例分析

让我们通过一个具体的例子来理解JS的执行机制:

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

编译阶段

  1. 创建全局执行上下文对象

    • 变量环境:a = undefined
    • 词法环境:空
  2. 创建函数fn的执行上下文对象

    • 变量环境:a = undefined, b = undefined
    • 词法环境:a = function

执行阶段

  1. 全局代码执行:

    • var a = 1;a被赋值为1
    • fn(3);:调用函数fn,创建函数执行上下文
  2. 函数fn执行:

    • console.log(a);:输出3(函数参数a的值)
    • var a = 2;a被赋值为2
    • var b = a;b被赋值为2
    • console.log(a);:输出2
  3. 函数fn执行完毕,执行上下文弹出

  4. 全局代码继续执行:

    • console.log(a);:输出1

六、为什么JS执行机制如此设计?

JavaScript的执行机制设计有其历史原因和实际考量:

  1. 编译在执行前的霎那:JavaScript不是完全的解释型语言,而是"先编译后执行",这样可以在执行前进行语法检查和优化。
  2. 执行上下文的管理:每个函数执行时都会创建一个执行上下文,这使得JavaScript能够支持函数嵌套和作用域链。
  3. 调用栈的LIFO特性:后进先出的栈结构使得函数调用和返回的管理变得简单高效。
  4. 变量提升:虽然有时让人困惑,但变量提升使得JavaScript在函数作用域内可以"自由"地使用变量,而不必担心声明顺序。

七、总结

JavaScript的执行机制可以概括为:

  1. 代码先编译再执行:编译阶段由V8引擎完成,包括语法检查、变量提升和函数提升。
  2. 执行上下文:每个函数执行时都会创建一个执行上下文,包含变量、函数和可访问对象。
  3. 调用栈:管理执行上下文的后进先出数据结构,确保代码按正确顺序执行。
  4. var VS let/const:var有变量提升,作用域为函数级;let/const也会变量提升,但不会初始化,有暂时性死区,作用域为块级。
  5. 内存分配:简单数据类型存储在栈内存,复杂数据类型存储在堆内存。

理解这些机制,可以帮助我们写出更高效、更不易出错的JavaScript代码。在编写代码时,我们应该避免依赖变量提升,使用let/const代替var,并清楚地理解不同数据类型的内存分配方式。

记住,JavaScript不是简单的"从上到下"执行,而是一个复杂的编译-执行过程。V8引擎的这些设计,使得JavaScript能够高效地执行,同时也为我们提供了丰富的编程能力。掌握这些机制,你就能真正理解JavaScript的"魔法",写出更优雅、更高效的代码。