本文是我翻譯《JavaScript Concurrency》書籍的第二章 JavaScript運行模型,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發編程方面的實踐。 完整書籍翻譯地址: "https://github.com/yzsunl ...
本文是我翻譯《JavaScript Concurrency》書籍的第二章 JavaScript運行模型,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發編程方面的實踐。
完整書籍翻譯地址:https://github.com/yzsunlei/javascript_concurrency_translation 。由於能力有限,肯定存在翻譯不清楚甚至翻譯錯誤的地方,歡迎朋友們提issue指出,感謝。
本書第一章我們探討了JavaScript併發的一些情況。一般來說,在JavaScript應用程式中處理併發只是一件小事。有很多想編寫併發JavaScript代碼的,提出的一些解決辦法並不是非常規範的。有很多回調,並且用到的所有這些回調就足夠讓人發瘋了。我們還看了下我們編寫的併發JavaScript代碼如何改變現有的組件。Web workers已經開始成熟,javascript語言的併發結構才剛剛引入。
JavaScript語言和運行時環境已經是定下來的。我們需要在設計層面考慮併發,而不是在編寫代碼時。併發應該是預設的。這說起來很容易,但很難做到。在本書中,我們將探討JavaScript併發所提供的所有特性,以及我們如何利用好它們作為設計工具的優勢。但是,我們在這樣做之前,需要深入瞭解JavaScript究竟是怎樣運行的。這些是設計併發應用程式的必要知識,因為我們需要確切地知道在選擇一種併發機制時會發生什麼。
在本章中,我們將從瀏覽器環境開始,看看代碼運行所涉及的所有子系統 - 例如JavaScript解釋器,任務隊列和DOM本身。然後我們將介紹一些代碼示例,這些代碼將揭示運行我們的代碼時真正發生的事情。最後我們將通過討論在這個模型中面臨的挑戰來結束本章。
一切都是任務
當我們訪問網頁時,會在瀏覽器中為我們創建整個環境。這個環境有幾個子系統,我們瀏覽的網頁外觀和行為都應該遵循萬維網聯盟(W3C)規範。任務是Web瀏覽器中的一個基本抽象。任何發生的事情要麼是一個任務本身,要麼是較大任務的一部分。
如果您正在閱讀任何W3C規範,他們使用術語“用戶代理”而不是“Web瀏覽器”。在99.9%的情況下,我們正在閱讀的內容
符合主流的瀏覽器提供商。
在本節中,我們將介紹這些環境的主要組件,以及任務隊列和事件迴圈如何在這些組件之間進行通信,以實現網頁的整體外觀和交互行為。
大體的介紹
這裡先介紹一些術語,它們將在本章的各個部分進行講解:
• 執行環境:每當打開新網頁時,都會創建一個容器。這是一個豐富複雜的環境,它擁有我們的JavaScript代碼將與之交互的一切。它也可以作為沙箱 - 我們的JavaScript代碼無法訪問環境之外的東西。
• JavaScript解釋器:這是負責解析和執行JavaScript源代碼的組件。瀏覽器的工作是使用全局變數來擴充解釋器,例如window和XMLHttpRequest。
• 任務隊列:只要發生一些事情,就會有任務排隊。一個執行環境至少有一個這樣的隊列,但通常它有幾個隊列。
• 事件迴圈:執行環境具有單一的事件迴圈,負責為所有任務隊列提供服務。只有一個事件迴圈,因為只有一個線程。
Web瀏覽器中創建的執行環境如下示圖。任務隊列是瀏覽器中發生的任何事情的入口。例如,一個任務可以用於通過將腳本傳遞給JavaScript解釋器來執行腳本,而另一個任務用於渲染DOM更新。現在我們將深入探究這個環境的組成部分。
執行環境
也許Web瀏覽器執行環境中最具啟發性的方面是我們的JavaScript代碼相對於執行它的解釋器所占的部分要小。我們的代碼可以看作只是大機器中的一個齒輪。在這些環境中肯定會發生很多事情,因為瀏覽器實現的平臺有大量的用途。這不僅僅包括在屏幕上渲染元素,或是使用樣式屬性增強這些元素。DOM本身類似於微平臺,就像網路設施,文件訪問,安全性等一樣。所有這些部分對於網站運行的網路環境以及相關的應用程式都至關重要。
在併發環境中,我們最感興趣的是將所有這些組件組合在一起運行的機制。我們的應用程式主要用JavaScript編寫,解釋器知道如何解析和運行它。但是,這最終如何轉化為頁面上的視覺變化?瀏覽器的網路組件如何知道發出HTTP請求,以及響應完成後如何調用JavaScript解釋器?
這些不同組件之間的協調限制了我們在JavaScript中的使用併發。這些限制是必要的,因為沒有它們,開發Web應用程式將變得相當複雜。
事件迴圈
一旦執行環境準備好了,事件迴圈就是首先要啟動運行的組件之一。它的工作是為環境中的一個或多個任務隊列提供服務。瀏覽器提供商可以根據需要自由實現隊列,但必須至少有一個隊列。如果他們願意的話,瀏覽器可以將每個任務放在一個隊列中,以同等的優先順序執行每項任務。這樣做的問題意味著如果隊列被堆積,那麼有些必須優先執行的任務(例如滑鼠或鍵盤事件)就會出現問題了。
在實踐中,有幾個隊列是有意義的,如果沒有其他原因,除了按優先順序分隔任務。這一點很重要,因為只有一個控制線程 - 意味著只有一個CPU - 來處理這些隊列。以下是通過不同級別的優先順序為幾個隊列提供服務的事件迴圈:
即使事件迴圈與執行環境一起啟動,這並不意味著它總是要處理它的任務。如果總是要處理任務,那麼實際應用程式永遠不會有任何CPU空閑時間。事件迴圈將等待更多任務,優先順序高的隊列首先得到服務。例如,使用前面這張圖中應用的隊列,將始終首先為交互隊列提供服務。即使事件迴圈正在處理渲染隊列任務,如果互動式任務排隊,事件迴圈將在處理渲染任務之前恢復處理此任務。
任務隊列
任務隊列的概念對於理解Web瀏覽器的工作方式至關重要。瀏覽器這個術語實際上是有誤導性的。我們在早期的一些網站中使用它們瀏覽靜態網頁。現在,大型複雜的應用程式在瀏覽器中運行 - 它實際上更像是一個Web平臺。為它們提供服務的任務隊列和事件迴圈可能是處理這麼多組件的最佳設計。
我們在本章前面看到,從執行環境的角度來看,JavaScript解釋器以及它解析和運行的代碼實際上只是一個黑盒子。事實上,調用解釋器本身就是一項任務,而且反映了JavaScript的運行直到完成的特性。許多任務涉及JavaScript解釋器的調用,如下所示:
這些事件中的任何一個 - 用戶單擊元素,頁面中載入的腳本或來自先前API調用的數據返回瀏覽器 - 創建調用JavaScript解釋器的任務。它告訴解釋器運行一段特定的代碼,並且它將繼續運行直到完成。這是JavaScript的運行直到完成的特性。接下來,我們將深入探究這些任務創建的執行上下文。
執行上下文
現在是時候看看JavaScript解釋器本身 - 這是當事件發生並且代碼需要運行時從其他瀏覽器組件接管的組件。在解釋器中,我們會找到一堆上下文,但總有一個活躍的JavaScript上下文。這與堆棧控制活動上下文的許多編程語言類似。
將活動上下文視為我們JavaScript代碼中正在發生的事件的快照。使用堆棧結構是因為活動上下文可以被隨時更改為其他內容,例如調用函數時。發生這種情況時,會將新快照推送到堆棧,成為活動上下文。當它運行完成時,它會從堆棧中彈出,將下一個上下文保留為活動上下文。
在本節中,我們將瞭解JavaScript解釋器如何處理上下文切換,以及管理上下文堆棧的內部任務隊列。
維護執行狀態
JavaScript解釋器中的上下文堆棧不是靜態結構 - 它在不斷變化。在這個堆棧的整個生命周期中發生了兩件重要的事情。首先,在堆棧的頂部,我們有活動的上下文。這是解釋器在其指令中移動時當前執行的代碼。這裡有張示圖說明JavaScript執行上下文堆棧的概念,活動上下文始終位於頂部:
調用堆棧的另一個重要事情是當活動上下文停用時為其記錄狀態。例如,假設在幾條語句之後,func1()調用func2()。此時,在調用func2()之後,直接將上下文添加到該位置。然後,它被替換為新的活動上下文 - func2()。完成後,重覆該過程,func1()再次成為活動上下文。
這種上下文切換髮生在我們的整個代碼執行過程中。例如,有一個全局上下文,它是我們代碼執行的入口,函數本身具有自己的上下文。最近JavaScript還有一些新增的語言特性,它們也有自己的上下文,如模塊和生成器。接下來,我們將看看負責創建新執行上下文的任務隊列。
工作隊列
工作隊列類似於我們之前查看的任務隊列。不同之處在於工作隊列特定於JavaScript解釋器。也就是說,它們被封裝在解釋器中 - 瀏覽器不直接與這些隊列交互。但是,當瀏覽器調用解釋器時,例如,響應於載入的腳本或事件回調任務時,解釋器將創建新的工作。
JavaScript解釋器中的工作隊列實際上比用於協調所有Web瀏覽器組件的任務隊列簡單得多。只有兩個必要的隊列。一個用於創建新的執行上下文堆棧(調用堆棧)。另一個特定於promise解析回調函數。
我們將在下一章深入探討promise解析回調的工作原理。
鑒於這些內部JavaScript工作隊列的職責限制,有些人可能得出結論:它們是不必要的 - 過度設計的行為。事實並非如此,因為雖然今天在這些工作中發現的它們職責有限,但是工作隊列設計讓語言更容易地擴展和改進。特別是,在考慮未來語言版本中的新併發結構時,工作隊列機制是很有意義的。
使用定時器創建任務
到目前為止,在本章中,我們已經瞭解了Web瀏覽器環境的所有內部組件,以及JavaScript解釋器在此環境中的位置。所有這些與將併發原則應用於我們的代碼有什麼關係?通過瞭解底層發生的事情,我們可以更深入地弄明白運行代碼塊時發生的情況。特別是,我們知道相對於其他代碼塊發生了什麼; 時間排序是一個至關重要的併發屬性。
這就是說,讓我們實際寫一些代碼。在本節中,我們將使用定時器將任務顯式添加到任務隊列。我們還將瞭解JavaScript解釋器何時何地跳轉並開始執行我們的代碼。
使用setTimeout()
所述的setTimeout()函數是在任何JavaScript代碼定住。它用於在將來的某個時刻執行代碼。JavaScript新手經常被setTimeout()函數弄迷糊,因為它是一個定時器。設定在未來的某個時間點,比如說3秒後,將調用回調函數。當我們調用setTimeout()時,我們將獲得一個timer ID值,稍後可以使用clearTimeout()清除它。以下是setTimeout()的基本用法:
//創建一個可以調用我們函數的定時器比如300ms。
//我們可以使用console.time()和console.timeEnd()函數看到它實際需要多長時間。
//
//這通常是301ms左右,根本不是用戶可以註意到的,
//但調度函數調用得到的準確性並不可靠。
var timer = setTimeout(() => {
console.timeEnd('setTimeout');
}, 300);
console.time('setTimeout');
這是JavaScript新手常常誤解的部分;這個定時器只能儘量保證時間準確性。我們使用setTimeout()時唯一的保證是我們的回調函數永遠不會比我們傳遞它的時間更早的被調用。因此,如果我們說在300毫秒內調用此函數,它將永遠不會在275毫秒內調用它。一旦300毫秒過去,新任務就會排隊。如果在此任務之前沒有任何排隊等待,則回調會按時運行。即使有一些事情在它前面的隊列,其實也不容易被察覺 - 它似乎在正確的時間運行。
但正如我們所見,JavaScript是單線程運行的。這意味著一旦JavaScript解釋器啟動,它就不會停止直到它完成; 即使有任務等待定時器事件回調。因此,即使我們要求定時器在300毫秒時執行回調,它完全有可能會在500毫秒時執行。我們來看一個例子來看看為什麼它是可能的:
//註意,這個函數會消耗CPU ...
function expensive(n = 25000) {
var i = 0;
while(++ i <n * n) {}
return i;
}
//創建一個定時器,回調使用console.timeEnd()看看我們等了多久。
//是否是真的等了我們期待的300ms。
var timer = setTimeout(() => {
console.timeEnd('setTimeout');
}, 300);
console.time('setTimeout');
//這需要幾秒鐘的時間在CPU上完成。
//同時任務已排隊等待運行我們的回調函數,
//但事件迴圈無法獲得到那個任務隊列,直到expensive()完成。
expensive();
使用setInterval()
setInterval()函數是setTimeout()函數的姐妹。正如其名,它接受一個回調函數,以固定的時間間隔進行調用執行。事實上,setInterval()函數採用了和setTimeout()完全相同的參數。唯一的區別是在於它會不斷的調用執行回調函數的功能,每隔X毫秒,直到該計時器被使用clearInterval()函數清除。
當我們想要一遍又一遍地調用相同的函數時,這個函數很實用。例如,如果我們輪詢API介面,則setInterval()是一個很好的候選解決方案。但是,請記住,回調函數的調用是固定的。也就是說,一旦我們用1000毫秒調用setInterval(),沒有清除定時器就沒有改變1000毫秒。對於間隔需要是動態的場景,使用setTimeout()可以更好的實現。回調函數中設定下一個間隔,允許間隔是動態的。例如,通過增加間隔時間來不斷地輪詢API。
在我們上次查看的setTimeout()示例中,我們看到了運行JavaScript代碼如何破環事件迴圈。也就是說,它阻止事件迴圈使用我們的回調函數來調用JavaScript解釋器的任務。這允許我們將代碼執行推遲到將來的某個點,但沒有準確的保證。讓我們看看當我們使用setInterval()計劃任務時會發生什麼。還有一些後續運行的JavaScript代碼塊:
//一個跟蹤我們正在進行第幾次執行的計數器。
var cnt = 0;
//設置interval定時器。回調會記錄調度回調函數的次數。
var timer = setInterval(() => {
console.log('Interval', ++cnt);
}, 3000);
//阻塞CPU一段時間。當我們不再阻塞CPU時,調用第一個interval,
//如預期的那樣。然後第二個,如果預料到的話。依次類推
//因此,當我們阻止回調任務時,我們就是阻止執行下一個間隔的任務。
expensive(50000);
響應DOM事件
在上一節中,我們瞭解瞭如何延時運行JavaScript代碼。這是由其他JavaScript代碼明確完成的。大多數情況下,我們的代碼會響應用戶交互而直接運行。在本節中,我們將介紹一些公共介面,不僅由DOM事件使用的,還包括網路事件和Web worker事件等。我們還將研究一種處理大量類似事件的技術 - 稱為去抖。
事件對象
事件對象介面被許多瀏覽器組件所使用,包括DOM元素。這是我們如何分發事件到元素以及監聽到的事件和執行一個回調函數作為響應。它實際上是一個非常簡單的交互,很容易被捕捉到。這是至關重要的,因為許多不同類型的組件使用相同的介面進行事件管理。我們將會通過這本書進一步看到。
上一節中使用的定時器的回調函數與執行EventTarget事件是相同的任務隊列機制。如果事件被觸發,一個使用對應的回調函數調用JavaScript解釋器的任務將被加入任務隊列。在這裡使用setTimeout()所面臨的限制同樣會出現。下麵是當長時間運行的JavaScript代碼阻塞用戶事件時的任務隊列的示圖:
除了將偵聽器函數附加到對用戶交互做出反應的事件目標上之外,我們還可以手動觸發這些事件,如下代碼所示:
//通用事件回調,記錄事件時間戳。
function onClick(e) {
console.log('click', new Date(e.timeStamp));
}
//我們將要用作事件的元素目標對象。
var button = document.querySelector('button');
//設置我們的 onClick 函數作為此目標上 click 事件的事件偵聽器。
button.addEventListener('click', onClick);
//除了用戶點擊按鈕外,還有EventTarget介面讓我們手動調度事件
button.dispatchEvent(new Event('click'));
最好是儘可能命名一下回調中使用的函數。這樣,當我們的代碼出錯時,跟蹤查找問題就容易得多。使用匿名函數並不是不可以,它只是在追蹤問題時會耗費更多的時間。另一方面,箭頭函數更簡潔,並且具有更大的綁定靈活性。選擇使用它是明智的。
控制事件頻率
用戶交互事件的一個挑戰是在很短的時間內可能有很多這樣的事件。例如,當用戶在屏幕上移動滑鼠時,會觸發數百個事件。如果我們有監聽這些事件,任務隊列將很快被填滿,用戶體驗也就將會很糟糕。
即使我們確實創建有高頻事件(例如滑鼠移動)的事件監聽器,我們也沒必要響應所有這些事件。例如,如果在1-2秒內發生了150次滑鼠移動事件,我們只關心最後一次移動 - 滑鼠指針的最近位置。也就是說,使用我們的事件回調代碼調用JavaScript解釋器的次數比需要的多149倍。
為了處理這種高頻事件場景,我們可以使用一種稱為去抖的技術。去抖函數意味著如果在給定時間範圍內連續多次調用它,則實際僅使用最後一個調用,並忽略所有先前的調用。讓我們來看看下麵例子是如何實現的:
//跟蹤“mousemove”事件的數量。
var events = 0;
//debounce()將提供的 func 來限制調用它的頻率。
function debounce(func, limit) {
var timer;
return function debounced(...args) {
//移除所有現有的計時器
clearTimeout(timer);
//在“limit”毫秒後調用函數
timer = setTimeout(() => {
timer = null;
func.apply(this, args);
}, limit);
};
}
//記錄有關滑鼠事件的一些信息, 並記錄事件總數。
function onMouseMove(e) {
console.log(`X ${e.clientX} Y ${e.clientY}`);
console.log('events', ++events);
}
//將輸入的內容記錄到文本輸入中
function onInput(e) {
console.log('input', e.target.value);
}
//使用debounced監聽 mousemove 事件
//onMouseMove函數的版本。要是我們
//沒有使用debounce()包裝此回調。
window.addEventListener('mousemove', debounce(onMouseMove, 300));
//使用去抖動版本監聽 input 事件
//onInput()函數,以防止每次按鍵觸發事件。
document.querySelector('input').addEventListener('input', debounce(onInput, 250));
使用去抖技術來避免給CPU帶來很多沒必要的工作量。通過忽略149個事件,我們保存了正確的值,否則大量執行CPU指令並且得到的不是正確值。我們還節省了在這些事件處理程式中可能發生的各種類型的記憶體分配。
JavaScript的併發原則在“第一章,JavaScript併發簡介?”結尾時已經講過了,本書後面部分將通過代碼示例來說明它。
響應網路事件
前端應用程式的另一個重要部分是網路交互,獲取數據,發出命令等。由於網路通信本質上是非同步進行的,因此我們必須依賴事件 - EventTarget介面來確保準確性。
我們首先看一下通用機制,它將我們的回調函數與請求掛起並從後端獲取響應數據。然後,我們將看看如何嘗試同步多個網路請求創建一個看似不太可能的併發場景。
發出請求
為了與網路進行交互,我們創建了一個XMLHttpRequest的實例。然後我們告訴它我們要做的請求類型 - GET或POST和請求介面。這些請求對象還實現了EventTarget介面,以便我們可以監聽從網路返回的數據。以下是此代碼的示例:
//回調成功的網路請求,解析JSON數據
function onLoad(e) {
console.log('load', JSON.parse(this.responseText));
}
//回調失敗的網路請求,記錄錯誤信息
function onError() {
console.error('network', this.statusText || '未知錯誤');
}
//回調已取消的網路請求,記錄警告信息
function onAbort() {
console.warn('request aborted ...');
}
var request = new XMLHttpRequest();
//針對每種情況,使用 EventTarget 綁定不同的事件監聽
request.addEventListener('load', onLoad);
request.addEventListener('error', onError);
request.addEventListener('abort', onAbort);
//發送 api.json介面 的 GET 請求。
request.open('get', 'api.json');
request.send();
我們可以在這裡看到網路請求有許多可能的狀態。成功狀態是伺服器響應我們需要的數據,並且我們能夠將其解析為JSON。錯誤狀態是出現問題時,可能伺服器無法訪問。我們在這裡關註的最後的一個狀態是請求被取消或中止。這意味著我們不再關心成功狀態,因為我們的應用程式中的某些內容在請求執行時發生了變化。例如,用戶跳轉到其它地方。
雖然之前的代碼很容易使用和理解,但情況並非總是如此。我們現在看到的只是單個請求和一些回調。而我們的應用程式很少由單個網路請求組成的。
協調請求
在上一節中,我們看到了與XMLHttpRequest實例的基本交互與發出網路請求的例子。當有多個請求時,挑戰就來了。大多數情況下,我們會發出多個網路請求,以便我們得到渲染UI組件所需的所有數據。而來自後端的響應將在不同時間返回,並且還可能彼此依賴。
不管怎樣,我們需要將這些非同步網路請求的響應同步化。讓我們看看如何使用EventTaget回調函數來完成這項工作:
//得到響應時調用的函數,它還負責協調響應
function onLoad() {
//當響應準備就緒時,我們將解析的響應添加到 responses 數組
//以便後面的請求返回時我們可以使用其他的響應數據
responses.push(JSON.parse(this.responseText));
//是否出現了所有期待的響應?
if(responses.length === 3) {
//我們如何按順序做任何我們需要的事情,
//因為我們需要所有數據來渲染UI組件
for(let response of responses) {
console.log('hello', response.hello);
}
}
}
//創建我們的API請求實例和 responses數組用於保存不同步的響應結果
var req1 = new XMLHttpRequest(),
req2 = new XMLHttpRequest(),
req3 = new XMLHttpRequest(),
responses = [];
//發出我們所有需要的網路請求
for(let req of [req1, req2, req3]) {
req.addEventListener('load', onLoad);
req.open('get', 'api.json');
req.send();
}
當有多個請求時,需要考慮很多額外的問題。由於它們都在不同的時間返回,我們需要將解析後的響應存儲在一個數組中,隨著每個響應的返回,我們需要檢查是否有我們期望的一切。這個簡化的示例甚至沒有考慮失敗或取消的請求。正如此代碼所表示的那樣,同步的回調函數方法是有限的。在接下來的章節中,我們將學習如何剋服這一局限。
這種模型的併發挑戰
我們在本章中討論這個執行模型對JavaScript併發帶來的挑戰。有兩個基本問題。第一個問題是任何運行的JavaScript代碼都會阻止其他任何事情的發生。第二個問題是嘗試使用回調函數完成非同步操作,會導致回調地獄。
並行機會有限
過去,JavaScript中缺乏並行性並不是真正的問題。沒有人註意它,因為JavaScript僅被視為HTML頁面的漸進增強工具。當前端開始承擔更多責任時,這種情況發生了變化。目前,大多數應用程式邏輯處理實際上都放在前端。這允許後端組件專註於JavaScript無法解決的問題(從瀏覽器的角度來看,NodeJS完全是我們將在本書後面討論的另一個問題)。
例如,後端可以實現將API數據映射和轉換為某種特殊的形式。這意味著前端JavaScript代碼只需要查詢此介面。問題是這個API介面是為某些特定的UI功能而創建的,而不是我們數據模型的必須實現的。如果我們可以在前端執行這些任務,我們會將UI功能和所需的數據轉換緊密結合在一起。這樣可以減輕後端工作量,專註於複製和負載平衡等更重要的事情上。
我們可以在前端執行這些類型的數據轉換,但它們會嚴重破壞介面的可用性。這主要是由於所有模塊需要相同的計算資源。換句話說,這個模型使我們無法實現併發原則並利用多個資源。我們將在Web workers的幫助下剋服這個Web瀏覽器限制,這將在後面的章節中介紹。
通過回調進行同步
通過回調進行同步很難實現,並且不能很好地擴展。回調地獄,這是在JavaScript編程中一個流行的術語。毋庸置疑,通過代碼中的回調進行無休止的同步會產生問題。我們經常需要創建某種狀態跟蹤機制,例如全局變數。當出現問題時,回調函數的嵌套在整體上遍歷是非常耗時的。
一般來說,同步多個非同步操作的回調方法需要大量開銷。也就是說,用於處理非同步操作的代碼存在很多重覆的。同步併發原則是編寫併發代碼,而不是將主要目標嵌入同步處理邏輯的迷宮中。Promise通過減少回調函數的使用,幫助我們在整個應用程式中一致地編寫併發代碼。
小結
本章的重點是Web瀏覽器平臺以及JavaScript在其中的地位。每當我們瀏覽網頁並與網頁交互時,都會觸發很多事件。這些作為任務處理,從隊列中獲取。其中一個任務是調用帶有運行代碼的JavaScript解釋器。
當JavaScript解釋器運行時,它包含執行上下文堆棧。函數,模塊和全局腳本代碼 - 這些都是JavaScript執行上下文的示例。解釋器也有自己的內部工作隊列; 一個用於創建新的執行上下文堆棧,另一個用於調用promise解析回調函數。
我們編寫了一些使用setTimeout()函數手動創建任務的代碼,並演示了長時間運行的JavaScript代碼對於這些任務的影響。然後我們查看了EventTarget介面,用於監聽DOM事件和網路請求,以及我們在本章中未討論的其他內容,如Web workers和文件讀取。
我們貫穿本章的是JavaScript程式員在使用這個模型時所面臨的一些挑戰。特別是,很難遵守我們的JavaScript併發原則。我們不使用並行,並試圖只使用同步,但回調卻是一個噩夢。
在下一章中,我們將介紹一種使用promises進行同步的新思路。這將使我們能夠認真開始設計和構建併發JavaScript應用程式。