程式、執行緒詳細梳理

發表於2018-07-12

展現形式:由於是屬於系統梳理型,就沒有由淺入深了,而是從頭到尾的梳理知識體系,
重點是將關鍵節點的知識點串聯起來,而不是僅僅剖析某一部分知識。

內容是:從瀏覽器程式,再到瀏覽器核心執行,再到JS引擎單執行緒,再到JS事件迴圈機制,從頭到尾系統的梳理一遍,擺脫碎片化,形成一個知識體系

目標是:看完這篇文章後,對瀏覽器多程式,JS單執行緒,JS事件迴圈機制這些都能有一定理解,
有一個知識體系骨架,而不是似懂非懂的感覺。

另外,本文適合有一定經驗的前端人員,新手請規避,避免受到過多的概念衝擊。可以先存起來,有了一定理解後再看,也可以分成多批次觀看,避免過度疲勞。

大綱

  • 區分程式和執行緒
  • 瀏覽器是多程式的

    • 瀏覽器都包含哪些程式?
    • 瀏覽器多程式的優勢
    • 重點是瀏覽器核心(渲染程式)
    • Browser程式和瀏覽器核心(Renderer程式)的通訊過程
  • 梳理瀏覽器核心中執行緒之間的關係

    • GUI渲染執行緒與JS引擎執行緒互斥
    • JS阻塞頁面載入
    • WebWorker,JS的多執行緒?
    • WebWorker與SharedWorker
  • 簡單梳理下瀏覽器渲染流程

    • load事件與DOMContentLoaded事件的先後
    • css載入是否會阻塞dom樹渲染?
    • 普通圖層和複合圖層
  • 從Event Loop談JS的執行機制

    • 事件迴圈機制進一步補充
    • 單獨說說定時器
    • setTimeout而不是setInterval
  • 事件迴圈進階:macrotask與microtask
  • 寫在最後的話

區分程式和執行緒

執行緒和程式區分不清,是很多新手都會犯的錯誤,沒有關係。這很正常。先看看下面這個形象的比喻:

- 程式是一個工廠,工廠有它的獨立資源

- 工廠之間相互獨立

- 執行緒是工廠中的工人,多個工人協作完成任務

- 工廠內有一個或多個工人

- 工人之間共享空間複製程式碼

再完善完善概念:

- 工廠的資源 -> 系統分配的記憶體(獨立的一塊記憶體)

- 工廠之間的相互獨立 -> 程式之間相互獨立

- 多個工人協作完成任務 -> 多個執行緒在程式中協作完成任務

- 工廠內有一個或多個工人 -> 一個程式由一個或多個執行緒組成

- 工人之間共享空間 -> 同一程式下的各個執行緒之間共享程式的記憶體空間(包括程式碼段、資料集、堆等)複製程式碼

然後再鞏固下:

如果是windows電腦中,可以開啟工作管理員,可以看到有一個後臺程式列表。對,那裡就是檢視程式的地方,而且可以看到每個程式的記憶體資源資訊以及cpu佔有率。

所以,應該更容易理解了:程式是cpu資源分配的最小單位(系統會給它分配記憶體)

最後,再用較為官方的術語描述一遍:

  • 程式是cpu資源分配的最小單位(是能擁有資源和獨立執行的最小單位)
  • 執行緒是cpu排程的最小單位(執行緒是建立在程式的基礎上的一次程式執行單位,一個程式中可以有多個執行緒)

tips

  • 不同程式之間也可以通訊,不過代價較大
  • 現在,一般通用的叫法:單執行緒與多執行緒,都是指在一個程式內的單和多。(所以核心還是得屬於一個程式才行)

瀏覽器是多程式的

理解了程式與執行緒了區別後,接下來對瀏覽器進行一定程度上的認識:(先看下簡化理解)

  • 瀏覽器是多程式的
  • 瀏覽器之所以能夠執行,是因為系統給它的程式分配了資源(cpu、記憶體)
  • 簡單點理解,每開啟一個Tab頁,就相當於建立了一個獨立的瀏覽器程式。

關於以上幾點的驗證,請再第一張圖

