02.JS执行过程剖析

65 阅读11分钟

JS执行过程

1.初始化全局对象

在JavaScript引擎执行代码之前,会在堆内存中创建一个全局对象(Global Object,缩写为 GO)。这个全局对象包含了所有作用域(scope)都可以访问的属性和方法。 在该全局对象中,通常会包含一些JavaScript中常用的内置对象和函数,比如Date、Array、String、Number、setTimeout、setInterval等等。这些对象和函数可以在任何作用域中被直接调用,因为它们是全局对象的属性。

此外,在这个全局对象中,通常还会有一个指向自身的属性,通常被称为window属性。在浏览器环境下,window代表了当前浏览器窗口,它是全局作用域中的对象。通过window对象,可以访问和操作全局作用域中的任何变量和函数。

这个全局对象的初始化是在JavaScript代码执行之前发生的,确保在代码执行过程中,全局作用域中始终存在这些内置对象和函数,使得开发者可以随时使用它们。

console.log("window", window);
console.log("window.window.window", window.window.window);

运行结果如下,可以看到window.window仍然指向的是window对象

2.创建全局执行上下文

执行上下文栈(ECS):

JavaScript引擎内部维护了一个执行上下文栈,也称为调用栈。这个栈用于管理代码的执行顺序。

全局代码块的执行:

当JavaScript引擎开始执行时,首先要执行的是全局的代码块(全局的代码块通常是指在所有函数之外的代码。)为了执行全局代码,引擎会创建一个全局执行上下文(Global Execution Context,缩写为GEC)。随后,全局执行上下文GEC会被放入执行上下文栈中执行。

全局执行上下文主要包含两部分内容:

第一部分:

在代码执行前,即在代码被解析并转成AST的过程中,引擎会将全局范围内定义的变量、函数等信息加入到全局对象(Global Object 或 GO)中,但是并不会赋值。这个过程被称为变量的作用域提升(hoisting)。

第二部分(代码执行中的赋值和函数调用):

代码由上到下依次执行,对变量进行赋值,函数会被调用。

以具体的JS代码举例子

var name = "why";

console.log(num1);

var num1 = 20;
var num2 = 30;
var result = num1 + num2;

console.log(result);

将全局执行上下文放入ECS时的情况可以看下图。第一部分将全局定义的变量,函数放入GO对象(可以看到此时的变量值为undefined),第二部分进行代码的执行

关于第一部分需要补充的是,将函数与变量加入GO中时有所区别:

普通变量:普通变量的声明(例如var x;)会被提升至当前作用域的顶部,但是并不会赋值。这意味着在声明之前使用这个变量会得到undefined。

函数:函数声明(例如function myFunction() {})也会被提升至当前作用域的顶部,但是函数的整体定义会在内存中开辟一块新的空间,这个空间包含了函数的代码体和函数的父级作用域。并且在将定义的函数加入GO中时,此时并不关心函数内的具体内容,在函数内部声明的变量只有在函数被调用时才会被创建。

作用域提升(Hoisting)是JavaScript中一种特殊的行为,它指的是在代码执行前,JavaScript引擎会将变量声明和函数声明提升到当前作用域的顶部,使得可以在声明之前就使用这些变量和函数。

在JavaScript中,变量和函数的声明会被提升,但是赋值不会被提升。这意味着在变量声明之前使用变量会返回undefined,而在函数声明之前调用函数是可以成功的。

3.全局执行上下文开始执行代码

var name = "why";

console.log(num1);

var num1 = 20;
var num2 = 30;
var result = num1 + num2;

console.log(result);

代码在执行过程中,会依次对GO中的变量进行赋值,以及执行函数。例如当执行完第一行代码,会对name变量进行赋值,随后进行console.log(num1),此时结果为undefined,当前的GO内部具体情况如下:

4.补充函数的执行细节

上一小节提到了全局执行上下文执行代码的过程,但是仅仅针对全局定义的变量进行讨论。本小节说明在执行中遇到函数时的具体执行细节

