1

以下是自己在过往不断总结的一些关于 JavaScript 在浏览器和 Node 环境下事件循环的理解。如有谬误欢迎指正。

你能否说出这个例子输出什么?

image.png

这里的结果其实是:不确定的输出。为什么呢?这里就要给大家澄清几个误解

下面这些都是误解,你有吗?

误解1:在JavaScript平台上有一个用户态的主线程,用来执行 JavaScript 代码;除此之外,还有个EventLoop线程
用来做事件循环的检查,检查到有事件任务时,再交给JavaScript执行线程来执行

误解2:所有的异步操作(无论文件读写还是database操作)都交给libuv提供的线程池来处理。

误解3:EventLoop的事件队列就是一个类似queue的先进先出数据结构的队列

解释

误解1:EventLoop和执行JavaScript就在一个线程内。

误解2:libuv默认创建4个线程来进行异步工作;但现在OS一般都有提供异步接口例如linux的AIO,一般都是优先使用异步接口。

误解3:EventLoop作为进程,它有一组阶段,他会以循环的方式去处理各个阶段的事件,每个阶段是一种类似队列的结构。

https://www.dynatrace.com/news/blog/all-you-need-to-know-to-really-understand-the-node-js-event-loop-and-its-metrics/

后面我们来继续分享EventLoop的机制。

浏览器内核会在其它线程中执行异步操作,当操作完成后,将操作结果以及事先定义的回调函数放入 JavaScript 主线程的任务队列中。
JavaScript 主线程会在执行栈清空后,读取任务队列,读取到任务队列中的函数后,将该函数入栈,一直运行直到执行栈清空,再次去读取任务队列,不断循环。
当主线程阻塞时,任务队列仍然是能够被推入任务的。这也就是为什么当页面的 JavaScript 进程阻塞时,我们触发的点击等事件,会在进程恢复后依次执行。

图解浏览器中的 JavaScript 运行时线程

image.png
image.png

Promise.then的回调是异步执行的

image.png

之所以promise无论有没有真的走异步,但then始终要异步,是因为规范要求的。onFulfilled 必须在执行上下文栈(Execution Context Stack) 只包含 平台代码(platform code) 后才能执行。平台代码指引擎,环境,Promise 实现代码等。实践上来说,这个要求保证了 onFulfilled 的异步执行(以全新的栈),在 then 被调用的这个事件循环之后

也就是说,必须等到所有业务代码执行完(相当于resolve之前存在的所有macro和micro都做完,再执行)。 其实对我们关注业务代码来说,相当于then里面的代码就是异步执行。

macroTask 和 microtask

image.png

JavaScript 中的任务又分为 MacroTask 与 MicroTask 两种,在 ES2015 中 MacroTask 即指 Task,而 MicroTask 则是指代 Job

(macro)task主要包含:script(整体代码)、setTimeout、setInterval、I/O(包括网络)、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境),

自测发现: macrotask队列也不止一个

image.png

(macro)task主要包含:script(整体代码)、setTimeout、setInterval、I/O(包括网络)、UI交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境),requestAnimationFrame(这个比较特殊,他跟ui render相关)

可以看到:假如做一个实验(先让页面产生一个setTimeout3秒,然后让页面sleep6秒,sleep第4秒去点击页面click),最终鼠标点击事件要在setTimeout之前触发。哪怕setTimeout事件放入队列要早于click.

为了验证自己的结论,我写了这样一个实验:

image.png

发现,dom事件会比setTimeout更早触发。

UI render在事件循环中什么位置?

image.png

可以看到,其实,实际上浏览器渲染的触发并不是每次事件循环都会触发,他会以大约60帧每秒的频率来触发。
如果一次循环内没有触发render,那你的requestAnimation回调也不会执行

复习UI渲染的基本流程:
image.png

Promise的microtask何时放入队列?

Promise加入microtask的顺序 是以then触发时为依据的。且对于已经resolve 的promise,then注册时也会立刻放入 microtask队列。

例子:
image.png

网上常说的Tick是什么意思。何为一个Tick?

Tick是否包含microTask队列清空的这一部分时间? 待定,我觉得看你怎么理解了。

vue官方文档认为把dom更新放到microTask里就称作 “when in the next tick”, 而且vue把 nextTick这个函数名的实现细节实现为microTask,看似都是把nexttick这个概念认为是“执行完当前一个macroTask任务之后,下一个macroTask任务开始之前”

而node中的nextTick,也是放在microTask队列。其microTask是在macroTask执行过程的两个阶段之间执行。它的粒度是“清空一个阶段的macroTask任务之后,执行下一个阶段的macroTask任务之前”

总之,他们nextTick都有点像两个大粒度之间插入的任务。

但我们不要被nextTick名字误导,我觉得可以理解为下一个Tick之前要执行的东西,所以函数名叫nextTick。

关于这个问题,在Stack Overflow上面有个问题:

https://stackoverflow.com/questions/47508649/when-does-the-event-loop-turn

关于 microtask执行 到底是在一个tick(loop turn 或 loop iterator)里面,不是很重要。但是为了能有个标准,我倾向于把它归类在一个tick里面。

image.png

HTML 规范中对 EventLoop的描述:
image.png

浏览器与Node.js的EventLoop机制的区别

  1. 浏览器中是执行一个macroTask+清空microtask队列,依次循环
  2. Node.js 中是执行一个macroTask队列的所有任务+清空microtask队列”; 再取下一种macroTask队列

image.png

