绕不开的浏览器事件队列

274 阅读10分钟

写在前面

js的异步始终是绕不开的话题,基本每天写代码都能碰上异步编程的概念。而异步编程又和事件循环机制息息相关,之前对事件循环和异步编程也是一知半解,趁有空整理下事件循环和异步编程相关的知识。

浏览器进程与js线程

翻翻我的操作系统教科书上对线程和进程的定义:

  • 进程:分配CPU资源的基本单位
  • 线程:进程的一个实体,是被独立调度和分派的基本单位

为什么js单线程

大家都知道js是单线程的,但准确来说,是js引擎是单线程

为什么是单线程呢?我理解的是,js是解释性的语言,解释一行,执行一行,所以具有跨平台性。 js引擎并不是独立运行的,跨平台意味着js依赖其运行的宿主环境中,大部分情况下是web浏览器(服务器有node.js),用来实现浏览器与用户之间的交互的,如果同时要处理用户点击,输入等操作,如果两个script同时想操作页面某个结点,浏览器就懵了,不知道该执行哪个。

js执行队列:操作系统中的轮转时间片算法:js会将任务分成无数个事件片,排成一个队列,时间片的谁前谁后都不一定,争抢时间片,一个时间片一个时间片的往js引擎里传送,任务按时间片执行完,类似吃饭,在时间片被压块的情况下看起来就像是同步执行完的

浏览器内核是多进程的

打开谷歌的任务管理器查看,每一个tab就是一个进程,我们可以清晰的看到每一个进程占有的 CPU资源和内存资源。注意,并不是一个tab页对应一个进程:在同一站点打开的tab属于同一进程,因为Chrome规定:每个标签对应一个渲染(render)进程。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程。就像这里打开B站的多页面共用一个进程。另外,多个空白页也会共用一个进程。

任务管理器

主进程常驻的线程有:

  • GUI渲染线程
  • JS引擎线程
  • 事件触发线程
  • 定时器触发线程
  • HTTP请求线程

GUI渲染线程

绘制用户界面,解析HTML、CSS,构建DOM树,布局等。
当界面需要重绘或者由于某种操作引发回流时,将执行该线程。该线程与JS引擎线程互斥

JS引擎线程

主要负责处理 JavaScript脚本,执行代码。
也是主要负责执行准备好待执行的事件,即定时器计数结束,或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS引擎线程的执行。 由于该线程与 GUI渲染线程互斥,当 JS引擎线程执行 JavaScript脚本时间过长,将导致页面渲染的阻塞。

事件触发线程

主要负责将准备好的事件交给 JS引擎线程执行。
比如 setTimeout定时器ajax等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将准备好的事件依次加入到任务队列的队尾,等待JS引擎线程的执行。
由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理

定时器触发线程

负责执行异步定时器一类的函数的线程,如setTimeoutsetInterval
主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待JS引擎线程执行。

HTTP请求线程

负责执行异步请求的线程,如: Promiseaxiosajax等。
主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到产生状态变更事件,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待JS引擎线程执行。

同步任务及异步任务

有的任务你需要Ajax获取到数据才能往下执行,我们不可能停止执行等待它执行返回结果。所以就有了同步任务、异步任务。 所谓异步任务,是指在计算机中同时发生的事情,就好比我们煮面,一边烧水一边切菜,等水开再下面。这就是一个简单的异步操作。 异步可以提高处理事件的效率。可以解决单线程按照顺序依次执行,无法同时进行多个任务的问题。

\color{#FF3030}{程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心}

怎么判断异步任务呢,简单来说:在将来执行的那块函数就是异步函数

就像vue中的 $nextTick方法,一开始我是这么理解: $nextTick保证能获取更新后的dom结构,实际上:Vue是异步执行dom更新的,这个方法的主要目的就是把事件直接插入到执行栈的最后,而不是放入到任务队列中去执行。这个执行流程就变成了执行栈的任务——>$nextTick——>任务队列。

这张图表明了同步任务与异步任务的执行流程

执行流程图

event loop

同步任务和异步任务在js中是如何执行的呢?

js的代码运行会形成一个主线程stacks)和一个任务队列task queue)。 主线程会从上到下一步步执行我们的js代码,形成一个执行栈。同步任务就会被放到这个执行栈中依次执行。而异步任务被放入到任务队列中执行,当执行栈中的任务全部运行完毕,js会去摘取并执行任务队列中的事件。这个事件就是你往后稍稍的回调函数
这个过程是循环进行的,这就是今天说的event loop。循环的每一轮称为一个tick

那么问题又来了
线程执行的任务都来自消息队列,消息队列是先进先出的,也就是说,无论前面有多少个任务,都需要等待前面的任务执行完,后面的任务才会被执行,那么如果某事件挂了 “ 加急号 ” 怎么办?如果某一事件执行太久怎么办?

为了弥补单线程的缺点,微任务就出场了

宏任务和微任务

消息队列并不是很灵活,如何处理高优先级的任务?兼顾效率和实时性?