当执行到一个函数时,JavaScript引擎会为该函数创建一个函数执行上下文(Functional Execution Context,简称FEC),并将该上下文压入执行上下文栈(Execution Context Stack,简称EC Stack)中。

函数执行上下文(FEC)包含三部分内容:

  • Activation Object(AO):在解析函数成为AST树结构时,会创建一个Activation Object(AO)。AO中包含了函数的形参、arguments对象(包含传入函数的参数)、函数定义和在函数内部定义的变量。这个AO扮演了函数内部作用域的角色,其中存储了函数内部的变量和函数。
  • 作用域链:作用域链由变量对象(VO,在函数中就是AO对象)和父级变量对象组成。在JavaScript中,作用域是嵌套的,一个函数可以访问其外部函数的变量。作用域链决定了变量的查找顺序,当在函数内部访问变量时,引擎会根据作用域链一层层查找变量,直到找到为止
  • this绑定的值:在JavaScript中,this关键字指向函数的调用者。在函数执行时,this的值是动态绑定的,取决于函数的调用方式。在FEC中,会存储this关键字绑定的值,决定了函数内部的this指向。

这些部分共同构成了函数执行上下文,确保函数在执行时能够访问到正确的变量和this值。这个执行上下文会被压入执行上下文栈(EC Stack),并在函数执行结束后从栈中弹出,控制权回到调用该函数的上下文中。

具体案例

以下列代码为例

var name = "why";

foo(123);
function foo(num) {
  console.log(m);
  var m = 10;
  var n = 20;
  function bar() {
    console.log(name);
  }
  bar();
}

执行结果是

具体解释:

当执行foo内部console.log(m);,由于函数上下文的AO对象中会提前定义m和n,所以打印undefined,执行到bar时function bar() {...}:在foo函数内部定义了bar函数。

bar();:调用bar函数。在bar函数内部:

console.log(name);:尝试访问name变量。由于name在bar函数的作用域链中找不到,JavaScript引擎会继续向上查找。在全局作用域中找到了name变量,因此会打印"why"

词法环境与变量环境

在早期的ECMAScript规范(如ECMAScript 3)中,激活对象(AO)是在函数执行上下文中用于存储局部变量、函数参数和arguments对象的内部对象。它是函数执行上下文中的一个部分,用于管理函数的变量。在这个规范中,变量对象(VO)和激活对象(AO)是等价的概念,用来描述同一个对象。

但是,随着规范的发展,ECMAScript 5和之后的版本中引入了更精确的概念。在现代的JavaScript引擎中(包括V8引擎等),变量对象(VO)不再用于描述函数执行上下文。取而代之的是,词法环境(Lexical Environment)和变量环境(Variable Environment)的概念。

词法环境(Lexical Environment):词法环境是函数执行上下文的一部分,包含了函数的局部变量、函数的参数、this指向的对象以及外部词法环境的引用(外部函数的变量和函数)。词法环境用于实现词法作用域链(Lexical Scope Chain)。

变量环境(Variable Environment):变量环境是词法环境的一种具体形式,它用于处理变量提升(hoisting)的情况。在变量环境中,变量和函数声明会被提前到作用域的顶部。变量环境在函数执行上下文中的行为更接近早期规范中的激活对象(AO)的概念。

因此,在现代JavaScript引擎中,不再使用AO 和 VO 这两个术语。相反,词法环境和变量环境的概念更好地描述了函数的作用域和变量提升的行为。

在早期的JavaScript引擎中(特别是旧版的V8引擎),VO 在函数执行上下文创建时转换为 AO。当一个函数被调用时,会创建一个新的执行上下文。在这个过程中,VO 被创建并初始化为一个空对象。随着函数内部变量的声明和赋值,VO 会被填充。在函数执行完毕后,VO 中包含了函数的局部变量、函数参数和arguments对象。这个 VO 在早期的引擎中即可被称为 AO。

