一篇文章,搞懂JS代码执行过程(含函数作用域相关面试题及图文解析)

452 阅读11分钟

JS引擎执行代码过程可分为如下几个步骤:

  1. 初始化全局对象(编译阶段)
  2. 运行代码(执行阶段)
  • 创建全局执行上下文
  • 全局执行上下文入栈(调用栈)执行

一、初始化全局对象

首先V8引擎通过Parser将代码解析为AST树(具体过程详见上一篇文章~),在此过程中,V8引擎内部会帮我们在堆内存中创建一个全局对象:Global Object(GO)

  • GO对象在所有的作用域中都可以访问
  • 里面会包含浏览器或Node中的全局的内容,如:Date、Array、String、Number、setTimeout、setInterval等
  • 其中还有一个window属性指向自己

二、运行代码

  1. 为了运行代码,V8引擎会有一个执行上下文栈(Execution Context Stack) ,也称函数调用栈、执行栈
  2. 在执行全局的代码块时,为了执行会创建一个全局执行上下文(Global Execution Context) ,全局执行上下文会被放入ECS(执行上下文栈)中被执行
  • 全局执行上下文中维护着一个VO(Variable Object)此处指向GO
  • GO中的全局变量是在代码被parser成AST过程中被加入进去的,要注意此时变量只是加入了进去但不会被赋值

2-1、示例1

2-1-1、代码示例

var num1 = 10;
console.log(num2); // undefined
var num2 = 20;

2-1-2、画图解析

  1. 编译阶段(初始化全局对象GO):
    • 遇到全局变量num1,将num1加入到GO中,此时为undefined
    • 遇到全局变量num2,将num2加入到GO中,此时为undefined

注意:此时并不会输出num2,这是编译阶段,只编译不运行

  1. 运行阶段(开始执行代码):

    • 为了运行全局代码会创建一个GEC(全局执行上下文),其中的VO(变量对象)指向GO

    • GEC入执行栈开始执行代码:

      • 首先将num1赋值为10:会通过VO找到GO中的num1并赋值为10,此时GO中的num1由undefined变为10

      • 然后输出num2,注意,此时还未执行到num2的赋值语句,所以通过VO去GO中找到的num2为undefined,这就是为什么console.log(num2)的输出结果为undefined

      • 最后将num2赋值为20,这时候GO中的num2才会由undefined变为20

2-2、示例2(含函数)

我们可以在函数定义前去调用函数,这是为什么呢?比如下面的代码块中所示:

2-2-1、代码示例

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

2-2-2、画图解析

  1. 编译阶段

    • JS引擎会创建一个GO,当在全局作用域中发现了函数时,会在内存中开辟另一块空间去存储函数对象,函数对象包含两部分内容:
      • 一部分是父级作用域(上述代码为全局作用域)
      • 一部分是函数执行体(代码块,要执行的代码)
  2. 执行阶段

    • 遇到函数执行时,会通过GO中函数变量对应的内存地址找到这个函数,在执行函数时,首先会为函数创建一个函数执行上下文,在其中也维护着一个VO,用于保存该函数的参数及创建的变量;此时会在内存中创建一个AO对象,VO指向AO

    • 函数执行上下文入栈开始执行:

      • 首先num被赋值为123
      • 打印出m,于是便通过VO找到AO中保存的m,由于此时还未对m进行赋值,所以输出undefined
      • 然后将m赋值为10
      • 再将n赋值为20
    • 函数体中代码执行完毕后弹出执行栈并销毁,所以VO被销毁了,也就没有指向AO的了,AO也就随之销毁了

2-3、示例3(含函数)

2-3-1、代码示例

var age = 10;
foo(123);
function foo(num) {
  console.log(m); // undefined
  var m = 10;
  function bar() {
    console.log(age); // 10
  }
  bar();
}

