前戏

众所周知,JavaScript作为一门单线程的非阻塞的脚本语言,起初设计的目的是为了运行在浏览器里进行交互的。我们来提取一下关键字「单线程」、「非阻塞」。

单线程,顾名思义,JavaScript代码在执行的时候,有且只有一个主线程来承担所有的处理任务。单线程在保证代码的执行顺序的时候又限制了代码的效率,所以又有另一门技术诞生–web worker,这使得JS成为了一门多线程语言。但是web worker也有很多限制,与其他多线程语言的多线程又有所不同,譬如说,web worker开启的其他线程都受制于主线程,不能独立执行,这些「线程」看起来就是主线程的子线程,而且,这些「线程」没有进行I/O操作的权限,只能为主线程分担一些类似计算的任务。所以说这些「线程」像是阉割版的线程,并不具备完成的功能,因此这项技术的诞生并不能改变JS单线程语言的本质特点。

非阻塞,为什么单线程又非阻塞呢?这就要归功于JS天生异步的特点了(关于js异步的问题会在单独文章里总结)。非阻塞就是当执行到异步任务的时候,主线程会挂起这个任务,然后在异步任务返回结果的时候再按照一定的规则去执行相应的回调。故而并不会阻塞主线程的执行。那么,JS引擎是如何实现「非阻塞」的呢?下面就上今天的主菜,事件循环 Event Loop。

主菜

浏览器里的事件循环机制

  1. 执行栈与事件队列
    javascript代码在执行的时候会将不同的变量存放在内存的不同位置中来加以区分,即堆(heap)和栈(stack)。堆里存放着对象,栈中存放着基础类型的变量和对象的指针。我们这里所说的执行栈与这个「栈」的意义有所不同。
    javascript代码在执行的时候回生成执行环境,简称EC(Execute Context),即执行上下文。EC里存着这个方法的私有作用域,上层作用域的指向,以及方法的参数,作用域中定义的变量以及作用域的this指向。由于javascript是单线程的,所以当这一系列方法被调用时候,同一时间只能执行其一,于是这些方法被排列在一个单独的地方,这个地方就叫做「执行栈」ECS(EC Stack)。
    当一个脚本第一次执行的时候,JavaScript引擎会解析这段代码,并将其中同步的代码按顺序加入执行栈中,然后从头开始按顺序执行。如果执行的是一个方法,那么js会在执行栈中添加这个方法的执行环境,然后进入这个执行环境继续执行其中的代码。当这个执行环境中的代码执行完毕返回结果后,js会退出当前执行环境并将其销毁,回到上一个方法的执行环境当中。这个过程如此反复进行着,知道执行栈中的代码全部执行完毕。
    在当前执行环境中可以调用其他方法,甚至可以调用自身,就是再执行栈中再添加该方法的执行环境,按照上面的过程无限进行,除非发生栈溢出,超出了所能使用的内存的最大值。
    以上都是针对同步代码的执行过程,那么对于异步操作,例如Ajax等,执行后会是怎样的情况呢?此前说道,js是非阻塞的,关于其实现的机制,这时候就要上另一道菜,「事件队列」(Task Queue)。
    JavaScript引擎在执行到异步代码的时候,不会立即返回结果,而是会将其挂起,然后继续执行执行栈中的其他任务。当一个异步代码返回结果是,会将其回调放在事件队列中,事件队列里的任务不会立即执行,而是等到执行栈中的同步任务全部执行完之后,主线程处于空闲状态,会去事件队列中查找是否有任务需要执行,如果有,那么主线程会把其中第一位的回调取出来放到执行栈中,然后执行其中的同步代码,如此反反复复来来回回,就形成了一个「循环」,这就是我们所说的「事件循环」(Event Loop)。
    stack表示我们所说的执行栈,web apis则是代表一些异步事件,而callback queue即事件队列

  2. 微任务和宏任务
    上面是对事件循环的一个宏观的描述,实际上异步任务也是分优先级的。说起异步任务,呐,异步任务又分为微任务(Micro task)和宏任务(Macro task)。

    1
    2
    3
    4
    5
    6
    7
    8
    // 以下属于微任务:
    Promise().then().catch().finally()
    new MutaionObserver()
    process.nextTick() //(Nodejs的API,下文会说到)
    // 以下属于宏任务:
    setTimeout(callback)
    setInterval(callback)
    setImmediate(callback) //(Nodejs的API,下文会说到)

上面又说到异步任务的回调会被放到「事件队列」里,再去细分,实际上还可以分为微任务队列和宏任务队列。当主线程的同步任务执行完之后,主线程会去微任务队列查询是否有事件存在,如果存在,那么依次执行队列中的回调,知道微任务队列为空;如果没有再去宏任务队列去查询出事件回调然后放到当前执行栈中。如此反反复复来来回回,形成一个循环。
其实这部分很容易记住,我们只要知道,一个事件循环中,同步任务执行完毕之后就先去事件队列里找微任务,微任务执行完了再去找宏任务。

1
2
3
4
5
6
7
8
9
10
11
setTimeout(function() {
console.log(4)
}, 0)
console.log(1)
new Promise(function(resolve, reject) {
console.log(2)
resolve()
}).then(d => {
console.log(3)
})
// 1 2 3 4

光盘

JavaScript基础很重要,只有掌握了代码执行的基本机制,才能更好的控制代码,减少不确定性。

文中若有错误,欢迎指正。