从底层看JS执行机制

1,698 阅读7分钟

从一个简单的例子出发

先从一个简单的例子出发(先不涉及异步),看看自己是否大致了解浏览器的执行机制:

console.log(a);
var a=1;
function foo(a){
    console.log(a);
    var a=2;
    console.log(a);
}
foo(a);
执行结果:

undefined 1 2

如果你的预测结果不一样,那你可以看看下面几个常见的误区:

  • var a=1不是进行了变量提升?为什么第一个打印的结果为 undefined?

答:变量提升只提升变量的声明,并不进行赋值。其中变量提升发生在预编译阶段,此时a=undefined,预编译结束后代码如下

//函数声明和变量声明进行提升,且函数声明优先级更高
function foo(a){
    console.log(a);
    var a=2;
    console.log(a);
}
var a;
console.log(a);
a=1;
foo(a);

很明显第一个结果为undefined。

  • foo函数中参数名和变量名都为a,使用时该以哪一个a为准?

变量声明在顺序上跟在函数声明和形式参数声明之后,同时,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。例子中的var a=2,可以拆分为var a;a=2;其中a=2是对参数a进行赋值。

现在我们详细地说一说js的执行机制:

首先你需要理解如下几个概念:

JavaScript中的堆和栈

  • 栈(stack) 栈stack为自动分配的内存空间,它由系统自动释放;
  • 堆(heap) 堆heap是动态分配的内存,大小不定也不会自动释放。

JavaScript 中的变量分为基本类型和引用类型。其中,基本类型存在于栈中,引用类型存在于堆中。在js的执行阶段,当执行到a=2这样的赋值语句时,js引擎线程会先判断2是基本类型还是引用类型,如果它是基本类型,则直接对执行栈中的AO进行赋值a=2(AO会在下面的执行上下文中讲到),若是引用类型,则在堆中存入2,然后用2在堆中的地址对AO进行赋值。

执行环境

js的执行环境分为三种:

  • 全局环境(JS代码加载完毕后,进入代码预编译即进入全局环境)
  • 函数环境(函数调用执行时,进入该函数环境,不同的函数则函数环境不同)
  • eval(不建议使用,会有安全,性能等问题)

js每进入一个执行环境就会创建一个执行上下文,并将它放入执行栈中。执行上下文会在下文讲到。

单线程(同步和异步)

js是一门单线程语言,但并不意味着参与js执行过程的线程就只有一个。一个有四个线程参与该过程: JS引擎线程、事件触发线程、定时器触发线程、HTTP异步请求线程。其中,只有JS引擎线程在执行JS脚本程序,其他三个线程只负责将满足触发条件的处理函数推进事件队列,等待JS引擎线程执行。

举一个简单的例子来说:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

console.log('script end');
  1. JS引擎主线程按代码顺序执行,当执行到console.log('script start');,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script start,然后继续向下执行;

  2. JS引擎主线程执行到setTimeout(function() { console.log('setTimeout'); }, 0);,JS引擎主线程认为setTimeout是异步任务API,则向浏览器内核进程申请开启定时器线程进行计时和控制该setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么当计时到4ms时,定时器线程就把该回调处理函数推进任务队列中等待主线程执行,然后JS引擎主线程继续向下执行;

  3. JS引擎主线程执行到console.log('script end');,JS引擎主线程认为该任务是同步任务,所以立刻执行输出script end;

  4. JS引擎主线程上的任务执行完毕(输出script start和script end)后,主线程空闲,则开始读取任务队列中的事件任务,将该任务队里的事件任务推进主线程中,按任务队列顺序执行,最终输出setTimeout,所以输出的结果顺序为script start script end setTimeout;

如果还不清楚,可以看看下图:

