運算子算是中間插入的章節介紹,雖然也很重要,但可能覺得有點突兀。
原本順序上講完函式,閉包,再來應該會接上同步/異步,總之現在把順序接回來,先來講事件循環,下一篇就會接上同步/異步的內容。
因為後面會大量講到同步和異步兩個詞,我們先定義同步異步,但相關的特性解說留待相關段落。
同步(Sync)操作指的是定義的操作會一個接一個,依序地完成。每個操作都需等待前個操作完成才能接續執行。
非同步(Async)又稱異步,指的是和同步不一樣的行為,非同步操作無需等待前一個操作,可以略過目前操作,直到該被執行的時間點才執行。
那講同步異步前,為什麼要先講事件循環,事件循環的重要性是什麼?
首先, JS 是一個單執行緒(single-threaded)的語言,這意味著 JS 中永遠不會有兩個語句同時被執行。以印出 new Date()
這樣印出當下時間的函式呼叫來舉例,你永遠無法透過兩次獨立的 new Date()
得到兩個完全一樣的 timestamp
,無論你透過如何的異步包裝。
就因為 JS 本質上是單執行序的語言,一個時間點,他就是只能處理一段程式碼,一個語句。
所以如果有一個語句需要長時間的執行,它就會讓後面語句被阻塞(Blocking)。
let startTime = new Date();
console.log(startTime);
for(let i = 0; i < 1000000000; i++){
}
console.log(`It takes time! ${new Date() - startTime} ms`);
依據你的電腦性能,上面一個非常大的迴圈會佔用一定的時間。
形成了阻塞,像是呼叫遠端 API,在等待遠端 API 執行完畢前,這也是一種阻塞。
阻塞發生時,同個網頁上的其他 JS 都無法執行,對使用者來說,就好像網頁卡住了。
事件循環就是用於管理事件執行順序的機制,同時事件循環機制會基於執行環境(Execution Context),比如執行於瀏覽器上,事件循環機制就會依照瀏覽器的規範執行。
實際上,在 ES 6 以前,ECMAScript 規範中完全沒有提到關於異步同步的相關語法,直到 ES 6 後才陸續有 Promise
,ES 2017 的 asnyc/await
被引入。ES 6 以前,儘管有 callback
,他更像是在被要求的時間點執行你定義的程式碼,用於預防阻塞 I/O 的情況發生。
那 JS 是怎麼決定順序的呢?我們就要來看呼叫堆疊。
這個我比較沒找到繁體中文的統一稱呼方法,可能大家平常都直接叫 Call Stack
(我都這樣叫啦哈哈),執行堆疊,調用棧都是指它。
有讀過資料結構的人應該知道,堆疊是一種數據結構,特色是先進後出(FILO)。
我們來看一段程式碼。
function a(){
console.log('here is start of a');//1
b();
console.log('here is a');//4
}
function b(){
c();
console.log('here is b');//3
}
function c(){console.log('here is c');}//2
語句的執行順序會是 1,2,3,4。
當程式遇到函式呼叫,則放入堆疊,如果函式中有其他函式,則持續放往堆疊內,而最後從堆疊頂端開始執行。
這邊我們以陣列表示堆疊,左方為先放入堆疊的內容:
上面的例子會是 a
放入堆疊 -> 堆疊中為 [a]
console.log()
放入堆疊 -> 堆疊中為 [a, console.log]
執行 console.log()
-> 堆疊中為 [a]
把 b
放入堆疊 -> 堆疊中為 [a,b]
把 c
放入堆疊 -> 堆疊中為 [a,b,c]
執行 c
-> 堆疊中為 [a,b]
執行 b
-> 堆疊中為 [a]
執行 a
-> 堆疊為空
這樣就能解釋上面的執行順序的 1,2,3,4。
這就是 Call Stack 的大致概念。
這邊借用 MDN 的圖幫助我們更全面的了解事件循環的其他要角
圖中可以看到 Stack
中是寫 Frame
而不是 function
,是因為 Frame
實際上代表著函式相關的調用上下文(Context),像是傳入參數、傳入參數、變數、返回位址、this
指向...等等,上面說明是用比較簡略的概念來說明。
所以除了 Stack
外,接下來還要介紹 Heap
和 Queue
。
在事件循環的上下文中,可以簡單的理解就是個分配的記憶體區域,用於儲存物件/函式的地方。
通常也指 Marco Queue
,Callback Queue
,任務佇列
。
佇列與堆疊不同,是先進先出(FIFO)的資料結構。
事件循環基於執行環境,以瀏覽器來說,提供了一些 API(Web APIs),如 DOM
,AJAX
,Timeout
等等,當我們執行這些 API,就會執行非同步操作,即呼叫所謂的 callback function
。
依據 function
本身來源和等待機制的不同,在觸發條件達到前(以 setTimeout
為例,就是時間還沒到之前),他們會被各自機制管理,而一旦觸發條件達到了(以 setTimeout
為例,就是時間到了),則事件循環會把這些 callback function
放入工作佇列中,這也是為什麼會有人稱呼為 Callback Queue
。
那什麼時候工作佇列的函式會被執行?依據事件循環機制,直到目前 Call Stack
一度為空為止,才會開始處理工作佇列裡的函式。而因為佇列的資料結構特性,工作佇列中的任務處理會依先進佇列先處理的順序進行。
自 ECMAScript 中的 Promise
語法被引入後,微工作佇列的規範更明確,因為 .then()
就是在微工作佇列中進行。
可以理解為某些細小的工作會被安排到這個佇列中,且這個佇列的執行優先序優於一般的工作佇列。
Marco Queue
的概念也是相對於這個佇列而來的。
所以工作佇列如果加進微工作佇列的概念,則實際執行工作佇列的函式的時間點則變成 1.Call Stack
為空,2. 執行微工作佇列直到微工作佇列為空,3. 執行工作佇列。
探討 JS 非同步的時候有個常用的例子,我稍微改一下它:
console.log('1: Start of script'); // 進入 Call Stack,立即執行
setTimeout(() => {
console.log('4: Macrotask (setTimeout)'); // 進入 Task queue,稍後執行
}, 0);
Promise.resolve().then(() => {
console.log('3: Microtask (Promise.then)'); // 進入 Microtask queue,優先於 Macrotask
});
console.log('2: End of script'); // 進入 Call Stack,立即執行
上面的例子中有個 等待時間為 0 的 setTimeout
函式,如果邏輯上來說,執行語句應該上到下,且等待時間為 0 則應該立即執行,則我們預期印出順序應為1,4,3,2
的順序。
但經過事件循環,我們應該會知道,setTimeout
會導致 console.log('Callback')
進入工作佇列,而 .then()
實際上會進入微工作佇列,執行順序應為 Call Stack
> Microtask Queue
> Task Queue
,所以印出來的順序會是 1,2,3,4
。
所以實際上的 setTimeout(...,0)
並不會真的即刻被執行,同理,透過這種方式的 callback,只能說近乎在該指定時間後被呼叫,可以保證只晚不早(因為早的話還沒打觸發條件,還沒放進工作佇列)。
如果看懂了這個例子的發生原因,可以說對事件循環和 JS 的同異步有了最基本的概念,足夠讓我們在下一篇進入更多的同異步探討了。