圖中開啟了Chrome瀏覽器的多個標籤頁,然後可以在Chrome的工作管理員中看到有多個程式(分別是每一個Tab頁面有一個獨立的程式,以及一個主程式)。
感興趣的可以自行嘗試下,如果再多開啟一個Tab頁,程式正常會+1以上

注意:在這裡瀏覽器應該也有自己的優化機制,有時候開啟多個tab頁後,可以在Chrome工作管理員中看到,有些程式被合併了
(所以每一個Tab標籤對應一個程式並不一定是絕對的)

瀏覽器都包含哪些程式?

知道了瀏覽器是多程式後,再來看看它到底包含哪些程式:(為了簡化理解,僅列舉主要程式)

  1. Browser程式:瀏覽器的主程式(負責協調、主控),只有一個。作用有

    • 負責瀏覽器介面顯示,與使用者互動。如前進,後退等
    • 負責各個頁面的管理,建立和銷燬其他程式
    • 將Renderer程式得到的記憶體中的Bitmap,繪製到使用者介面上
    • 網路資源的管理,下載等
  2. 第三方外掛程式:每種型別的外掛對應一個程式,僅當使用該外掛時才建立
  3. GPU程式:最多一個,用於3D繪製等
  4. 瀏覽器渲染程式(瀏覽器核心)(Renderer程式,內部是多執行緒的):預設每個Tab頁面一個程式,互不影響。主要作用為

    • 頁面渲染,指令碼執行,事件處理等

強化記憶:在瀏覽器中開啟一個網頁相當於新起了一個程式(程式內有自己的多執行緒)

當然,瀏覽器有時會將多個程式合併(譬如開啟多個空白標籤頁後,會發現多個空白標籤頁被合併成了一個程式),如圖

另外,可以通過Chrome的更多工具 -> 工作管理員自行驗證

瀏覽器多程式的優勢

相比於單程式瀏覽器,多程式有如下優點:

  • 避免單個page crash影響整個瀏覽器
  • 避免第三方外掛crash影響整個瀏覽器
  • 多程式充分利用多核優勢
  • 方便使用沙盒模型隔離外掛等程式,提高瀏覽器穩定性

簡單點理解:如果瀏覽器是單程式,那麼某個Tab頁崩潰了,就影響了整個瀏覽器,體驗有多差;同理如果是單程式,外掛崩潰了也會影響整個瀏覽器;而且多程式還有其它的諸多優勢。。。

當然,記憶體等資源消耗也會更大,有點空間換時間的意思。

重點是瀏覽器核心(渲染程式)

重點來了,我們可以看到,上面提到了這麼多的程式,那麼,對於普通的前端操作來說,最終要的是什麼呢?答案是渲染程式

可以這樣理解,頁面的渲染,JS的執行,事件的迴圈,都在這個程式內進行。接下來重點分析這個程式