作用域面试题

题1:函数调用函数

var message = "Hello Global";

function foo() {
  console.log(message);
}

function bar() {
  var message = "Hello Bar";
  foo();
}

bar();

运行结果:

var message = "Hello Global";:在全局作用域内声明了一个变量 message,并赋值为 "Hello Global"。

function foo() {...}:声明了一个函数 foo

function bar() {...}:声明了一个函数 bar

bar()调用bar函数,var message = "Hello Bar"在 bar 函数内部声明了一个新的局部变量 message,并赋值为 "Hello Bar"。

foo()调用了 foo 函数,foo 函数内部,它尝试打印 message 变量的值。但由于 foo 函数内部没有定义局部变量 message,它会在函数的作用域链中向上查找。它会找到全局作用域中的 message 变量,因此打印的是全局作用域中的 message 变量的值,即 "Hello Global"。

题2

var n = 100

function foo() {
  n = 200
}

foo()

console.log(n)

运行结果:

var n = 100:在全局作用域中声明了一个变量 n,并赋值为 100。

function foo() {...}:声明了一个函数 foo。

foo():调用了 foo 函数。创建foo的函数执行上下文时,内部没有定义变量,因此在执行代码n = 200时,会沿着父级作用域查找变量n,所以找到了全局作用域中的变量n,将值修改为 200。

console.log(n):在全局作用域中打印 n 的值,此时 n 的值已经被 foo 函数修改为 200,所以输出的结果是 200

题3

function foo() {
  console.log(n);
  var n = 200;
  console.log(n);
}

var n = 100;
foo();

运行结果

function foo() {...}:声明了一个函数 foo。

var n = 100:在全局作用域中声明了一个变量 n

foo():调用了 foo 函数。在函数内部,创建foo函数执行上下文时,将内部的变量n声明,并赋值为undefined

执行console.log(n);:由于变量提升,JavaScript 解释器会将 var n; 提升到函数作用域的顶部,但是此时它尚未赋值,输出的是 undefined。

var n = 200;:将n赋值为 200。

console.log(n),打印局部变量 n 的值,输出 200。

题4

var a = 100;

function foo() {
  console.log(a);
  return;
  var a = 200;
}

foo();

运行结果

var a = 100;:在全局作用域中声明了一个变量 a,并赋值为 100。

function foo() {...}:声明了一个函数 foo。

foo():调用了 foo 函数。创建foo的函数执行上下文,并将其内部var a定义的变量加入AO当中,并且值为undefined,这一步也相当于变量提升

console.log(a);:由于变量提升,JavaScript 解释器会将 var a; 提升到函数作用域的顶部,但是此时它尚未赋值,所以 console.log(a); 输出的是 undefined。

return;:此行代码执行后,函数立即返回,后面的 var a = 200; 不会执行。

因此,函数内部的 console.log(a); 输出的是局部作用域内的 a,它由于变量提升被声明了,但是在赋值之前就被输出,所以是 undefined。

题5

function foo() {
  var m = 100;
}

foo();
console.log(m);

运行结果:

这段代码会导致一个错误,因为 m 是在 foo 函数的作用域内声明的局部变量,外部无法访问。尝试在外部使用 console.log(m); 会导致 JavaScript 报错,指示 m is not defined

题6

function foo() {
  var a = b = 10;
}

foo();

// console.log(a);
console.log(b);

运行结果:

var a = b = 10;等价于var a = b 和 b = 10 两个语句。没有使用 var 关键字声明 b,它成为了一个全局变量。所以 b 变量实际上是在全局作用域内声明的,而不是在 foo 函数内部。

foo() 被调用时,b 被赋值为 10。然后,console.log(b) 打印出全局作用域内的 b,它的值是 10。

至于 a,它被使用 var 关键字声明,所以它是 foo 函数内部的局部变量。如果尝试打印 a 的值(取消注释 console.log(a)行并运行代码),它会输出一个错误,因为在函数外部无法访问 a。