js事件执行整理

js的事件感觉一直是重点也是容易被忽略的知识,借此篇边整理边理解。。。

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,js的主要用途是与用户互动以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。

js的事件循环主要受两个概念的影响:运行至完成(run-to-completion)和非阻塞输入/输出(I/O)

数据结构储备

1.栈stack

FILO。

p-1.jpg

在js中这里主要指执行栈,也称调用栈,用于存储在代码执行期间创建的所有执行上下文。如下函数,它的调用形成了一个栈帧:

1
2
3
4
5
6
7
8
9
10
11
function foo(b) {
var a = 10;
return a + b + 11;
}

function bar(x) {
var y = 3;
return foo(x * y);
}

console.log(bar(7)); // 返回 42

当调用 bar 时,创建了第一个帧(栈帧) ,帧中包含了 bar 的参数和局部变量。当 bar 调用 foo 时,第二个帧就被创建,并被压到第一个帧之上,帧中包含了 foo 的参数和局部变量。当 foo 返回时,最上层的帧就被弹出栈(剩下 bar 函数的调用帧 )。当 bar 返回的时候,栈就空了。

p-1-1.png

2.堆heap

堆的特点是”无序”的key-value”键值对”存储方式,堆的存取方式跟顺序没有关系,不局限出入口。
在js中,对象被分配在一个堆中,即用以表示一大块非结构化的内存区域。

p-2.png

栈跟堆的一个区别是栈是由系统自动分配,堆需要程序员自己申请

js的内存存储

p-4.png

为什么会有栈内存和堆内存之分?通常与垃圾回收机制有关。为了使程序运行时占用的内存最小。

  • 基本数据类型的变量随着方法的执行结束,这个方法的内存栈也将自然销毁了;
  • 引用类型只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。

3.队列queue

FIFO

p-3.png

一个js运行时包含了一个待处理的消息队列,每一个消息都关联着一个用以处理这个消息的函数。

在事件循环期间的某个时刻,运行时从最先进入队列的消息开始处理队列中的消息。为此,这个消息会被移出队列,并作为输入参数调用与之关联的函数。正如前面所提到的,调用一个函数总是会为其创造一个新的栈帧。

函数的处理会一直进行到执行栈再次为空为止;然后事件循环将会处理队列中的下一个消息(如果还有的话)。

4.桢frame

桢是事件循环周期中需要被执行的连续工作单元。桢包含把函数对象和堆中的变量链接在一起的执行上下文。

任务队列

“任务队列”是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在”任务队列”中添加一个事件,表示相关的异步任务可以进入”执行栈”了。主线程读取”任务队列”,就是读取里面有哪些事件。

在js中,有两类任务队列:宏任务队列(macro tasks)微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。

  • 宏任务:script(全局任务), setTimeout, setInterval, setImmediate, I/O, UI rendering。
api 浏览器 Nodejs
setTimeout
setInterval
setImmediate x
requestAnimationFrame x
  • 微任务:process.nextTick, Promise, Object.observer, MutationObserver。
api 浏览器 Nodejs
process.nextTick x
MutationObserver x
Promise.then catch finally

p-6.png

在任务队列中,所有同步任务(synchronous)都在主线程上执行,形成一个执行栈(execution context stack);主线程之外,还存在一个”任务队列”(task queue)。只要异步任务(asynchronous)有了运行结果,就在”任务队列”之中放置一个事件;一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行;主线程不断重复上一步。

p-7.png

1.setTimeout()

  • 将事件插入到了事件队列,必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。
  • 当主线程时间执行过长,无法保证回调会在事件指定的时间执行。
  • 浏览器端每次setTimeout会有4ms的延迟,当连续执行多个setTimeout,有可能会阻塞进程,造成性能问题。

2.setImmediate()

