Node 是爲構建實時 Web 應用而誕生的,可讓 JavaScript 運行在服務端的平臺。它具備事件驅動、單線程、異步 I/O 等特性。這些特性不只帶來了巨大的性能提高,有效的解決了高併發問題,還避免了多線程程序設計的複雜性。javascript
本文主要討論的是 Node 中實現異步 I/O 和事件驅動程序設計的基礎——事件循環。html
事件循環是 Node 自身的執行模型,Node 經過事件循環的方式運行 JavaScript 代碼(初始化和回調),並提供了一個線程池處理諸如文件 I/O 等高成本任務。前端
在 Node 中,有兩種類型的線程:一個事件循環線程(也稱爲主循環、主線程、事件線程等),它負責任務的編排;另外一個是工做線程池中的 K 個工做線程(也被稱爲線程池),它專門處理繁重的任務。java
看到這裏,可能有同窗會有疑問,文章開頭說 Node 是單線程的,爲何又存在兩種類型的線程呢? 事實上,Node 的單線程指的是自身 JavaScript 運行環境的單線程,Node 並無給 JavaScript 執行時建立新線程的能力,最終的操做,是經過底層的 libuv 及其帶來的事件循環來執行的。這也是爲何 JavaScript 做爲單線程語言,能在 Node 中實現異步操做的緣由。二者並不衝突。 下圖展現了異步 I/O 中線程的調用模型,能夠看到,對於主線程來講,一直都是單線程執行的。 node
【參考文章:Node.js 探祕:初識單線程的 Node.js — 凌恆】Node 的工做線程池是在 libuv 中實現的,它對外提供了通用的任務處理 API — uv_queue_work
。 linux
dns.lookup()
, dns.lookupService()
fs.FSWatcher()
和顯式調用 API 如 fs.readFileSync()
以外當調用這些 API 時,會進入對應 API 與 C++ 橋接通信的 Node C++ binding 中,從而向工做線程池提交一個任務。爲了達到較高性能,處理這些任務的模塊一般都由 C/C++ 編寫。git
上圖描述了 Node 的運行原理,從左到右,從上到下,Node 被分爲了四層:github
經典的服務器模型有如下幾種:web
事件驅動的實質,是經過主循環加事件觸發的方式來運行程序,這種執行模型被稱爲事件循環。經過事件驅動,能夠構建高性能服務器。數據庫
既然不少平臺都採用了事件驅動的模式,爲何 Ryan Dahl 恰恰選了 JavaScript 呢?在開發 Node 時,Ryan Dahl 曾經評估過多種語言。最終結論爲:C 的開發門檻高,能夠預見不會有太多開發者將其做爲平常的業務開發;Lua 自身已經包含不少阻塞 I/O 庫,爲其構建非阻塞 I/O 庫也沒法改變人們繼續使用阻塞 I/O 庫的習慣;Ruby 的虛擬機性能不夠高。相比之下,JavaScript 比 C 開發門檻低,比 Lua 歷史包袱少,在瀏覽器中已經有普遍的事件驅動應用,V8 引擎又具備超高性能,因而,Javascript 就成爲了 Node 的開發語言。
例如,使用 Node 進行數據庫查詢:
db.query('SELECT * from some_table', function(res) {
res.output();
});
複製代碼
進程在執行到db.query 的時候,不會等待結果返回,而是直接繼續執行後面的語句,直到進入事件循環。當數據庫查詢結果返回時,會將事件發送到事件隊列,等到線程進入事件循環之後,纔會調用以前的回調函數繼續執行後面的邏輯。
固然,這種事件驅動開發模式的弊端也是顯而易見的,它不符合常規的線性思路,須要把一個完整的邏輯拆分爲一個個事件,增長了開發和調試的難度。
下面是多線程阻塞式 I/O 和單線程事件驅動的異步式 I/O 的對比:
【表格來自於 《Node.js 開發指南》 — byvoid】
事件循環的使用場景能夠分爲異步 I/O 和非 I/O 的異步操做兩種。
異步 I/O 的主旨是使 I/O 操做與 CPU 操做分離,從而非阻塞的調用底層接口。如前所述,常見的使用場景有網絡通訊、磁盤 I/O、數據庫訪問等。固然,Node 也提供了部分同步 I/O 方式,如fs.readFileSync
,但 Node 並不推薦用戶使用它們。
非 I/O 的異步操做有定時器,如 setTimeout
、setInterval
,以及 process.nextTick
、setImmediate
、promise
。
setTimeout
、setInterval
、promise
與瀏覽器中的 API 一致,在此再也不贅述。
process.nextTick
的功能是爲事件循環設置一項任務, Node 會在下一輪事件循環時調用 callback。
爲何不能在當前循環執行完這項任務,而要交給下次事件循環呢?咱們知道,一個 Node 進程只有一個主線程,在任什麼時候刻都只有一個事件在執行。若是這個事件佔用大量 CPU 時間,事件循環中的下一個事件就要等待好久。使用 process.nextTick() 能夠把複雜的工做拆散,變成一個個較小的事件。例如:
function doSomething(args, callback) {
somethingComplicated(args);
process.nextTick(callback);
}
doSomething(function onEnd() {
compute();
});
複製代碼
假設 compute()
和 somethingComplicated()
是兩個較爲耗時的函數,調用 doSomething()
時會先執行 somethingComplicated()
,若是不使用 process.nextTick
,會當即調用回調函數,在 onEnd()
中會執行 compute()
,從而會佔用較長 CPU 時間,阻塞其餘事件的處理。而經過 process.nextTick
會把上面耗時的操做拆分至兩次事件循環,減小了每一個事件的執行時間,避免阻塞其餘事件。
另外,須要注意的是,雖然定時器也能將任務拆分至下一次事件循環處理,但並不建議用其代替 process.nextTick(fn)
,由於定時器的處理涉及到最小堆操做,時間複雜度爲 O(lg(n))
,而 process.nextTick
只是把回調函數放入隊列之中,時間複雜度爲 O(1)
,更加高效。
setImmediate()
和 process.nextTick()
相似,也是將回調函數延遲執行。不過 process.nextTick
會先於 setImmediate
執行。由於 process.nextTick
屬於 microtask,會在事件循環之初就執行;而 setImmediate
在事件循環的 check 階段纔會執行。這部分將在下一小節詳述。
Node 中的事件循環是在 libuv 中實現的,libuv 在 Node 中的地位以下圖:
【圖片來自《深刻淺出 Node.js》 — 樸靈】
Node 不是一個從零開始開發的 JavaScript 運行時,它是「站在巨人肩膀上」進行一系列拼湊和封裝獲得的結果。V8(Chrome V8)是 Node 的 JavaScript 引擎,由谷歌開源,以 C++ 編寫,具備高性能和跨平臺的特性,同時也用於 Chrome 瀏覽器。libuv 是專一於異步 I/O 的跨平臺類庫,實際上它主要就是爲 Node 開發的。基於不一樣平臺的異步機制,如 epoll / kqueue / IOCP / event ports,libuv 實現了跨平臺的事件循環。做爲一個在操做系統之上的中間層,libuv 使開發者不用本身管理線程就能輕鬆的實現異步。
下圖是官方文檔中給出的 libuv 結構圖:
能夠看出,除了事件循環外,libuv 還提供了計時器、網絡操做、文件操做、子進程等功能。
在 Node 中,就是直接使用 libuv 中的事件循環:
下面是 libuv 中事件循環的詳細流程:
如上圖所示,libuv 中的事件循環主要有 7 個階段,它們按照執行順序依次爲:
setTimeout
和 setInterval
預約的回調函數;setImmediate
設定的回調以外的回調函數;setImmediate
設定的回調函數;socket.on('close', ...)
之類的回調函數除了 libuv 中的七個階段外,Node 中還有一個特殊的階段,它通常被稱爲 microtask,它由 V8 實現,被 Node 調用。包括了 process.nextTick
、Promise.resolve
等微任務,它們會在 libuv 的七個階段以前執行,並且 process.nextTick
的優先級高於 Promise.resolve
。值得注意的是,在瀏覽器環境下,咱們常說事件循環中包括宏任務(macrotask 或 task)和微任務(microtask),這兩個概念是在 HTML 規範中制定,由瀏覽器廠商各自實現的。而在 Node 環境中,是沒有宏任務這個概念的,至於前面所說的微任務,則是由 V8 實現,被 Node 調用的;雖然名字相同,但瀏覽器中的微任務和 Node 中的微任務實際上不是一個東西,固然,不排除它們間有相互借鑑的成分。
讓咱們經過 libuv 中控制事件循環的核心代碼,近距離觀察這幾個階段。在 libuv v1.x 版本中,事件循環的核心函數 uv_run()
分別在 src/unix/core.c
和 src/win/core.c
中:
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
// 判斷事件循環繼續仍是啓動新一輪循環
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop); // timers 階段
ran_pending = uv__run_pending(loop); // pending 階段
uv__run_idle(loop); // idle 階段
uv__run_prepare(loop); // prepare 階段
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout); // poll 階段
uv__run_check(loop); // check 階段
uv__run_closing_handles(loop); // close 階段
if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have * been invoked when it returns. uv__io_poll() can return without doing * I/O (meaning: no callbacks) when its timeout expires - which means we * have pending timers that satisfy the forward progress constraint. * * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from * the check. */
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
/* The if statement lets gcc compile it to a conditional store. Avoids * dirtying a cache line. */
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
複製代碼
從上述代碼中能夠清楚的看到 timers、pending、idle、prepare、poll、check、close 這七個階段的調用。下面,讓咱們詳細看看這幾個階段。
void uv__run_timers(uv_loop_t* loop) {
struct heap_node* heap_node;
uv_timer_t* handle;
for (;;) {
heap_node = heap_min(timer_heap(loop)); // 最小堆
if (heap_node == NULL)
break;
handle = container_of(heap_node, uv_timer_t, heap_node);
if (handle->timeout > loop->time) // 若是遇到第一個還未到觸發時間的事件回調,退出循環
break;
uv_timer_stop(handle);
uv_timer_again(handle);
handle->timer_cb(handle);
}
}
複製代碼
能夠看出,timers 階段使用的數據結構是最小堆。這個階段會在事件循環的一個 tick 中不斷循環,把超時時間和當前的循環時間(loop -> time
)進行比較,執行全部到期回調;若是遇到第一個還未到期的回調,則退出循環,再也不執行 timers queue 後面的回調。
這裏爲何用最小堆而不用隊列?由於 timeout 回調須要按照超時時間的順序來調用,而不是先進先出的隊列邏輯。因此這裏用了最小堆。
static int uv__run_pending(uv_loop_t* loop) {
QUEUE* q;
QUEUE pq;
uv__io_t* w;
if (QUEUE_EMPTY(&loop->pending_queue))
return 0;
QUEUE_MOVE(&loop->pending_queue, &pq);
while (!QUEUE_EMPTY(&pq)) {
q = QUEUE_HEAD(&pq);
QUEUE_REMOVE(q);
QUEUE_INIT(q);
w = QUEUE_DATA(q, uv__io_t, pending_queue);
w->cb(loop, w, POLLOUT);
}
return 1;
}
複製代碼
這裏使用的是隊列。一些應該在上輪循環 poll 階段執行的回調,若是由於某些緣由不能執行,就會被延遲到這一輪循環的 pending 階段執行。也就是說,這個階段執行的回調都是上一輪殘留的。
這三個階段都由同一個函數定義
void uv__run_##name(uv_loop_t* loop) { \ uv_##name##_t* h; \ QUEUE queue; \ QUEUE* q; \ QUEUE_MOVE(&loop->name##_handles, &queue); \ while (!QUEUE_EMPTY(&queue)) { \ q = QUEUE_HEAD(&queue); \ h = QUEUE_DATA(q, uv_##name##_t, queue); \ QUEUE_REMOVE(q); \ QUEUE_INSERT_TAIL(&loop->name##_handles, q); \ h->name##_cb(h); \ } \ }
複製代碼
這裏用了宏以實現代碼的複用,但同時也下降了可讀性。這部分的邏輯和 pending 階段很像,遍歷隊列,執行回調,直至隊列爲空。
poll 階段較爲複雜,一共有 400+ 行代碼,這裏只截取部分,完整邏輯請自行查看源碼。
void uv__io_poll(uv_loop_t* loop, int timeout) {
// ...
// 處理觀察者隊列
while (!QUEUE_EMPTY(&loop->watcher_queue)) {
// ...
if (w->events == 0)
op = EPOLL_CTL_ADD; // 新增監聽事件
else
op = EPOLL_CTL_MOD; // 修改事件
// ...
for (;;) {
/* See the comment for max_safe_timeout for an explanation of why * this is necessary. Executive summary: kernel bug workaround. */
// 計算好 timeout 以防 uv_loop 一直阻塞
if (sizeof(int32_t) == sizeof(long) && timeout >= max_safe_timeout)
timeout = max_safe_timeout;
nfds = epoll_pwait(loop->backend_fd,
events,
ARRAY_SIZE(events),
timeout,
psigset);
/* Update loop->time unconditionally. It's tempting to skip the update when * timeout == 0 (i.e. non-blocking poll) but there is no guarantee that the * operating system didn't reschedule our process while in the syscall. */
SAVE_ERRNO(uv__update_time(loop));
if (nfds == 0) {
assert(timeout != -1);
if (timeout == 0)
return;
/* We may have been inside the system call for longer than |timeout| * milliseconds so we need to update the timestamp to avoid drift. */
goto update_timeout;
}
if (nfds == -1) {
if (errno != EINTR)
abort();
if (timeout == -1)
continue;
if (timeout == 0)
return;
/* Interrupted by a signal. Update timeout and poll again. */
goto update_timeout;
}
have_signals = 0;
nevents = 0;
assert(loop->watchers != NULL);
loop->watchers[loop->nwatchers] = (void*) events;
loop->watchers[loop->nwatchers + 1] = (void*) (uintptr_t) nfds;
for (i = 0; i < nfds; i++) {
pe = events + i;
fd = pe->data.fd;
/* Skip invalidated events, see uv__platform_invalidate_fd */
if (fd == -1)
continue;
assert(fd >= 0);
assert((unsigned) fd < loop->nwatchers);
w = loop->watchers[fd];
if (w == NULL) {
epoll_ctl(loop->backend_fd, EPOLL_CTL_DEL, fd, pe);
continue;
}
pe->events &= w->pevents | POLLERR | POLLHUP;
if (pe->events == POLLERR || pe->events == POLLHUP)
pe->events |=
w->pevents & (POLLIN | POLLOUT | UV__POLLRDHUP | UV__POLLPRI);
if (pe->events != 0) {
/* Run signal watchers last. This also affects child process watchers * because those are implemented in terms of signal watchers. */
if (w == &loop->signal_io_watcher)
have_signals = 1;
else
w->cb(loop, w, pe->events);
nevents++;
}
}
// ...
}
複製代碼
poll 階段的任務是阻塞以等待監聽事件的來臨,而後執行對應的回調。其中阻塞是有超時時間,在某些條件下超時時間會被置爲 0。此時,就會進入下一階段,而本 poll 階段未執行的回調會在下一循環的 pending 階段執行。
static void uv__run_closing_handles(uv_loop_t* loop) {
uv_handle_t* p;
uv_handle_t* q;
p = loop->closing_handles;
loop->closing_handles = NULL;
while (p) {
q = p->next_closing;
uv__finish_close(p);
p = q;
}
}
複製代碼
close 階段的邏輯很是簡單,就是循環關閉全部的 closing handles,其中的回調被 uv__finish_close
調用。
上面即是 libuv 關於事件循環七個階段的簡單源碼解讀。以前咱們還提到,在 microtask 中,存在着 process.nextTick
和 Promise.resolve
等階段。這部分已經超出了 libuv 的範圍,咱們能夠在 node 源碼中找到它們的調用路徑。
const {
// For easy access to the nextTick state in the C++ land,
// and to avoid unnecessary calls into JS land.
tickInfo,
// Used to run V8's micro task queue.
runMicrotasks,
setTickCallback,
enqueueMicrotask
} = internalBinding('task_queue');
// ...
// Should be in sync with RunNextTicksNative in node_task_queue.cc
function runNextTicks() {
if (!hasTickScheduled() && !hasRejectionToWarn())
runMicrotasks();
if (!hasTickScheduled() && !hasRejectionToWarn())
return;
processTicksAndRejections();
}
// ...
function nextTick(callback) {
if (typeof callback !== 'function')
throw new ERR_INVALID_CALLBACK(callback);
if (process._exiting)
return;
var args;
switch (arguments.length) {
case 1: break;
case 2: args = [arguments[1]]; break;
case 3: args = [arguments[1], arguments[2]]; break;
case 4: args = [arguments[1], arguments[2], arguments[3]]; break;
default:
args = new Array(arguments.length - 1);
for (var i = 1; i < arguments.length; i++)
args[i - 1] = arguments[i];
}
if (queue.isEmpty())
setHasTickScheduled(true);
queue.push(new TickObject(callback, args));
}
複製代碼
能夠看到,nextTick 就是向 queue 隊列壓入 callback,而後 nextTick
調用後獲得的隊列被 runNextTick
使用,觸發 runMicrotasks
函數,這個函數經過 internalBiding
綁定至 node_task_queue.cc
中的同名函數。最終,會觸發 V8 中的 microtask 隊列處理。
同理,promise 的相應調用路徑能夠從 github.com/nodejs/node… 中追蹤獲得,篇幅有限,就再也不贅述了。
瀏覽器中的事件循環是 HTML 規範中制定的,由不一樣瀏覽器廠商自行實現;而 Node 中則由 libuv 庫實現。所以,瀏覽器和 Node 中的事件循環在實現原理和執行流程上都存在差別。
在瀏覽器中,JavaScript 執行爲單線程(不考慮 web worker),全部代碼均在主線程調用棧完成執行。當主線程任務清空後纔會去輪循任務隊列中的任務。
異步任務分爲 task(宏任務,也能夠被稱爲 macrotask)和 microtask(微任務)兩類。關於事件循環的權威定義能夠在 HTML 規範文檔中查到:html.spec.whatwg.org/multipage/w…
當知足執行條件時,task 和 microtask 會被放入各自的隊列中,等待進入主線程執行,這兩個隊列被稱爲 task queue(或 macrotask queue)和 microtask queue。
setTimeout
、setInterval
、I/O
、UI renderpromise
、Object.observe
、MutationObserver
不過,正如規範強調的,這裏的 task queue 並不是是隊列,而是集合(sets),由於事件循環的執行規則是執行第一個可執行的任務,而不是將第一個任務出隊並執行。
詳細的執行規則能夠在 html.spec.whatwg.org/multipage/w… 查詢,一共有 15 個步驟。
能夠將執行步驟不嚴謹的概括爲:
進一步概括,就是:一個宏任務,全部微任務;一個宏任務,全部微任務...
Node 中的事件循環流程已經在前面詳述(參見_事件循環的執行機制_一節),這裏就再也不贅述了。一圖以蔽之:
下面,讓咱們看一些容易產生誤解的狀況:
process.nextTick
形成的 starve 現象const fs = require('fs');
function addNextTickRecurs(count) {
let self = this;
if (self.id === undefined) {
self.id = 0;
}
if (self.id === count) return;
process.nextTick(() => {
console.log(`process.nextTick call ${++self.id}`);
addNextTickRecurs.call(self, count);
});
}
addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
console.log('omg! file read complete callback was called!');
});
console.log('started');
複製代碼
在這段代碼中,因爲遞歸調用了 process.nextTick
,setTimeout
、setImmediate
以及文件 I/O 的回調將永遠不會獲得執行。由於執行 libuv 中七個階段前,會清空 microtask 中的任務。所謂的清空是指,執行完 microtask 隊列中已有的任務以後,準備執行 libuv 中的任務以前,會再次確認 microtask 中的任務是否爲空,若還有任務,會繼續執行。因爲遞歸調用了 process.nextTick
,會不斷往 microtask 中添加任務,從而形成了這種其餘隊列的飢餓(starve)現象。
固然,在 Node v0.12 以前,存在 process.maxTickDepth
屬性,用於限制 process.nextTick
的執行深度。可是在 v0.12 以後,出於某些緣由,這個屬性被移除了。此後只能建議開發者避免寫出這種代碼。
執行結果爲:
started
process.nextTick call 1
process.nextTick call 2
process.nextTick call 3
...
複製代碼
setTimeout
vs setImmediate
setTimeout
和 setImmediate
的回調哪一個會先執行呢?有同窗可能會說,我知道啊,setTimeout
屬於 timers 階段,setImmediate
屬於 check 階段,因此會先執行 setTimeout
。錯~,正確答案是,咱們沒法保證它們的前後順序。
setTimeout(function() {
console.log('setTimeout')
}, 0);
setImmediate(function() {
console.log('setImmediate')
});
複製代碼
屢次執行這段代碼,能夠看到,咱們會獲得兩種不一樣的輸出結果。
這是由 setTimeout
的執行特性致使的,setTimeout
中的回調會在超時時間後被執行,可是具體的執行時間卻不是肯定的,即便設置的超時時間爲 0。因此,當事件循環啓動時,定時任務可能還沒有進入隊列,因而,setTimeout
被跳過,轉而執行了 check 階段的任務。
換句話說,這種狀況下,setTimeou
和 setImmediate
不必定處於同一個循環內,因此它們的執行順序是不肯定的。
事情到這裏並無結束:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0);
setImmediate(() => {
console.log('immediate')
})
});
複製代碼
對於這種狀況,immediate 將會永遠先於 timeout 輸出。
讓咱們捋一遍這段代碼的執行過程:
fs.readFile
,開始文件 I/OsetImmediate
的回調被加入 immediates 隊列中setImmediate
註冊的回調函數setTimeout
註冊的回調函數promise
vs process.nextTick
promise
和 process.nextTick
組合使用的狀況比較好理解,nextTick
會優於 promise
執行,microtask 會優於 7 個階段執行,在執行 7 個階段前,會進一步確認 microtask 隊列是否爲空。例如:
Promise.resolve().then(() => console.log('promise1 resolved'));
Promise.resolve().then(() => console.log('promise2 resolved'));
Promise.resolve().then(() => {
console.log('promise3 resolved');
process.nextTick(() => console.log('next tick inside promise resolve handler'));
});
Promise.resolve().then(() => console.log('promise4 resolved'));
Promise.resolve().then(() => console.log('promise5 resolved'));
setImmediate(() => console.log('set immediate1'));
setImmediate(() => console.log('set immediate2'));
process.nextTick(() => console.log('next tick1'));
process.nextTick(() => console.log('next tick2'));
process.nextTick(() => console.log('next tick3'));
setTimeout(() => console.log('set timeout'), 0);
setImmediate(() => console.log('set immediate3'));
setImmediate(() => console.log('set immediate4'));
複製代碼
執行結果將爲:
next tick1
next tick2
next tick3
promise1 resolved
promise2 resolved
promise3 resolved
promise4 resolved
promise5 resolved
next tick inside promise resolve handler
set timeout
set immediate1
set immediate2
set immediate3
set immediate4
複製代碼
本文介紹了 Node 中事件循環的做用、執行機制以及與瀏覽器中事件循環的區別。事件循環是事件驅動編程模式的基礎,經過事件驅動模式,能夠構建異步非阻塞的高性能服務器,很是適合 I/O 密集型 web 應用。在 Node 中,事件循環是由 libuv 實現的,uv_run()
函數中定義了事件循環的七個階段。在 HTML 規範中,一樣也對事件循環作了定義,並由各個瀏覽器廠商各自實現,實現原理和運行機制都與 Node 中的事件循環有必定的區別。同時,因爲 Node 是在不斷迭代的,目前最新已經到了 v12.6.0 版本,不一樣版本間也會存在必定差別,因此本文也沒法涵蓋關於事件循環的全部內容。當咱們討論關於事件循環的具體問題時,可能會發現許多與以前經驗不符的現象。對於這些問題,首先要肯定 Node 版本;而後,多動手實驗、多看源碼、多讀規範,造成本身的正確認識。
因爲本人水平有限,若有紕漏或建議,歡迎留言。若是以爲不錯,歡迎點贊和關注「海致前端」公衆號。我會保持一週一篇乾貨分享,歡迎你來一塊兒交流。感謝你的閱讀,讓咱們一塊兒進步。