【笔记】js事件执行整理
Jul 4, 2019笔记jsnodejsjs事件执行整理
js的事件感觉一直是重点也是容易被忽略的知识,借此篇边整理边理解。。。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,js的主要用途是与用户互动以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。
js的事件循环主要受两个概念的影响:运行至完成(run-to-completion)和非阻塞输入/输出(I/O)
数据结构储备
1.栈stack
FILO。
在js中这里主要指执行栈,也称调用栈,用于存储在代码执行期间创建的所有执行上下文。如下函数,它的调用形成了一个栈帧:1
2
3
4
5
6
7
8
9
10
11function 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 返回的时候,栈就空了。
2.堆heap
堆的特点是”无序”的key-value”键值对”存储方式,堆的存取方式跟顺序没有关系,不局限出入口。
在js中,对象被分配在一个堆中,即用以表示一大块非结构化的内存区域。
栈跟堆的一个区别是栈是由系统自动分配,堆需要程序员自己申请
js的内存存储
为什么会有栈内存和堆内存之分?通常与垃圾回收机制有关。为了使程序运行时占用的内存最小。
- 基本数据类型的变量随着方法的执行结束,这个方法的内存栈也将自然销毁了;
- 引用类型只有当一个对象没有任何引用变量引用它时,系统的垃圾回收机制才会在核实的时候回收它。
3.队列queue
FIFO
一个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 | √ | √ |
在任务队列中,所有同步任务(synchronous)都在主线程上执行,形成一个执行栈(execution context stack);主线程之外,还存在一个”任务队列”(task queue)。只要异步任务(asynchronous)有了运行结果,就在”任务队列”之中放置一个事件;一旦”执行栈”中的所有同步任务执行完毕,系统就会读取”任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行;主线程不断重复上一步。
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步:
- 执行完主执行线程中的任务后,取出 Microtask Queue 中任务执行直到清空,下一步。
- 取出 Macrotask Queue 来执行。执行完毕后,下一步。
- 取出 Microtask Queue 来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步。更新UI渲染。
- Event Loop 会无限循环执行上面第2、3步,这就是Event Loop的主要控制逻辑。
即为同步完成,一个宏任务,所有微任务,一个宏任务,所有微任务……
其实现类似于:1
2
3while (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
61
3
5
4
6
2
NodeJs 的 Event Loop
如图,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,后其它),然后再进入下一个阶段。
浏览器和Node 环境下,microtask 任务队列的执行时机不同:
- Node端,microtask 在事件循环的各个阶段之间执行
- 浏览器端,micro task 在事件循环的 macro task 执行完之后执行
相关链接
- https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop
- https://nodejs.org/dist/latest-v10.x/docs/api/process.html#process_process_nexttick_callback_args
- https://nodejs.org/dist/latest-v10.x/docs/api/timers.html#timers_setimmediate_callback_args
- https://www.jianshu.com/p/a6d37c77e8db
- https://html.spec.whatwg.org/multipage/webappapis.html#event-loop
- http://www.ruanyifeng.com/blog/2014/10/event-loop.html
Author
My name is Micheal Wayne and this is my blog.
I am a front-end software engineer.
Contact: michealwayne@163.com