Nodejs(https://nodejs.org/dist/latest-v10.x/docs/api/timers.html#timers_setimmediate_callback_args)。

setImmediate()用来把一些需要长时间运行的操作放在一个回调函数里,在浏览器完成后面的其他语句后,就立刻执行这个回调函数,

  • 事件插入到事件队列尾部,主线程和事件队列的函数执行完成之后立即执行。和setTimeout(fn, 0)的效果差不多。

3.process.nextTick()

Nodejs(https://nodejs.org/dist/latest-v10.x/docs/api/process.html#process_process_nexttick_callback_args)。

  • 插入到事件队列尾部,但在下次事件队列之前会执行。也就是说,它指定的任务总是发生在所有异步任务之前,当前主线程的末尾。
  • 大致流程:当前”执行栈”的尾部–>下一次Event Loop(主线程读取”任务队列”)之前–>触发process指定的回调函数。
  • 服务器端node提供的办法。用此方法可以用于处于异步延迟的问题。

事件循环Event Loop

浏览器的 Event Loop 遵循的是 HTML5 标准,而 NodeJs 的 Event Loop 遵循的是 libuv

在浏览器中,”任务队列”中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入”任务队列”,等待主线程读取。

当stack空的时候,就会从任务队列中,取任务来执行。浏览器这边,共分3步:

    1. 执行完主执行线程中的任务后,取出 Microtask Queue 中任务执行直到清空,下一步。
    1. 取出 Macrotask Queue 来执行。执行完毕后,下一步。
    1. 取出 Microtask Queue 来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。更新UI渲染。
  • Event Loop 会无限循环执行上面第2、3步,这就是Event Loop的主要控制逻辑。

即为同步完成,一个宏任务,所有微任务,一个宏任务,所有微任务……

其实现类似于:

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

如果当前没有任何消息,queue.waitForMessage() 会同步地等待消息到达。

事件循环模型的一个非常有趣的特性是,与许多其他语言不同,JavaScript 永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。

浏览器的Event Loop

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的event loop。

一个event loop有一个或者多个task队列,每一个task都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个task队列,其他事件又是一个单独的队列。可以为鼠标、键盘事件分配更多的时间,保证交互的流畅。

哪些是task任务源呢?

  • DOM操作任务源:此任务源被用来相应dom操作,例如一个元素以非阻塞的方式插入文档。
  • 用户交互任务源:此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列。
  • 网络任务源:网络任务源被用来响应网络活动。
  • history traversal任务源:当调用history.back()等类似的api时,将任务插进task队列。

task任务源非常宽泛,比如ajax的onload,click事件,基本上我们经常绑定的各种事件都是task任务源,还有数据库操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是task任务源。总结来说task任务源:setTimeout、setInterval、setImmediate、I/O、UI rendering。

添加消息/事件

在浏览器里,当一个事件发生且有一个事件监听器绑定在该事件上时,消息会被随时添加进队列。如果没有事件监听器,事件会丢失。所以点击一个附带点击事件处理函数的元素会添加一个消息,其它事件类似。

setTimeout(func, 0)并不表示在 0 毫秒后就立即调用回调函数。

其等待的时间取决于队列里待处理的消息数量。在下面的例子中,”this is just a message” 将会在回调获得处理之前输出到控制台,这是因为延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。基本上,setTimeout 需要等待当前队列中所有的消息都处理完毕之后才能执行,即使已经超出了由第二参数所指定的时间。

注意

  • 在浏览器页面中可以认为初始执行线程中没有代码,每一个<script>标签中的代码是一个独立的 task,即会执行完前面的<script>中创建的 microtask 再执行后面的<script>中的同步代码。
  • 如果 microtask 一直被添加,则会继续执行 microtask,“卡死”macrotask。
    部分版本浏览器有执行顺序与上述不符的情况,可能是不符合标准或 js 与 html 部分标准冲突。
  • new Promise((resolve, reject) =>{console.log(‘同步’);resolve()}).then(() => {console.log('异步')}),即 promise 的 then 和 catch 才是 microtask,本身的内部代码不是。
  • 个别浏览器独有 API 未列出。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
console.log(1);

setTimeout(() => console.log(2), 0);

new Promise(resolve => {
console.log(3);
resolve()
}).then(() => {
console.log(4)
})
console.log(5)
</script>
<script>
console.log(6)
</script>

结果为:

1
2
3
4
5
6
1
3
5
4
6
2

NodeJs 的 Event Loop

p-5.png

如图,NodeJs的运行机制如下:
(1)V8引擎解析JavaScript脚本。
(2)解析后的代码,调用Node API。
(3)libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎。
(4)V8引擎再将结果返回给用户。

具体到事件,NodeJs 的运行是这样的:

  • (1)初始化 Event Loop
  • (2)执行您的主代码。这里同样,遇到异步处理,就会分配给对应的队列。直到主代码执行完毕。
  • (3)执行主代码中出现的所有微任务:先执行完所有nextTick(),然后在执行其它所有微任务。
  • (4)开始 Event Loop

NodeJs 的 Event Loop 分6个阶段执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

以上的6个阶段,具体处理的任务如下:

  • timers: 这个阶段执行setTimeout()和setInterval()设定的回调。
  • pending callbacks: 上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段执行。
  • idle, prepare: 仅内部使用。
  • poll: 执行 I/O callback,在适当的条件下会阻塞在这个阶段
  • check: 执行setImmediate()设定的回调。
  • close callbacks: 执行比如socket.on(‘close’, …)的回调。
    每个阶段执行完毕后,都会执行所有微任务(先 nextTick,后其它),然后再进入下一个阶段。

p-8.png
浏览器和Node 环境下,microtask 任务队列的执行时机不同:

  • Node端,microtask 在事件循环的各个阶段之间执行
  • 浏览器端,micro task 在事件循环的 macro task 执行完之后执行

相关链接