一方面:任务分为同步任务和异步任务
另一方面:任务可以划分为宏任务和微任务(taskjobs

  • 通常我们把消息队列中的任务称为宏任务
  • 当前宏任务快执行结束后一次性立即执行的任务称为微任务

在《你不知道的js(中卷)》中写道:es6建立了一个新的概念建立在事件循环队列之上:叫任务队列(job queue)。

任务队列就是国内常说的微任务。是挂在事件循环队列的每个tick之后的队列。在处理宏任务的过程中出现回调,就先放在微任务列表中,等宏任务中的主要功能都直接完成之后,这时浏览器马上执行当前宏任务中的微任务。

这样就一不会影响到其他的宏任务的执行,二也能保证本宏任务中的变化事件都能得到及时的处理,不会出现任务排队’ 饿死 ‘的情况,解决了实时性问题。

宏任务与微任务

如果在微任务执行期间微任务队列加入了新的微任务,会将新的微任务加入队列尾部,之后也会被执行

  • 宏任务:
- 全局(script)
- setTimeout
- setInterval
- I / O
- UI渲染
- setImmediate(Node.js 环境)
  • 微任务:
- process.nextTick
- promise
- Object.observe
- MutationObserver

这篇博文把宏任务和微任务比作银行柜台我觉得很生动形象,通俗易懂 --> 微任务和宏任务的区别

根据whatwg规范介绍

  • 一个事件循环( event loop )会有一个或多个任务队列( task queue )
  • 每一个 event loop 都有一个 microtask queue
  • task queue == macrotask queue != microtask queue
  • 一个任务 task 可以放入 macrotask queue 也可以放入 microtask queue 中
  • 调用栈清空(只剩全局),然后执行所有的microtask。当所有可执行的microtask执行完毕之后。循环再次从macrotask开始,找到其中一个任务队列执行完毕,然后再执行所有的microtask,这样一直循环下去

俗话说:光说不练全白给,来看一段代码: 想想输出顺序是什么?

setTimeout1 = setTimeout(() => {
  console.log(' 1')
}, 0);
seTtimeout2 = setTimeout(() => {
  Promise.resolve ()
  .then( () => {
    console.log(' 2')
  })
  console.log(' 3')
}, 0);
new Promise( (resolve) => {
  console.time("promise")
  for( var i = 0; i < 10000; i++) {
    i === 9999 && resolve()
  }
  console.timeEnd("promise")
}).then( () => {
  console.log(' 4 ')
});
async function test() {
  let result = await Promise.resolve(' 5 ')
  console.log(result)
}
test()
console.log(' 6 ')

经过 瞎猜 缜密的分析,正确的执行步骤应该是:

1: 执行脚本,入栈

stacks: []
task queue : [script]
microtask queue : []

2: 遇到setTimeout1,宏任务,进入任务队列

stacks: [script]
task queue : [setTimeout1]
microtask queue : []

3: 遇到setTimeout2,宏任务,进入任务队列

stacks: [script]
task queue : [setTimeout1, setTimeout2]
microtask queue : []

4:new Promise 同步操作,执行for循环,输出执行时间

stacks: [script]
task queue : [setTimeout1, setTimeout2]
microtask queue : []

5:在i = 9999时,resolve触发,回调成功后.then()的执行代码进入微任务队列

stacks: [script]
task queue : [setTimeout1, setTimeout2]
microtask queue : [console.log(' 4 ')]

6: async返回一个promise对象,Promise.resolve放进微任务队列

stacks: [script]
task queue : [setTimeout1, setTimeout2]
microtask queue : [console.log(' 4 '), Promise.resolve]

7: 执行console.log(' 6 '), 此时栈中的任务结束

stacks: []
task queue : [setTimeout1, setTimeout2]
microtask queue : [console.log(' 4 '), Promise.resolve]

8: 栈中为空,事件轮询线程先执行微任务队列,微任务队列中的任务排队入栈,先输出' 4 '

stacks: []
task queue : [setTimeout1, setTimeout2]
microtask queue : [ Promise.resolve]

9:执行微任务队列中async的promise,输出' 5 '

stacks: []
task queue : [setTimeout1, setTimeout2]
microtask queue : []

10:栈又空,微任务队列也是空的,这时开始执行宏任务里的任务了,setTimeout1先入队列,先输出' 1 '

stacks: [setTimeout1, setTimeout2]
task queue : []
microtask queue : []

11:接着setTimeout2在里有个Promise.resolve(),将resolve()压入微任务队列里头,然后输出’ 3 ‘

stacks: [ setTimeout2 ]
task queue : []
microtask queue : [ Promise.resolve()]

12: seTimeout执行完成后执行微任务队列:输出' 2 ' 所有任务执行完成

/**
 * 打印结果
 * promise:1.522ms
 *  6 
 *  5 
 *  4 
 *  1 
 *  3 
 *  2 
 */

简单滴说:整个的js代码宏任务先执行,同步代码执行完后有微任务执行微任务,没有微任务执行下一个宏任务,如此往复循环至结束

事件循环是理解异步编程的基础,归纳梳理后脑子里的体系会更清楚一些,希望看到这里的小伙伴可以更好的理解事件循环机制咯~

强推,一定要看