Netherspite

JavaScript的执行过程

JavaScript的执行与运行

JavaScript分为编译阶段和执行阶段。编译后会生成执行上下文和可执行代码。JavaScript的执行与运行是两个不同的概念,执行一般依赖于环境如node、浏览器、Ringo等,JavaScript在不同环境下的执行机制可能不同。而运行指的是JavaScript的解析引擎,这是统一的。

JavaScript有三种代码会在执行之前编译并创建上下文:

  1. 全局代码,全局执行上下文只有一份
  2. 调用函数时,函数内部代码会被编译并创建函数执行上下文
  3. eval函数

闭包

闭包在编译原理中是处理语法产生式的一个步骤;在计算几何中,表示包裹平面点集的凸多边形(凸包);在编程语言领域,表示一种函数。根据闭包的古典定义,JavaScript中的闭包包含两个部分。

  • 环境部分
    • 环境:函数的词法环境(执行上下文的一部分)
    • 标识符列表:函数中用到的未声明的变量
  • 表达式部分:函数体

执行上下文

当执行到一个函数时,就会进行执行上下文的准备工作。JavaScript引擎创建了执行上下文栈(Execution Context Stack)来管理执行上下文。方便起见定义上下文栈为一个数组:

ECStack = [];

JavaScript代码初始化时首先向执行上下文栈压入全局执行上下文,只有当整个应用程序结束时ESCtack才会被清空

ECStack = [ globalContext ];

当执行一个函数时,就会创建一个执行上下文,并压入执行上下文栈,执行完毕后,从栈中弹出。在ES3中,每个执行上下文有三个重要属性:

  • Variable Object,VO:变量对象
  • Scope:作用域链
  • this

ES5中改进了命名方式。

  • Lexical Environment:词法环境,获取变量时使用
  • Variable Environment:变量环境,声明变量时使用
  • this

ES2018中,又修改了执行上下文。

  • Lexical Environment:词法环境,获取变量或this时使用
  • Variable Environment:变量环境,声明变量时使用
  • Code Evaluation State:恢复代码的执行位置
  • Function:执行的任务是函数时使用,表示正在被执行的函数
  • ScriptOrModule:执行的任务是脚本或模块时使用,表示正在被执行的代码
  • Realm:使用的基础库和内置对象实例
  • Generator:生成器上下文有的属性,表示当前生成器

作用域链/词法环境

JavaScript采用的是词法作用域,函数的作用域在定义的时候就决定了。函数有个内部属性[[scope]],当函数创建时,就会保存所有父变量对象到其中。在上述例子中,foo和c各自的[[scope]]为:

foo.[[scope]] = [
globalContext.VO
];
c.[[scope]] = [
fooContext.AO,
globalContext.VO
];

函数激活时,进入函数上下文,创建VO/AO后,就会将活动对象添加到作用链的前端,这时执行上下文的作用域链为Scope

Scope = [AO].concat([[Scope]]);

变量对象

全局上下文的变量对象就是全局对象(window)。在函数上下文中,用活动对象(Activation Object,AO)来表示变量对象。
执行上下文的代码会分成两个阶段进行处理:分析和执行。进入执行上下文时,变量对象会包括:

  • 函数的所有形参:名称和对应值组成一个变量对象的属性被创建,没有实参属性值为undefined
  • 函数声明:名称和对应值(函数对象function-object)组成一个变量对象的属性,如果变量对象已经存在相同名称的属性,则替换它
  • 变量声明:由名称和对应值(undefined)组成一个变量对象的属性,如果变量名称跟已声明的形式参数或函数相同,则变量声明不会干扰已存在的属性

如:

function foo(a) {
var b = 2;
function c() {}
var d = function() {};
b = 3;
}
foo(1);

在进入执行上下文后,这时候的AO

AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}

代码执行阶段,按顺序执行代码,根据代码,修改变量对象的值,执行完后AO

AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}