請牢記,瀏覽器的渲染程式是多執行緒的(這點如果不理解,請回頭看程式和執行緒的區分

終於到了執行緒這個概念了?,好親切。那麼接下來看看它都包含了哪些執行緒(列舉一些主要常駐執行緒):

  1. GUI渲染執行緒

    • 負責渲染瀏覽器介面,解析HTML,CSS,構建DOM樹和RenderObject樹,佈局和繪製等。
    • 當介面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該執行緒就會執行
    • 注意,GUI渲染執行緒與JS引擎執行緒是互斥的,當JS引擎執行時GUI執行緒會被掛起(相當於被凍結了),GUI更新會被儲存在一個佇列中等到JS引擎空閒時立即被執行。
  2. JS引擎執行緒

    • 也稱為JS核心,負責處理Javascript指令碼程式。(例如V8引擎)
    • JS引擎執行緒負責解析Javascript指令碼,執行程式碼。
    • JS引擎一直等待著任務佇列中任務的到來,然後加以處理,一個Tab頁(renderer程式)中無論什麼時候都只有一個JS執行緒在執行JS程式
    • 同樣注意,GUI渲染執行緒與JS引擎執行緒是互斥的,所以如果JS執行的時間過長,這樣就會造成頁面的渲染不連貫,導致頁面渲染載入阻塞。
  3. 事件觸發執行緒

    • 歸屬於瀏覽器而不是JS引擎,用來控制事件迴圈(可以理解,JS引擎自己都忙不過來,需要瀏覽器另開執行緒協助)
    • 當JS引擎執行程式碼塊如setTimeOut時(也可來自瀏覽器核心的其他執行緒,如滑鼠點選、AJAX非同步請求等),會將對應任務新增到事件執行緒中
    • 當對應的事件符合觸發條件被觸發時,該執行緒會把事件新增到待處理佇列的隊尾,等待JS引擎的處理
    • 注意,由於JS的單執行緒關係,所以這些待處理佇列中的事件都得排隊等待JS引擎處理(當JS引擎空閒時才會去執行)

  4. 定時觸發器執行緒

    • 傳說中的setIntervalsetTimeout所線上程
    • 瀏覽器定時計數器並不是由JavaScript引擎計數的,(因為JavaScript引擎是單執行緒的, 如果處於阻塞執行緒狀態就會影響記計時的準確)
    • 因此通過單獨執行緒來計時並觸發定時(計時完畢後,新增到事件佇列中,等待JS引擎空閒後執行)
    • 注意,W3C在HTML標準中規定,規定要求setTimeout中低於4ms的時間間隔算為4ms。
  5. 非同步http請求執行緒

    • 在XMLHttpRequest在連線後是通過瀏覽器新開一個執行緒請求
    • 將檢測到狀態變更時,如果設定有回撥函式,非同步執行緒就產生狀態變更事件,將這個回撥再放入事件佇列中。再由JavaScript引擎執行。

看到這裡,如果覺得累了,可以先休息下,這些概念需要被消化,畢竟後續將提到的事件迴圈機制就是基於事件觸發執行緒的,所以如果僅僅是看某個碎片化知識,
可能會有一種似懂非懂的感覺。要完成的梳理一遍才能快速沉澱,不易遺忘。放張圖鞏固下吧:

再說一點,為什麼JS引擎是單執行緒的?額,這個問題其實應該沒有標準答案,譬如,可能僅僅是因為由於多執行緒的複雜性,譬如多執行緒操作一般要加鎖,因此最初設計時選擇了單執行緒。。。

Browser程式和瀏覽器核心(Renderer程式)的通訊過程

看到這裡,首先,應該對瀏覽器內的程式和執行緒都有一定理解了,那麼接下來,再談談瀏覽器的Browser程式(控制程式)是如何和核心通訊的,
這點也理解後,就可以將這部分的知識串聯起來,從頭到尾有一個完整的概念。

如果自己開啟工作管理員,然後開啟一個瀏覽器,就可以看到:工作管理員中出現了兩個程式(一個是主控程式,一個則是開啟Tab頁的渲染程式)
然後在這前提下,看下整個的過程:(簡化了很多)

  • Browser程式收到使用者請求,首先需要獲取頁面內容(譬如通過網路下載資源),隨後將該任務通過RendererHost介面傳遞給Render程式
  • Renderer程式的Renderer介面收到訊息,簡單解釋後,交給渲染執行緒,然後開始渲染

    • 渲染執行緒接收請求,載入網頁並渲染網頁,這其中可能需要Browser程式獲取資源和需要GPU程式來幫助渲染
    • 當然可能會有JS執行緒操作DOM(這樣可能會造成迴流並重繪)
    • 最後Render程式將結果傳遞給Browser程式
  • Browser程式接收到結果並將結果繪製出來

這裡繪一張簡單的圖:(很簡化)

看完這一整套流程,應該對瀏覽器的運作有了一定理解了,這樣有了知識架構的基礎後,後續就方便往上填充內容。

這塊再往深處講的話就涉及到瀏覽器核心原始碼解析了,不屬於本文範圍。

如果這一塊要深挖,建議去讀一些瀏覽器核心原始碼解析文章,或者可以先看看參考下來源中的第一篇文章,寫的不錯

梳理瀏覽器核心中執行緒之間的關係

到了這裡,已經對瀏覽器的執行有了一個整體的概念,接下來,先簡單梳理一些概念

GUI渲染執行緒與JS引擎執行緒互斥

由於JavaScript是可操縱DOM的,如果在修改這些元素屬性同時渲染介面(即JS執行緒和UI執行緒同時執行),那麼渲染執行緒前後獲得的元素資料就可能不一致了。

因此為了防止渲染出現不可預期的結果,瀏覽器設定GUI渲染執行緒與JS引擎為互斥的關係,當JS引擎執行時GUI執行緒會被掛起,
GUI更新則會被儲存在一個佇列中等到JS引擎執行緒空閒時立即被執行。

JS阻塞頁面載入

從上述的互斥關係,可以推匯出,JS如果執行時間過長就會阻塞頁面。

譬如,假設JS引擎正在進行巨量的計算,此時就算GUI有更新,也會被儲存到佇列中,等待JS引擎空閒後執行。
然後,由於巨量計算,所以JS引擎很可能很久很久後才能空閒,自然會感覺到巨卡無比。

所以,要儘量避免JS執行時間過長,這樣就會造成頁面的渲染不連貫,導致頁面渲染載入阻塞的感覺。

WebWorker,JS的多執行緒?

前文中有提到JS引擎是單執行緒的,而且JS執行時間過長會阻塞頁面,那麼JS就真的對cpu密集型計算無能為力麼?

所以,後來HTML5中支援了Web Worker

MDN的官方解釋是:

Web Worker為Web內容在後臺執行緒中執行指令碼提供了一種簡單的方法。執行緒可以執行任務而不干擾使用者介面

一個worker是使用一個建構函式建立的一個物件(e.g. Worker()) 執行一個命名的JavaScript檔案 

這個檔案包含將在工作執行緒中執行的程式碼; workers 執行在另一個全域性上下文中,不同於當前的window

因此,使用 window快捷方式獲取當前全域性的範圍 (而不是self) 在一個 Worker 內將返回錯誤複製程式碼

這樣理解下:

  • 建立Worker時,JS引擎向瀏覽器申請開一個子執行緒(子執行緒是瀏覽器開的,完全受主執行緒控制,而且不能操作DOM)
  • JS引擎執行緒與worker執行緒間通過特定的方式通訊(postMessage API,需要通過序列化物件來與執行緒互動特定的資料)

所以,如果有非常耗時的工作,請單獨開一個Worker執行緒,這樣裡面不管如何翻天覆地都不會影響JS引擎主執行緒,
只待計算出結果後,將結果通訊給主執行緒即可,perfect!

而且注意下,JS引擎是單執行緒的,這一點的本質仍然未改變,Worker可以理解是瀏覽器給JS引擎開的外掛,專門用來解決那些大量計算問題。

其它,關於Worker的詳解就不是本文的範疇了,因此不再贅述。

WebWorker與SharedWorker

既然都到了這裡,就再提一下SharedWorker(避免後續將這兩個概念搞混)

  • WebWorker只屬於某個頁面,不會和其他頁面的Render程式(瀏覽器核心程式)共享

    • 所以Chrome在Render程式中(每一個Tab頁就是一個render程式)建立一個新的執行緒來執行Worker中的JavaScript程式。
  • SharedWorker是瀏覽器所有頁面共享的,不能採用與Worker同樣的方式實現,因為它不隸屬於某個Render程式,可以為多個Render程式共享使用

    • 所以Chrome瀏覽器為SharedWorker單獨建立一個程式來執行JavaScript程式,在瀏覽器中每個相同的JavaScript只存在一個SharedWorker程式,不管它被建立多少次。

看到這裡,應該就很容易明白了,本質上就是程式和執行緒的區別。SharedWorker由獨立的程式管理,WebWorker只是屬於render程式下的一個執行緒

簡單梳理下瀏覽器渲染流程

本來是直接計劃開始談JS執行機制的,但想了想,既然上述都一直在談瀏覽器,直接跳到JS可能再突兀,因此,中間再補充下瀏覽器的渲染流程(簡單版本)

為了簡化理解,前期工作直接省略成:(要展開的或完全可以寫另一篇超長文)

- 瀏覽器輸入url,瀏覽器主程式接管,開一個下載執行緒,
然後進行 http請求(略去DNS查詢,IP定址等等操作),然後等待響應,獲取內容,
隨後將內容通過RendererHost介面轉交給Renderer程式

- 瀏覽器渲染流程開始複製程式碼

瀏覽器器核心拿到內容後,渲染大概可以劃分成以下幾個步驟:

  1. 解析html建立dom樹
  2. 解析css構建render樹(將CSS程式碼解析成樹形的資料結構,然後結合DOM合併成render樹)
  3. 佈局render樹(Layout/reflow),負責各元素尺寸、位置的計算
  4. 繪製render樹(paint),繪製頁面畫素資訊
  5. 瀏覽器會將各層的資訊傳送給GPU,GPU會將各層合成(composite),顯示在螢幕上。

所有詳細步驟都已經略去,渲染完畢後就是load事件了,之後就是自己的JS邏輯處理了

既然略去了一些詳細的步驟,那麼就提一些可能需要注意的細節把。

這裡重繪參考來源中的一張圖:(參考來源第一篇)

load事件與DOMContentLoaded事件的先後

上面提到,渲染完畢後會觸發load事件,那麼你能分清楚load事件與DOMContentLoaded事件的先後麼?

很簡單,知道它們的定義就可以了:

  • 當 DOMContentLoaded 事件觸發時,僅當DOM載入完成,不包括樣式表,圖片。

(譬如如果有async載入的指令碼就不一定完成)

  • 當 onload 事件觸發時,頁面上所有的DOM,樣式表,指令碼,圖片都已經載入完成了。

(渲染完畢了)

所以,順序是:DOMContentLoaded -> load

css載入是否會阻塞dom樹渲染?

這裡說的是頭部引入css的情況

首先,我們都知道:css是由單獨的下載執行緒非同步下載的。

然後再說下幾個現象:

  • css載入不會阻塞DOM樹解析(非同步載入時DOM照常構建)
  • 但會阻塞render樹渲染(渲染時需等css載入完畢,因為render樹需要css資訊)

這可能也是瀏覽器的一種優化機制。

因為你載入css的時候,可能會修改下面DOM節點的樣式,
如果css載入不阻塞render樹渲染的話,那麼當css載入完之後,
render樹可能又得重新重繪或者回流了,這就造成了一些沒有必要的損耗。
所以乾脆就先把DOM樹的結構先解析完,把可以做的工作做完,然後等你css載入完之後,
在根據最終的樣式來渲染render樹,這種做法效能方面確實會比較好一點。

普通圖層和複合圖層

渲染步驟中就提到了composite概念。

可以簡單的這樣理解,瀏覽器渲染的圖層一般包含兩大類:普通圖層以及複合圖層

首先,普通文件流內可以理解為一個複合圖層(這裡稱為預設複合層,裡面不管新增多少元素,其實都是在同一個複合圖層中)

其次,absolute佈局(fixed也一樣),雖然可以脫離普通文件流,但它仍然屬於預設複合層

然後,可以通過硬體加速的方式,宣告一個新的複合圖層,它會單獨分配資源
(當然也會脫離普通文件流,這樣一來,不管這個複合圖層中怎麼變化,也不會影響預設複合層裡的迴流重繪)

可以簡單理解下:GPU中,各個複合圖層是單獨繪製的,所以互不影響,這也是為什麼某些場景硬體加速效果一級棒

可以Chrome原始碼除錯 -> More Tools -> Rendering -> Layer borders中看到,黃色的就是複合圖層資訊

如下圖。可以驗證上述的說法

如何變成複合圖層(硬體加速)

將該元素變成一個複合圖層,就是傳說中的硬體加速技術

  • 最常用的方式:translate3dtranslateZ
  • opacity屬性/過渡動畫(需要動畫執行的過程中才會建立合成層,動畫沒有開始或結束後元素還會回到之前的狀態)
  • will-chang屬性(這個比較偏僻),一般配合opacity與translate使用(而且經測試,除了上述可以引發硬體加速的屬性外,其它屬性並不會變成複合層),

作用是提前告訴瀏覽器要變化,這樣瀏覽器會開始做一些優化工作(這個最好用完後就釋放)

  • <video><iframe><canvas><webgl>等元素
  • 其它,譬如以前的flash外掛

absolute和硬體加速的區別

可以看到,absolute雖然可以脫離普通文件流,但是無法脫離預設複合層。
所以,就算absolute中資訊改變時不會改變普通文件流中render樹,
但是,瀏覽器最終繪製時,是整個複合層繪製的,所以absolute中資訊的改變,仍然會影響整個複合層的繪製。
(瀏覽器會重繪它,如果複合層中內容多,absolute帶來的繪製資訊變化過大,資源消耗是非常嚴重的)

而硬體加速直接就是在另一個複合層了(另起爐灶),所以它的資訊改變不會影響預設複合層
(當然了,內部肯定會影響屬於自己的複合層),僅僅是引發最後的合成(輸出檢視)

複合圖層的作用?

一般一個元素開啟硬體加速後會變成複合圖層,可以獨立於普通文件流中,改動後可以避免整個頁面重繪,提升效能

但是儘量不要大量使用複合圖層,否則由於資源消耗過度,頁面反而會變的更卡

硬體加速時請使用index

使用硬體加速時,儘可能的使用index,防止瀏覽器預設給後續的元素建立複合層渲染

具體的原理時這樣的:
**webkit CSS3中,如果這個元素新增了硬體加速,並且index層級比較低,
那麼在這個元素的後面其它元素(層級比這個元素高的,或者相同的,並且releative或absolute屬性相同的),
會預設變為複合層渲染,如果處理不當會極大的影響效能**

簡單點理解,其實可以認為是一個隱式合成的概念:如果a是一個複合圖層,而且b在a上面,那麼b也會被隱式轉為一個複合圖層,這點需要特別注意

另外,這個問題可以在這個地址看到重現(原作者分析的挺到位的,直接上鍊接):

web.jobbole.com/83575/

從Event Loop談JS的執行機制

到此時,已經是屬於瀏覽器頁面初次渲染完畢後的事情,JS引擎的一些執行機制分析。

注意,這裡不談可執行上下文VOscop chain等概念(這些完全可以整理成另一篇文章了),這裡主要是結合Event Loop來談JS程式碼是如何執行的。

讀這部分的前提是已經知道了JS引擎是單執行緒,而且這裡會用到上文中的幾個概念:(如果不是很理解,可以回頭溫習)

  • JS引擎執行緒
  • 事件觸發執行緒
  • 定時觸發器執行緒

然後再理解一個概念:

  • JS分為同步任務和非同步任務
  • 同步任務都在主執行緒上執行,形成一個執行棧
  • 主執行緒之外,事件觸發執行緒管理著一個任務佇列,只要非同步任務有了執行結果,就在任務佇列之中放置一個事件。
  • 一旦執行棧中的所有同步任務執行完畢(此時JS引擎空閒),系統就會讀取任務佇列,將可執行的非同步任務新增到可執行棧中,開始執行。

看圖:

看到這裡,應該就可以理解了:為什麼有時候setTimeout推入的事件不能準時執行?因為可能在它推入到事件列表時,主執行緒還不空閒,正在執行其它程式碼,
所以自然有誤差。

事件迴圈機制進一步補充

這裡就直接引用一張圖片來協助理解:(參考自Philip Roberts的演講《Help, I'm stuck in an event-loop》)

上圖大致描述就是:

  • 主執行緒執行時會產生執行棧,

棧中的程式碼呼叫某些api時,它們會在事件佇列中新增各種事件(當滿足觸發條件後,如ajax請求完畢)

  • 而棧中的程式碼執行完畢,就會讀取事件佇列中的事件,去執行那些回撥
  • 如此迴圈
  • 注意,總是要等待棧中的程式碼執行完畢後才會去讀取事件佇列中的事件

單獨說說定時器

上述事件迴圈機制的核心是:JS引擎執行緒和事件觸發執行緒

但事件上,裡面還有一些隱藏細節,譬如呼叫setTimeout後,是如何等待特定時間後才新增到事件佇列中的?

是JS引擎檢測的麼?當然不是了。它是由定時器執行緒控制(因為JS引擎自己都忙不過來,根本無暇分身)

為什麼要單獨的定時器執行緒?因為JavaScript引擎是單執行緒的, 如果處於阻塞執行緒狀態就會影響記計時的準確,因此很有必要單獨開一個執行緒用來計時。

什麼時候會用到定時器執行緒?當使用setTimeoutsetInterval,它需要定時器執行緒計時,計時完成後就會將特定的事件推入事件佇列中。

譬如:

setTimeout(function(){
    console.log('hello!');
}, 1000);複製程式碼

這段程式碼的作用是當1000毫秒計時完畢後(由定時器執行緒計時),將回撥函式推入事件佇列中,等待主執行緒執行

setTimeout(function(){
    console.log('hello!');
}, 0);

console.log('begin');複製程式碼

這段程式碼的效果是最快的時間內將回撥函式推入事件佇列中,等待主執行緒執行

注意:

  • 執行結果是:先beginhello!
  • 雖然程式碼的本意是0毫秒後就推入事件佇列,但是W3C在HTML標準中規定,規定要求setTimeout中低於4ms的時間間隔算為4ms。

(不過也有一說是不同瀏覽器有不同的最小時間設定)

  • 就算不等待4ms,就算假設0毫秒就推入事件佇列,也會先執行begin(因為只有可執行棧內空了後才會主動讀取事件佇列)

setTimeout而不是setInterval

用setTimeout模擬定期計時和直接用setInterval是有區別的。

因為每次setTimeout計時到後就會去執行,然後執行一段時間後才會繼續setTimeout,中間就多了誤差
(誤差多少與程式碼執行時間有關)

而setInterval則是每次都精確的隔一段時間推入一個事件
(但是,事件的實際執行時間不一定就準確,還有可能是這個事件還沒執行完畢,下一個事件就來了)

而且setInterval有一些比較致命的問題就是:

  • 累計效應(上面提到的),如果setInterval程式碼在(setInterval)再次新增到佇列之前還沒有完成執行,

就會導致定時器程式碼連續執行好幾次,而之間沒有間隔。
就算正常間隔執行,多個setInterval的程式碼執行時間可能會比預期小(因為程式碼執行需要一定時間)

  • 譬如像iOS的webview,或者Safari等瀏覽器中都有一個特點,在滾動的時候是不執行JS的,如果使用了setInterval,會發現在滾動結束後會執行多次由於滾動不執行JS積攢回撥,如果回撥執行時間過長,就會非常容器造成卡頓問題和一些不可知的錯誤
    (這一塊後續有補充,setInterval自帶的優化,不會重複新增回撥)
  • 而且把瀏覽器最小化顯示等操作時,setInterval並不是不執行程式,

它會把setInterval的回撥函式放在佇列中,等瀏覽器視窗再次開啟時,一瞬間全部執行時

所以,鑑於這麼多但問題,目前一般認為的最佳方案是:用setTimeout模擬setInterval,或者特殊場合直接用requestAnimationFrame

補充:JS高程中有提到,JS引擎會對setInterval進行優化,如果當前事件佇列中有setInterval的回撥,不會重複新增。不過,仍然是有很多問題。。。

事件迴圈進階:macrotask與microtask

這段參考了參考來源中的第2篇文章(英文版的),(加了下自己的理解重新描述了下),
強烈推薦有英文基礎的同學直接觀看原文,作者描述的很清晰,示例也很不錯,如下:

jakearchibald.com/2015/tasks-…

上文中將JS事件迴圈機制梳理了一遍,在ES5的情況是夠用了,但是在ES6盛行的現在,仍然會遇到一些問題,譬如下面這題:

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
    console.log('promise1');
}).then(function() {
    console.log('promise2');
});

