概述

js是一种非常灵活的语言,理解js引擎的执行过程对我们学习javascript非常重要,但是网上讲解js引擎的文章也大多是浅尝辄止或者只局部分析,例如只分析事件循环(Event Loop)或者变量提升等等,并没有全面深入的分析其中过程。所以我一直想把js执行的详细过程整理成一个较为详细的知识体系,帮助我们理解和整体认识js。

在分析之前我们先了解以下基础概念:

  • javascript是单线程语言

    在浏览器中一个页面永远只有一个线程在执行js脚本代码(在不主动开启新线程的情况下)。

  • javascript是单线程语言,但是代码解析却十分的快速,不会发生解析阻塞。

    javascript是异步执行的,通过事件循环(Event Loop)的方式实现。

下面我们先通过一段较为简单的代码(暂不存在事件循环(Event Loop))来检验我们对js引擎执行过程的理解是否正确,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<script>
console.log(fun)

console.log(person)
</script>

<script>
console.log(person)

console.log(fun)

var person = "Eric";

console.log(person)

function fun() {
console.log(person)
var person = "Tom";
console.log(person)
}

fun()

console.log(person)
</script>

我们可以先分析上面的代码,按自己的理解分析输出的顺序是什么,然后在浏览器执行一次,结果一样的话,那么代表你已经对js引擎执行过程有了正确的理解;如果不是,则代表还存在模糊或者概念不清晰等问题。结果我们不在这里进行讨论,我们利用上面简单的例子全面分析js引擎执行过程,相信在理解该过程后我们就不难得出结果的,js引擎执行过程分为三个阶段:

  1. 语法分析

  2. 预编译阶段

  3. 执行阶段

注:浏览器首先按顺序加载由<script>标签分割的js代码块,加载js代码块完毕后,立刻进入以上三个阶段,然后再按顺序查找下一个代码块,再继续执行以上三个阶段,无论是外部脚本文件(不异步加载)还是内部脚本代码块,都是一样的原理,并且都在同一个全局作用域中。

语法分析

js脚本代码块加载完毕后,会首先进入语法分析阶段。该阶段主要作用是:

分析该js脚本代码块的语法是否正确,如果出现不正确,则向外抛出一个语法错误(SyntaxError),停止该js代码块的执行,然后继续查找并加载下一个代码块;如果语法正确,则进入预编译阶段

语法错误报错如下图:
syntax

预编译阶段

js代码块通过语法分析阶段后,语法正确则进入预编译阶段。在分析预编译阶段之前,我们先了解一下js的运行环境,运行环境主要有三种:

  • 全局环境(JS代码加载完毕后,进入代码预编译即进入全局环境)

  • 函数环境(函数调用执行时,进入该函数环境,不同的函数则函数环境不同)

  • eval(不建议使用,会有安全,性能等问题)

每进入一个不同的运行环境都会创建一个相应的执行上下文(Execution Context),那么在一段JS程序中一般都会创建多个执行上下文,js引擎会以栈的方式对这些执行上下文进行处理,形成函数调用栈(call stack),栈底永远是全局执行上下文(Global Execution Context),栈顶则永远是当前执行上下文。

函数调用栈

函数调用栈就是使用栈存取的方式进行管理运行环境,特点是先进后出,后进先出

我们分析下段简单的JS脚本代码来理解函数调用栈:

1
2
3
4
5
6
7
8
9
10
11
function bar() {
var B_context = "Bar EC";

function foo() {
var f_context = "foo EC";
}

foo()
}

bar()

上面的代码块通过语法分析后,进入预编译阶段,如下图:
stack

  1. 首先进入全局环境,创建全局执行上下文(Global Execution Context),推入stack栈中

  2. 调用bar函数,进入bar函数运行环境,创建bar函数执行上下文(bar Execution Context),推入stack栈中

  3. 在bar函数内部调用foo函数,则再进入foo函数运行环境,创建foo函数执行上下文(foo Execution Context),推入stack栈中

  4. 此刻栈底是全局执行上下文(Global Execution Context),栈顶是foo函数执行上下文(foo Execution Context),如上图,由于foo函数内部没有再调用其他函数,那么则开始出栈

  5. foo函数执行完毕后,栈顶foo函数执行上下文(foo Execution Context)首先出栈

  6. bar函数执行完毕,bar函数执行上下文(bar Execution Context)出栈

  7. Global Execution Context则在浏览器或者该标签页关闭时出栈。

注:不同的运行环境执行都会进入代码预编译和执行两个阶段,语法分析则在代码块加载完毕时统一检验语法

创建执行上下文

执行上下文可理解为当前的执行环境,与该运行环境相对应。创建执行上下文的过程中,主要做了以下三件事件,如图:
EC

  1. 创建变量对象(Variable Object)

  2. 建立作用域链(Scope Chain)

  3. 确定this的指向

创建变量对象