总结函数执行过程中执行上下文作用域链和变量对象的创建过程:

  • 函数被创建,保存作用域链到内部属性[[scope]]
  • 函数被执行,创建函数执行上下文并压其入执行上下文栈
  • 准备工作:复制[[scope]]属性创建作用域链,用arguments创建活动对象AO,随后初始化,并将活动对象压入作用域链顶端
  • 执行函数,修改AO
  • 执行完毕,函数上下文从执行上下文栈弹出
    foo.[[ scope ]] = [ globalContext.VO ];
    ECStack = [ fooContext, globalContext ];
    fooContext = {
    Scope: [AO, foo.[[ scope ]] ],
    AO: {
    arguments: {
    0: 1,
    length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c() {},
    d: undefined
    }
    }

Realm

在ES2016之前的版本中,标准中很少提及{}的原型问题。但在实际的开发过程中,通过iframe等方式创建多window环境并不罕见,这促进了Realm的引进。

Realm中包含一组完整的内置对象的复制。对不同Realm中的对象操作instanceOf几乎是失效的。

var iframe = document.createElement('iframe')
document.documentElement.appendChild(iframe)
iframe.src="javascript:var b = {};"

var b1 = iframe.contentWindow.b;
var b2 = {};

console.log(typeof b1, typeof b2); //object object

console.log(b1 instanceof Object, b2 instanceof Object); //false true

b1和b2同样的代码在不同的Realm中执行,表现出了不同的行为。

编译器和解释器

编译型语言:如C/C++、GO等,在程序执行之前,都需要经过编译器的编译过程,并且编译之后会直接保留及其能读懂的二进制文件,每次运行可以直接运行该二进制文件。

解释型语言:如JavaScript、Python等,在每次运行时都需要通过解释器对程序进行动态解释和执行。


V8中的JavaScript执行过程


  1. 生成抽象语法树(AST)和执行上下文

    参考:抽象语法树执行过程

  2. 生成字节码

    解释器Ignition会根据AST生成字节码,并解释执行字节码。由于Chrome在手机上的普及,在内存比较小的手机上V8存放转换后的机器码会消耗大量内存,所以引入了字节码。

  3. 执行代码

    第一次执行的字节码,解释器Ignition会逐条解释执行,如果发现有被重复执行多次的一段代码(热点代码),后台的编译器TurboFan会将该热点代码编译为机器码,提升代码的执行效率。字节码配合编译器和解释器称为即时编译(JIT

Event Loop

单线程

JavaScript是单线程,同一时间只能做一件事。作为浏览器脚本语言,JavaScript的主要用途是与用户互动以及操作DOM。假设是多线程,此时一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点此时会引发一系列复杂的问题。

浏览器的渲染进程有一个IO线程用来接收其他进程传进来的消息,接收到消息之后,会将这些消息组装成任务发送给消息队列。

任务队列

单线程意味着所有任务需要排队,任务分为同步任务和异步任务。异步任务包括宏任务微任务。在所有同步任务执行完之前,异步任务是不会执行的。

Event Loop

异步任务执行机制如下:


  • 所有同步任务在主线程上执行,形成一个执行栈
  • 主线程之外,还有一个任务队列。只要异步任务有了运行结果,就在任务队列之中放置一个事件
  • 一旦执行栈中的所有同步任务执行完毕,系统就会读取任务队列。

异步任务类型:setTimeoutsetInterval、DOM事件、Promise、AJAX请求。其中ajax加载完才会放入异步队列,Promise定义部分的代码会立即执行

JavaScript代码运行分为

  • 预解析,把所有函数定义、变量声明提前
  • 执行,从上到下执行

微任务与宏任务

宿主(浏览器等)发起的任务称为宏任务,JavaScript引擎发起的任务称为微任务。宏任务时间粒度比较大,执行的时间间隔是不能精确控制的。每个宏任务都关联了一个微任务队列。

  • 宏任务:script(全局任务)、setTimeoutsetIntervalsetImmediate、I/O、UI rendering
  • 微任务:new Promise.then/catch/finallyprocess.nextTick等,以及使用MutationObserver监控某个DOM节点,然后通过JavaScript来修改这个节点,或者添加、删除部分子节点,当DOM节点发生变化时,会产生DOM变化记录的微任务

由于执行代码入口都是全局任务 script,而全局任务属于宏任务,所以当栈为空,同步任务任务执行完毕时,会先执行微任务队列里的任务。微任务队列里的任务全部执行完毕后,会读取宏任务队列中排最前的任务。执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。执行微任务的过程中产生的新的微任务不会推迟到下个宏任务中执行,而是在当前的宏任务中继续执行。

setTimeout

  • setTimeout存在嵌套调用,定时器调用五次以上,系统会哦按段该函数方法被阻塞了,如果定时器的调用时间间隔小于4ms,那么浏览器会将每次调用的时间间隔设置为4ms。
  • 未激活的页面,setTimeout执行最小间隔是1000ms,目的是为了优化后台页面的加载损耗以及降低耗电量。
  • setTimeout设置的延时值是用32bit来存储的,最大的数字是2147483647ms,大约24.8天。大于这个数会溢出,导致其被立即执行。

MutationObserver

前身是Mutation Event,采用观察者的设计模式,当有DOM变动立刻触发相应的事件,属于同步回调,这种DOM变化的实时性会造成严重的性能问题
MutationObserver将响应函数改为异步调用,每次DOM节点发生变化时,渲染引擎将其变化记录封装成微任务,并将微任务添加进当前的微任务队列中,即通过异步来解决了同步操作的性能问题,用微任务解决了实时性的问题。

Node环境下的Event Loop

Node中的Event Loop是基于libuv实现的,而libuv是Node的新跨平台抽象层,其使用异步,事件驱动的编程方式,核心是提供I/O的时间循环和异步回调。libuv的API包含有时间、非阻塞的网络、异步文件操作、子进程等。

Node的Event Loop的六个阶段

timers

执行setTimeout()setInterval()中到期的callback,并且由poll阶段控制。在timers阶段使用最小堆而不是队列来保存元素,因为timeout的callback是按照超时时间顺序来调用的。timers在第一个阶段的原因是在Node中定时器指定的时间不准确,放在第一个可以尽量保证其准确。

pending callback

上一轮循环中有少属的I/Ocallback会被延迟到这一轮的这一阶段执行,比如一些TCP的error回调

idle, prepare

仅内部使用

poll

执行I/Ocallback,处理poll队列中的事件。当Event Loop进入到poll阶段且timers阶段没有定时器回调的时候,会出现以下两种情况:

  • poll队列非空,执行poll队列中的事件,直到为空或达到系统限制
  • poll队列为空,若setImmediate有回调要执行,则直接进入check,否则等待callback被添加到队列中再立即执行,此时会产生阻塞

同时,一旦poll队列为空,Event Loop就会去检查timer的任务,有的话会返回timers执行回调。

check

执行setImmediate的callback。setImmediatesetTimeout相比,如果在I/O周期内安排了任何计时器,则setImmediate始终在任何计时器之前执行

close callbacks

执行close事件的callback,如socket.on('close'[, fn])

nextTick队列

process.nextTick()有自己的队列,它不是Event Loop的一部分,如果存在nextTick队列,就会清空队列中的所有回调函数,并且优先于其他微任务。

setTimeout(() => {
console.log('timer1');
Promise.resolve().then(function(){
console.log('promise1');
})
}, 0);
process.nextTick(() => {
console.log('nextTick1');
process.nextTick(() => {
console.log('nextTick2');
})
})
//nextTick1
//nextTick2
//timer1
//promise1

nextTick与setImmediate

nextTick在同一阶段立即出发,而setImmediate在事件循环的下一次迭代或tick中出发

Node与浏览器Event Loop的区别

浏览器环境下,微任务的任务队列是在每个宏任务执行完之后执行;而Node中微任务在事件循环的各个阶段之间执行,一个阶段执行完毕,就会清空微任务队列。

Completion Record

Completion Record表示一个语句执行完之后的结果,它有三个字段:

  • [[type]],表示完成的类型,有break、continue、return、throw和normal几种类型
  • [[value]],表示语句的返回值,若没有则为empty
  • [[target]],表示语句的目标,通常是一个JavaScript标签

JavaScript是依靠语句的Completion Record类型来实现语句的复杂嵌套结构的控制。

break continue return throw
if 穿透 穿透 穿透 穿透
switch 消费 穿透 穿透 穿透
for/while 消费 消费 穿透 穿透
function 报错 报错 消费 穿透
try 特殊 特殊 特殊 消费
catch 特殊 特殊 特殊 穿透
finally 特殊 特殊 特殊 穿透

finally中的内容必须保证执行,所以即使try/catch执行完毕的结果是非normal的完成记录,也必须要执行finally,而当finally执行也得到了非normal的记录,则会使finally中的记录作为整个try结构的结果。


参考文章

  1. 编译器和解释器:V8是如何执行一段JavaScript代码的?
  2. 彻底吃透 JavaScript 的执行机制

 评论