console.log('script end');複製程式碼

嗯哼,它的正確執行順序是這樣子的:

script start
script end
promise1
promise2
setTimeout複製程式碼

為什麼呢?因為Promise裡有了一個一個新的概念:microtask

或者,進一步,JS中分為兩種任務型別:macrotaskmicrotask,在ECMAScript中,microtask稱為jobs,macrotask可稱為task

它們的定義?區別?簡單點可以按如下理解:

  • macrotask(又稱之為巨集任務),可以理解是每次執行棧執行的程式碼就是一個巨集任務(包括每次從事件佇列中獲取一個事件回撥並放到執行棧中執行)

    • 每一個task會從頭到尾將這個任務執行完畢,不會執行其它
    • 瀏覽器為了能夠使得JS內部task與DOM任務能夠有序的執行,會在一個task執行結束後,在下一個 task 執行開始前,對頁面進行重新渲染
(`task->渲染->task->...`)
複製程式碼
  • microtask(又稱為微任務),可以理解是在當前 task 執行結束後立即執行的任務

    • 也就是說,在當前task任務後,下一個task之前,在渲染之前
    • 所以它的響應速度相比setTimeout(setTimeout是task)會更快,因為無需等渲染
    • 也就是說,在某一個macrotask執行完後,就會將在它執行期間產生的所有microtask都執行完畢(在渲染前)