创建变量对象主要经过以下几个过程,如图:
VO

  1. 创建arguments对象,检查当前上下文中的参数,建立该对象的属性与属性值,仅在函数环境(非箭头函数)中进行,全局环境没有此过程

  2. 检查当前上下文的函数声明,按代码顺序查找,将找到的函数提前声明,如果当前上下文的变量对象没有该函数名属性,则在该变量对象以函数名建立一个属性,属性值则为指向该函数所在堆内存地址的引用,如果存在,则会被新的引用覆盖。

  3. 检查当前上下文的变量声明,按代码顺序查找,将找到的变量提前声明,如果当前上下文的变量对象没有该变量名属性,则在该变量对象以变量名建立一个属性,属性值为undefined;如果存在,则忽略该变量声明

注:在全局环境中,window对象就是全局执行上下文的变量对象,所有的变量和函数都是window对象的属性方法。

所以函数声明提前和变量声明提升是在创建变量对象中进行的,且函数声明优先级高于变量声明。

我们分析一段简单的代码,帮助我们理解该过程,如下:

1
2
3
4
5
6
7
8
9
10
11
function fun(a, b) {
var num = 1;

function test() {

console.log(num)

}
}

fun(2, 3)

这里我们在全局环境调用fun函数,创建fun执行上下文,这里为了方便大家理解,暂时不讲解作用域链以及this指向,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
funEC = {
//变量对象
VO: {
//arguments对象
arguments: {
a: undefined,
b: undefined,
length: 2
},

//test函数
test: <test reference>,

//num变量
num: undefined
},

//作用域链
scopeChain:[],

//this指向
this: window
}
  • funEC表示fun函数的执行上下文(fun Execution Context简写为funEC)

  • funE的变量对象中arguments属性,上面的写法仅为了方便大家理解,但是在浏览器中展示是以类数组的方式展示的

  • <test reference>表示test函数在堆内存地址的引用

注:创建变量对象发生在预编译阶段,但尚未进入执行阶段,该变量对象都是不能访问的,因为此时的变量对象中的变量属性尚未赋值,值仍为undefined,只有进入执行阶段,变量对象中的变量属性进行赋值后,变量对象(Variable Object)转为活动对象(Active Object)后,才能进行访问,这个过程就是VO –> AO过程。

建立作用域链

作用域链由当前执行环境的变量对象(未进入执行阶段前)与上层环境的一系列活动对象组成,它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

理清作用域链可以帮助我们理解js很多问题包括闭包问题等,下面我们结合一个简单的例子来理解作用域链,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var num = 30;

function test() {
var a = 10;

function innerTest() {
var b = 20;

return a + b
}

innerTest()
}

test()

在上面的例子中,当执行到调用innerTest函数,进入innerTest函数环境。全局执行上下文和test函数执行上下文已进入执行阶段,innerTest函数执行上下文在预编译阶段创建变量对象,所以他们的活动对象和变量对象分别是AO(global),AO(test)和VO(innerTest),而innerTest的作用域链由当前执行环境的变量对象(未进入执行阶段前)与上层环境的一系列活动对象组成,如下:

1
2
3
4
5
6
7
8
9
10
11
innerTestEC = {

//变量对象
VO: {b: undefined},

//作用域链
scopeChain: [VO(innerTest), AO(test), AO(global)],

//this指向
this: window
}

我们这里直接使用数组表示作用域链,作用域链的活动对象或变量对象可以直接理解为作用域。

  • 作用域链的第一项永远是当前作用域(当前上下文的变量对象或活动对象);

  • 最后一项永远是全局作用域(全局执行上下文的活动对象);

  • 作用域链保证了变量和函数的有序访问,查找方式是沿着作用域链从左至右查找变量或函数,找到则会停止查找,找不到则一直查找到全局作用域,再找不到则会抛出引用错误。

在这里我们顺便思考一下,什么是闭包

我们先看下面一个简单例子,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var num = 20;

function bar() {
var result = num + 20;

return result
}

bar()
}

foo()

因为对于闭包有很多不同的理解,包括我看的一些书籍(例如js高级程序设计),我这里直接以浏览器解析,以浏览器理解的闭包为准来分析闭包,如下图:
闭包

如上图所示,chrome浏览器理解闭包是foo,那么按浏览器的标准是如何定义闭包的,我总结为三点:

  1. 在函数内部定义新函数

  2. 新函数访问外层函数的局部变量,即访问外层函数环境的活动对象属性

  3. 新函数执行,创建新的函数执行上下文,外层函数即为闭包


确定this指向

在全局环境下,全局执行上下文中变量对象的this属性指向为window;函数环境下的this指向却较为灵活,需根据执行环境和执行方法确定,需要举大量的典型例子概括,本文先不做分析。

总结

由于涉及的内容过多,这里将第三个阶段(执行阶段)单独分离出来。另开新文章进行详细分析,下篇文章主要介绍js执行阶段中的同步任务执行和异步任务执行机制(事件循环(Event Loop))。本文如果错误,敬请指正。

参考书籍

  • 你不知道的javascript(上卷)