2-3-2、画图解析

  1. 编译阶段

    • 首先创建GO,其中包含了foo变量(指向foo对象)及age变量
    • 遇到了函数foo,内存中会额外开辟一个空间去存储foo对象,foo对象中包含了父级作用域(此处为全局作用域)以及函数体(执行代码)
  2. 执行阶段

    • 全局执行上下文创建并入栈执行,首先为全局变量中的age赋值,此时GO中的age由undefined变为了10

    • 然后执行foo函数:

      • 为foo函数创建一个函数执行上下文及AO对象(其中包含了VO,指向创建的AO对象)
      • 同时由于foo中存在函数bar,所以会额外为bar开辟一个空间去存储bar函数对象(bar函数对象中同样包含了父级作用域:此处为foo函数作用域、函数体)
      • foo函数执行上下文入栈执行,首先num被赋值为123
      • 然后输出m,由于此时m还未被赋值,且在当前作用域中发现了m,所以直接输出了undefined
      • m被赋值为10,此时函数foo的AO中的m变为10
    • 执行bar函数:

      • 同样为bar函数创建一个执行上下文与其对应的AO对象
      • bar函数执行上下文入栈执行,输出age
      • 会首先去当前作用域中去找age,发现没有,那么就去其父级作用域foo中去找,发现foo的AO中也没有age,那么就接着沿着父级作用域去找,foo的父级作用域(全局作用域)中发现了age,所以最后输出10(foo函数的父级作用域为全局作用域,bar函数的父级作用域为foo函数作用域,这样形成了一条作用域链
    • bar函数中内容执行完毕,弹出栈销毁,那么其对应的AO也会被销毁;紧接着foo执行上下文栈弹出销毁,其对应的AO也会被销毁

2-4、示例4(含函数,易错)

2-4-1、代码示例

var msg = 'hello word!';
function  foo() {
  console.log(msg);// hello word!
}
function bar() {
  var msg = 'HI';
  foo();
}
bar();

2-4-2、画图解析

  1. 编译阶段

JS引擎会创建GO对象,将全局变量msg放入,遇到了函数foo与bar,会分别开辟空间去存储foo对象与bar对象,注意,foo对象与bar对象的父级作用域都是全局作用域(GO)

  • 所以,父级作用域在编译阶段就已经确定好了
  1. 执行阶段

    • 首先GEC创建并入栈执行,会为msg赋值为hello word!

    • bar函数执行,会首先创建好bar函数的执行上下文及AO对象,其AO对象中保存着其作用域中的变量msg、foo函数变量。bar函数的执行上下文入栈执行:

      • 首先会为其作用域中的msg赋值为HI
      • 然后执行foo函数
    • foo函数执行,会创建好foo函数的执行上下文及其AO对象,只不过其AO对象中没有内容

      • 输出msg,由于其作用域中没有msg,那么就去其父级作用域中去寻找(即GO中),在GO中找到了msg,所以输出hello word!
      • 要注意,foo的函数的父级作用域在编译创建GO对象时就已经确定了!因为创建GO对象时,在全局作用域中发现了foo,所以为foo函数开辟了一个空间去存储foo对象,此时foo对象中的父级作用域信息就为GO对象了
    • foo函数执行完出栈销毁并销毁其AO对象,紧接着bar函数执行上下文出栈并销毁其AO对象不会被指向了也会随之销毁

三、相关面试题

3-1、题目一

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

解析:

  1. 编译时:GO对象中会保存n,foo变量,并创建foo函数对应的对象(其父级作用域为GO)

  2. 执行时

    • 全局执行上下文入栈执行,把n赋值为100,紧接着foo执行上下文被创建并创建其对应的AO入栈执行

    • 将n赋值为200时,由于其AO中没有n,那么会向其父级作用域中寻找,也就是GO中寻找,发现了n,则把GO中的n赋值为了200

    • foo执行完毕出栈销毁

    • 输出n,由于此时GO中的n被赋值为了200,所以输出200

3-2、题目二

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

解析:

  1. 编译时:GO对象中会保存foo变量及n,并创建foo函数对应的对象(其父级作用域为GO)
  2. 执行时
    • 全局执行上下文入栈执行,把n赋值为100,紧接着foo执行上下文被创建并创建其对应的AO(其AO中保存着n变量,此时还为undefined)
    • foo执行上下文入栈执行,首先输出n,会先从当前作用域AO中去找,发现有,但要注意此时还未给其作用域下的n赋值,所以会先输出undefined,然后为n赋值为200,紧接着再输出n,由于n已经被赋值为200了,所以会打印出200
    • foo函数执行完毕,其执行上下文出栈销毁,其对应的AO就失去了指向,也会随之销毁

3-3、题目三

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

解析:

  1. 编译时:GO对象中会保存a变量及foo变量,并创建foo函数对应的对象(其父级作用域为GO)
  2. 执行时
    • 全局执行上下文入栈执行,把a赋值为100,紧接着foo执行上下文被创建并创建其对应的AO(其AO中保存着a变量,此时还为undefined)
    • foo执行上下文入栈执行,首先输出a,会先从当前作用域AO中去找,发现有,所以会输出undefined,然后直接return了(虽然有return,但不影响编译阶段将a放入foo的AO中)
    • foo函数执行完毕,其执行上下文出栈销毁,其对应的AO就失去了指向,也会随之销毁

3-4、题目四

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

var a = b = 0 会被转成两行代码:

var a = 0;

b = 0

b = 0 这种情况就比较特殊了,前面没有var,会被加入到全局作用域GO中,所以:

  1. 编译时:GO对象中会保存foo变量及b变量,并创建foo函数对应的对象(其父级作用域为GO)
  2. 执行时
    • 全局执行上下文入栈执行,紧接着创建foo函数的执行上下文并创建其对应的AO,AO中保存着变量b,此时为undefined
    • foo执行上下文入栈执行,首先将a赋值为0,然后将全局作用域中的b赋值为0
    • foo函数执行完毕,其执行上下文出栈销毁,其对应的AO就失去了指向,也会随之销毁
    • 紧接着输出a,回去当前作用域去找,由于当前作用域(GO)中没有,然而这是全局作用域,它没有父级作用域的。找不到a就会报"a is not undefined"的错误,代码停止执行,所以b也就没有输出结果。

3-5、题目五

var n = 100;
function foo1() {
  console.log(n);
}
function foo2() {
  var n = 200;
  console.log(n);
  foo1();
}
foo2();
console.log(n);
  1. 编译时:GO对象中会保存n变量、foo1变量、foo2变量,并创建foo1函数对应的对象(其父级作用域为GO)及foo2函数对应的对象(其父级作用域为GO)

  2. 执行时

    • 全局执行上下文入栈执行,将GO中的n变量赋值为100
    • foo2函数执行上下文及其对应的AO创建(AO中有n变量),并入栈执行
    • foo2执行时将其AO中的变量n赋值为200,紧接着输出,所以先输出200
    • foo1函数执行上下文及其对应的AO被创建并入栈执行
    • foo1执行时输出n,由于其AO中不存在n,则去其父级作用域GO中寻找,找到了父级作用域中的n为100所以输出100
    • foo1执行完毕出栈销毁并销毁其AO,紧接着foo2执行完毕出栈销毁并销毁其AO,最后输出n,首先会去当前作用域去寻找n,找到了当前作用域(全局作用域)中的n为100,所以最后输出100