首先,这是一个浏览器环境,其中主线程操作堆和执行栈,而RunTime中存在着许多web API,当主线程读取到setTimeOut等API时,它会交给其他线程来处理(setTimeOut则是定时器触发线程),定时器触发线程会先将setTimeOut中的回调函数存放在event table中,当满足触发条件时(如上面的4ms),就将回调函数推入事件队列(callback queue)中,等待主线程空闲(执行栈中为空),回调函数则被推入执行栈中进行执行。

执行上下文

执行上下文可理解为当前的执行环境,与该运行环境相对应。js引擎每进入一个环境就会创建相应的执行上下文,创建执行上下文的过程中,主要做了以下三件事件,如图:

其中,变量对象VO(Variable object)用于存放声明后的变量、函数和形参。我们举一个例子来说:

var a = 10;
 
function test(x) {
  var b = 20;
};
 
test(30);

对应的变量对象是:

// 全局上下文的变量对象
VO(global) = {
  a: undefined,
  test: <reference to function>
  //<reference to function>是test函数位于堆中的地址
};
 
// test函数上下文的变量对象
VO(test) = {
  arguments: {
      x:undefined,
      length:1
  },
  b: undefined
};

当预编译结束,js进入解释执行阶段时,VO就会转化为AO(Active object),也就是活动对象。AO中变量和参数的值不再是undefined,它们的值会随着js的逐步执行而发生变化。

作用域链用于表明上下文的执行顺序。上例中的作用域链为:

 scopeChain: [VO(test),  AO(global)],
  • 我们这里直接使用数组表示作用域链,作用域链的活动对象或变量对象可以直接理解为作用域;
  • 它的第一项永远是当前作用域(当前上下文的变量对象或活动对象);
  • 最后一项永远是全局作用域(全局执行上下文的活动对象);
  • 作用域链保证了变量和函数的有序访问,查找方式是沿着作用域链从左至右查找变量或函数,找到则会停止查找,找不到则一直查找到全局作用域,再找不到则会抛出引用错误。

this指向当前作用域。这里不做过多分析。

执行的三个阶段

js的执行分为三个阶段:

  1. 语法分析阶段: script标签加载即开始语法分析,分析整个标签内的语法错误。无错误即进入预编译阶段。

  2. 预编译阶段: 每进入一个新环境,就进行一次预编译,同时创建一个执行上下文(包含VO对象、作用域链、this,忘了是什么就往回看看)并放入执行栈中。此时,当前环境会进行一定的函数提升和变量提升,注意:函数提升优先于变量提升。环境中没有新的函数声明则进入解释执行阶段。

  3. 解释执行阶段: 将当前执行上下文中的VO->AO, 此后,js引擎在当前环境从上到下、从左到右执行代码,不断改变AO中的变量等内容。当前上下文执行完毕则出栈,执行下一个上下文。

同样,我们举一个例子进行分析:

var a = 1;
function bar() {
    var b = 2;

    function foo() {
        var c = 3;
    }

    foo();
    console.log(b)
}
function coo() {
    alert("hello")
}

bar()
coo()

我们解释一下以上过程:

  1. 浏览器加载script标签;
  2. 语法分析;
  3. 预编译(全局),js进入全局函数环境,主线程创建全局执行上下文(Global EC),入栈;
  4. 全局执行,a=1;
  5. bar()调用,js进入bar函数环境,创建bar执行上下文(bar EC),入栈;
  6. bar执行,bar EC中VO(bar)->AO(bar),b=2;
  7. foo()调用,创建foo执行上下文(foo EC),入栈;
  8. foo执行,foo EC中VO(foo)->AO(foo),c=3;
  9. foo执行结束,出栈;
  10. 继续执行bar,console.log(b);
  11. bar执行结束,出栈;
  12. coo()调用;
  13. ...
  14. coo执行结束,出栈;
  15. 浏览器或者该标签页关闭,Global EC出栈;


至此,你已基本理解了js的执行机制。

参考文献:

js引擎的执行过程

深入理解JavaScript系列(12):变量对象(Variable Object)