分別很麼樣的場景會形成macrotask和microtask呢?

  • macrotask:主程式碼塊,setTimeout,setInterval等(可以看到,事件佇列中的每一個事件都是一個macrotask)
  • microtask:Promise,process.nextTick等

__補充:在node環境下,process.nextTick的優先順序高於Promise__,也就是可以簡單理解為:在巨集任務結束後會先執行微任務佇列中的nextTickQueue部分,然後才會執行微任務中的Promise部分。

參考:segmentfault.com/q/101000001…

再根據執行緒來理解下:

  • macrotask中的事件都是放在一個事件佇列中的,而這個佇列由事件觸發執行緒維護
  • microtask中的所有微任務都是新增到微任務佇列(Job Queues)中,等待當前macrotask執行完畢後執行,而這個佇列由JS引擎執行緒維護

(這點由自己理解+推測得出,因為它是在主執行緒下無縫執行的)

所以,總結下執行機制:

  • 執行一個巨集任務(棧中沒有就從事件佇列中獲取)
  • 執行過程中如果遇到微任務,就將它新增到微任務的任務佇列中
  • 巨集任務執行完畢後,立即執行當前微任務佇列中的所有微任務(依次執行)
  • 當前巨集任務執行完畢,開始檢查渲染,然後GUI執行緒接管渲染
  • 渲染完畢後,JS執行緒繼續接管,開始下一個巨集任務(從事件佇列中獲取)