原因可能是:浏览器需要尽快做一些UI渲染等操作,所以一个Tick周期的粒度要小(因为UI渲染放在microtask执行之后)
而Node.js主要关注IO回调(fs.readFile(callback))的执行要尽早(因为要尽早给客户端响应内容),所以就以某个类型的macroTask任务批量做完再去执行该阶段的microtask队列(如nextTick)。

Vue.nextTick 的实现原理?

vue数据修改会被watcher记录下来(watcher会调用nextTick放入microtask队列),因此dom树文档对象模型的实际修改是在microtask执行的时候才会修改的。
也正因如此,你要想访问到dom元素的变更,也需要在nextTick里面去获取dom修改后的内容,如果你依旧在microtask执行之前去获取dom内容肯定是拿不到的。

那么,为什么vue要尽量用microtask实现呢?
原因是期望尽快的修改dom,从而尽快让浏览器完成渲染。
image.png

image.png

那为啥不直接修改dom而是要放到微任务队列修改?因为数据驱动的dom更新不可能让你改一下数据他就改一次dom,他要等你把所有数据都改完再批量更新dom

如何理解 JavaScript 会阻塞UI渲染?

首先,我们要搞清楚 UI render发生在什么时候:一般会在microTask执行结束之后,下一个Tick之前。即 UI渲染一般是在两个macroTask之间的间隙。one macroTask —> one microTask queue —>

其次,我们要区分dom操作和UI渲染的关系。dom操作是修改dom对象模型,是实时生效的,ui渲染是指的浏览器根据dom模型重绘UI界面,这个是只发生在js主线程的下一个Tick之前的那个时间点的。
其实就相当于:UI render是等到JavaScript不执行(比如完成一个task之后的空闲间隙)的时候,UI才会渲染。
因此:UI渲染跟JavaScript执行不会同时发生(网上常说的互相阻塞),从表面上可以认为 js执行会阻塞UI渲染。 对开发者的意义是,不要让js线程执行太耗时的任务,否则ui很久看不到dom渲染结果。

UI render 是单独的线程,但跟JavaScript运行互斥
因此,UI render是在microtask执行完 下一个macroTask开始前 的间隙来执行的

dom 事件冒泡时 task 的处理顺序?

例子:
image.png

发现,一个点击事件的处理过程中插入微任务,那么微任务会优先于下一个冒泡执行。

问题原因核心在于三点:

  1. dom event 优先级高于其他macroTask;
  2. microTask会在下一个macroTask执行前清空;
  3. 冒泡事件是多个独立的macroTask

image.png

由于冒泡是多个macroTask任务,所以肯定要执行完所有microTask(Promise.then)之后,再执行上一层冒泡事件的回调。
至于timer回调,自然要等到所有dom event处理完再说咯~

Node 中的EventLoop与浏览器有所不同?

Node 中的 EventLoop 有所不同。* nodejsevent是基于libuv,而浏览器的event loop则在html5的规范中明确定义。
libuv已经对event loop作出了实现,而html5规范中只是定义了浏览器中event loop的模型,具体实现留给了浏览器厂商

EventLoop的一切都在官方文档进行详细解释了:
https://nodejs.org/en/docs/gu...

Node 每个阶段有一个队列;所有phase的认为可以理解为我们所谓的macroTask。

image.png

各个阶段的举例:

timer queue: setTimeout, setInterval
IO queue: fs.readFile…
immediate queue: setImmediat

执行流程图:

image.png

它们当然在从当前阶段到下一个阶段之前尽可能快的运行。不像其他阶段,它们两个没有系统依赖的最大限制,node 运行它们直到两个队列是空的。然而,nextTickQueue 会比 microTaskQueue 有着更高的任务优先级
(图解:整个圈圈叫做一个EventLoop,圈圈里每个节点叫一个phase,每个phase执行之前都要先清掉nextTick和microtask两个queue。另外事件循环应该是在main.js主代码执行完之后启动的,当然如果main.js里有注册microtask和nextTick,那么第一次事件循环的第一个timer也不会优先执行,而是先把nextTick和microtask清空,具体例子看下一页ppt)

Node 中防止 IO 饥饿

由于 process.nextTick会在每个阶段都要清空才会进入下一个阶段,且 nextTick会优先于正常macroTask任务执行。因此假设你处在 IO phrase 阶段,如果你弄了一个 process.nextTick ,那么你的回调会立刻优先得到执行,结果你又搞了一个耗时长的任务,这时你就卡住你的 IO phrase 了。这就是 IO 饥饿。

我们知道, IO 阶段,通常是在处理用户 IO结果,那么饥饿就意味着你的浏览器端的用户可能在苦苦等不到响应了。因此要尽量防止IO饥饿。

解决办法是,尽量用 setImmediate。因为setImmediate处于 IO阶段的下一个阶段,当你执行 setImmediate后,你的回调不会在本次 IO Tick内执行,所以 IO 回调依然会得到正常处理,不会饥饿。

别人家的解释:
image.png

Node什么时候适合用 nextTick呢?

node内部的httpServer就用到了。
image.png

这个地方,我们一般首先创建 server 实例,然后调用 listen方法监听本机端口(这个在底层是个同步系统调用,相当于并没有系统的listening回调)。 而Node为了让我们可以收到一个listen完成的消息,因此,他模拟了一个异步回调,即在nextTick后发射了一个 "listening" 事件。

为啥他不在listen后立刻触发回调呢?因为他认为你此时还没绑定上 listening回调函数。所以这时候就适合用 nextTick 来实现了。

最后,给大家出一个题目。

image.png


sheldon
947 声望1.6k 粉丝

echo sheldoncui