如圖:

另外,請注意下Promisepolyfill與官方版本的區別:

  • 官方版本中,是標準的microtask形式
  • polyfill,一般都是通過setTimeout模擬的,所以是macrotask形式
  • 請特別注意這兩點區別

注意,有一些瀏覽器執行結果不一樣(因為它們可能把microtask當成macrotask來執行了),
但是為了簡單,這裡不描述一些不標準的瀏覽器下的場景(但記住,有些瀏覽器可能並不標準)

20180126補充:使用MutationObserver實現microtask

MutationObserver可以用來實現microtask
(它屬於microtask,優先順序小於Promise,
一般是Promise不支援時才會這樣做)

它是HTML5中的新特性,作用是:監聽一個DOM變動,
當DOM物件樹發生任何變動時,Mutation Observer會得到通知

像以前的Vue原始碼中就是利用它來模擬nextTick的,
具體原理是,建立一個TextNode並監聽內容變化,
然後要nextTick的時候去改一下這個節點的文字內容,
如下:(Vue的原始碼,未修改)

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))

observer.observe(textNode, {
    characterData: true
})
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}複製程式碼

對應Vue原始碼連結

不過,現在的Vue(2.5+)的nextTick實現移除了MutationObserver的方式(據說是相容性原因),
取而代之的是使用MessageChannel
(當然,預設情況仍然是Promise,不支援才相容的)。

MessageChannel屬於巨集任務,優先順序是:MessageChannel->setTimeout
所以Vue(2.5+)內部的nextTick與2.4及之前的實現是不一樣的,需要注意下。

這裡不展開,可以看下juejin.im/post/5a1af8…

寫在最後的話

看到這裡,不知道對JS的執行機制是不是更加理解了,從頭到尾梳理,而不是就某一個碎片化知識應該是會更清晰的吧?

同時,也應該注意到了JS根本就沒有想象的那麼簡單,前端的知識也是無窮無盡,層出不窮的概念、N多易忘的知識點、各式各樣的框架、
底層原理方面也是可以無限的往下深挖,然後你就會發現,你知道的太少了。。。

另外,本文也打算先告一段落,其它的,如JS詞法解析,可執行上下文以及VO等概念就不繼續在本文中寫了,後續可以考慮另開新的文章。

最後,喜歡的話,就請給個贊吧!

參考資料


相關文章