Javascript單線程和事件循環
一、單線程
Javascript 是單線程的,意味著不會有其他線程來競爭。為什麼是單線程呢?
假設 Javascript 是多線程的,有兩個線程,分別對同一個元素進行操作:
function changeValue() { const e = document.getElementById("ele1"); if (e) { e.value = "VALUE"; } } function deleteElement() { const e = document.getElementById("ele1"); if (e) { e.remove(); } }
一個線程將執行changeValue()
函數,如果元素存在就修改元素的值;一個線程將執行deleteElement()
函數,如果元素存在就刪除元素。此時在多線程的條件下,兩個函數同時執行,線程 1 執行,判斷元素存在,準備執行修改值的代碼e.value = "VALUE";
,此時線程 2 搶占瞭 CPU,執行瞭deleteElement()
函數,完整的執行結束,成功刪除瞭元素,CPU 的控制權回到瞭線程 1,線程 1 繼續執行剩下的代碼,也就是將要執行的e.value = "VALUE";
,然而因為這個元素被線程 2 刪除瞭,獲取不到元素,修改元素的值失敗!
能夠發現,瀏覽器環境下,不管有幾個線程,都是共享同一個文檔(Document),對 DOM 的頻繁操作,多線程將帶來極大的不穩定性。如果是單線程,則能夠保證對 DOM 的操作是極其穩定和可預見的。你永遠不用擔心有別的線程搶占瞭資源,做瞭什麼操作而影響到原來的線程。
由於單線程,JS 一次隻能處理一個任務,在該任務處理完成之前,其他任務必須等待。這一點非常重要,在理解下面的事件循環前,首先得明確這個概念。
二、事件循環
如你所見,因為瀏覽器執行Javascript
是單線程,所以一次隻能夠執行一個任務。那麼當出現多個要執行的任務,其他尚未執行的任務在什麼地方等待呢?
為瞭能夠讓任務有個可以等待執行的地方,瀏覽器就建立瞭一個隊列,所有的任務都在隊列裡等待,當要執行任務的時候,就從隊列的隊頭裡拿一個任務來執行,執行過程中,其他任務繼續等待。當任務執行完之後,再從隊列裡拿下一個任務來執行。
可是,除瞭開發者編寫的Javascript
代碼之外,還有很多事件發生,比如瀏覽器的點擊事件,鼠標移動事件,鍵盤事件,網絡請求等。這些事件也需要執行,而且為瞭客戶體驗的流暢,需要盡快執行,以更新頁面。我們的隊列可能有很多任務正在等待執行,如果把瀏覽器發生的事件排入隊列的隊尾,那麼在前面的任務執行完成之前,瀏覽器的頁面將一直堵塞住,在用戶看在,將是非常卡頓的。
為瞭應對這種問題,瀏覽器就多加瞭一個隊列,這個隊列中的任務,將被盡快執行。為瞭和前一個隊列做區分,前面一個隊列就叫宏任務隊列吧,這個新加的隊列就叫微任務隊列吧。宏任務隊列的任務叫宏任務,微任務隊列裡的任務叫微任務。
宏任務隊列的執行方式仍不變,還是一次拿一個宏任務來執行。但是在執行完一個宏任務後,就變瞭,不檢查宏任務隊列是否為空,而是檢查微任務隊列是否為空! 如果微任務隊列不為空,就執行一個微任務,當前微任務執行完成後,繼續檢查微任務隊列是否為空,如果微任務隊列不為空,就再執行一個微任務,直到微任務隊列為空。當微任務隊列為空後,就渲染瀏覽器,回到宏任務隊列執行,如此循環往復。
通過這種模型,瀏覽器將需要快速響應的 DOM 事件放入微任務隊列,以達到快速執行的目的。當微任務隊列執行完成後,便按需要重新渲染瀏覽器,用戶就會感覺自己的操作被迅速地響應瞭。
這種事件執行方式,稱為事件循環。瀏覽器中的事件和代碼,就在事件循環模型下執行。
三、事件循環的應用
通過上圖的事件循環模型,我們得知瀏覽器渲染的順序,是在執行瞭一個宏任務和剩下的所有微任務之後,那麼為瞭保證瀏覽器的渲染順暢,我們不宜讓每一個宏任務的執行事件太長,也不能讓清空微任務隊列太耗時。一次事件循環中,隻執行一個宏任務,那麼,對耗時的宏任務需要分解成盡可能小的宏任務,微任務卻不同。由於微任務是清空整個微任務隊列,所以,在微任務裡不要生成新的微任務。畢竟微任務隊列的使命就是為瞭盡可能先處理微任務,然後重新渲染瀏覽器。
宏任務隊列和微任務隊列這兩者,都是獨立於事件循環的,也就是說,在執行Javascript
代碼時,任務隊列的添加行為也在發生,即使現在正在清空微任務隊列。這是為瞭避免在執行代碼時,發生的事件被忽略。如此可知,即使我們分解一個耗時任務,也不能因為微任務會被優先執行就選擇將它分解成多個微任務,這將阻塞瀏覽器重新渲染。更好的做法是分解成多個宏任務,這樣執行一個分解後的宏任務不會太耗時,可以盡快達到讓瀏覽器渲染。
在瀏覽器的渲染之前,會清空微任務隊列,所以,對瀏覽器 DOM 的修改更新,就適合放到微任務裡去執行。
瀏覽器渲染的次數大概是每秒 60 次,約等於 16ms 一次。在瀏覽器渲染頁面的時候,任何任務都無法再對頁面進行修改,這意味著,為瞭頁面的平滑順暢,我們的代碼,單個宏任務和當前微任務隊列裡所有微任務,都應該在 16ms 內執行完成。否則就會造成頁面卡頓。
四、使用代碼來說明
我會用一些簡單卻有效的代碼來說明事件循環如何影響頁面效果,以下的代碼很少,建議你一起編寫,體驗一下。
先看下面的代碼,我定義瞭一個foo()
函數,它將一次性往元素中添加 5 萬個子元素,我將在頁面加載完成後立即執行它。
function foo() { const d = document.getElementById("container"); for (let index = 0; index < 50000; index++) { const e = document.createElement("div"); e.textContent = "NEW"; d.appendChild(e); } }
可見這是一個耗時的操作,如果你電腦很好,體驗不到卡頓的話,可以換成循環 50 萬次。
在一陣時間的卡頓後,頁面一次性出現瞭大量子元素。雖說添加元素的目的達到瞭,但是元素出現之前的卡頓卻不能忍受。根據事件循環,我們能夠知道,是因為執行瞭一個非常耗時的宏任務,導致阻塞瞭頁面的渲染。用下面一張圖說明。
上面這張圖代表著本次事件循環的執行,一開始,瀏覽器就將foo()
放進宏任務隊列。從 0ms 開始,宏任務隊列裡有任務,事件循環取出一個宏任務,該宏任務為foo()
,執行,添加 5 萬個子元素,執行非常耗時,需要 2000ms(假設的時間),foo()
執行完後,執行微任務,假設我們的清空微任務隊列需要執行 5ms,清空後,時間來到瞭 2005ms,這個時候才能開始重新渲染瀏覽器。經過瞭這一次事件循環,竟然耗時瞭 2015ms!
那麼,我們要改善體驗,期望是一個平滑的渲染效果。因為瀏覽器頁面的變化,隻有在事件循環中重新渲染瀏覽器這一步才會發生變化,所以我們要做的就是,盡可能快地到事件循環中的渲染瀏覽器這一步。所以,我們要將這個foo()
分解成多個宏任務。
為什麼不能分解成微任務?因為微任務會在宏任務完成後全部執行。假設我們將添加 5 萬 個元素分解成宏任務添加 1000 個,微任務添加 49000 個,那麼事件循環還是必須執行完添加 1000 個元素的宏任務後,執行添加 49000 個元素的微任務,才能渲染頁面。所以我們要分解成宏任務。
假設我們分解成瞭 200 個宏任務,每個宏任務都添加 250 個元素,那麼,在事件循環執行的時候,任務隊列裡有 200 個宏任務,取出一個執行,這個宏任務隻添加 250 個元素,耗時 10ms。當前宏任務完成後,便清空微任務,耗時 5ms,時間來到瞭 15ms,就可以渲染瀏覽器瞭。這一次事件循環,在渲染瀏覽器前隻耗時 15ms!
接著,渲染瀏覽器後,頁面上出現瞭 250 個元素,又開始事件循環,從宏任務隊列裡拿出一個宏任務執行。
如上圖所示,接連不斷的事件循環使瀏覽器渲染看起來平滑順暢。
接下來我們便改造我們的代碼,讓它分解成多個宏任務。
五、setTimeout()
setTimeout()
函數,用於將一個函數延遲執行,是我們的重點方法。
你應該很熟悉這個函數的用法瞭,setTimeout()
接收兩個參數,第一個是一個回調函數,第二個是數字,用於指示延遲多少時間,以毫秒為單位(ms)。
這裡主要介紹的是第二個參數,很多人以為第二個參數是指延遲多少毫秒後執行傳進來的函數,但其實,它的真正含義是:延遲多少毫秒後進入宏任務隊列!
假設如下代碼:
setTimeout(() => { console.log("execute setTimeout()"); }, 10);
下面我用一張圖說明這段代碼的執行,圖中,上方代表時間軸,下方代表宏任務隊列。
在 0ms 時,註冊setTimeout
函數,第一個參數裡的方法將在 10ms 後加入宏任務隊列,此時,宏任務時沒有我們代碼裡的任務的。
其他我們不知道的 JS 代碼執行瞭 10 ms。
到瞭 10ms 後,setTimeout
到期,第一個參數裡的方法加入宏任務隊列。
上圖中,10ms 到瞭,加入瞭宏任務隊列。但是要註意,事件循環此時可能正在執行一個宏任務,或者正在清空微任務隊列,或者正在渲染瀏覽器,所以不會馬上執行新增加的宏任務,隻有又一次循環到瞭執行宏任務的時候,才會從宏任務隊列中獲取宏任務執行(JS 是單線程的)。假設這段時間耗時瞭 5ms,那麼如下圖。
如上圖所示,在 15ms 的時候,我們才從宏任務隊列裡取出在 10ms 時放入宏任務隊列的宏任務,並執行。和我們的代碼對比,盡管setTimeout
的第二個參數是 10ms,卻在 15ms 才執行。
當理解瞭setTimeout
的原理之後,便可以使用setTimeout
將一個耗時的任務分解成多個宏任務,以充分給予瀏覽器渲染。
我修改瞭foo
函數,如下所示:
function foo() { const d = document.getElementById("container"); const total = 50000; const size = 250; const chunk = total / size; let i = 0; setTimeout(function render() { for (let index = 0; index < size; index++) { const e = document.createElement("div"); e.textContent = "NEW"; d.appendChild(e); } i++; if (i < chunk) { setTimeout(render, 0); } }, 0); }
在foo
方法中,首先獲取瞭要添加子元素的元素,和定義瞭各種變量。total
表示一共有幾個元素要添加,因為我電腦性能差,所以是 5 萬,你可以修改成你喜歡的值;size
是指我們分解後每個宏任務要添加幾次元素;chunk
是指分解後,一共有幾個宏任務,通過簡單的計算得到;i
是用於標記執行到瞭第幾個宏任務瞭。
接下來就是重點瞭,註冊瞭setTimeout
,在 0ms 後將傳入的render
函數放進宏任務隊列裡。然後這個foo
函數就執行結束瞭,事件循環繼續往下執行,清空微任務隊列,渲染瀏覽器。等到下一個事件循環的時候,才會從宏任務隊列裡拿出由setTimeout
放入的render
函數(如果是第一個的話)並執行。
如上圖所示,當前的事件循環正在執行foo()
函數,此時render()
在宏任務隊列中等待。
假設這次事件循環需要的時間是 10ms,那麼到瞭 10ms 後,事件循環開始瞭新的一輪,從宏任務隊列裡獲取一個新的宏任務,獲取到瞭render()
任務並執行。來看render()
函數裡的代碼:
function render() { for (let index = 0; index < size; index++) { const e = document.createElement("div"); e.textContent = "NEW"; d.appendChild(e); } i++; if (i < chunk) { setTimeout(render, 0); } }
代碼執行瞭 for 循環,添加size
次數的子元素,在示例中size
定義為瞭 250,添加 250 個子元素,數量不多,添加過程會非常快。在執行完 for 循環後,將外部的i
變量加 1,我們將使用i
判斷所有的子元素是否添加完畢,如果是則結束函數,如果不是,則再次通過setTimeout
註冊一個render()
函數,然後結束當前函數。
如上圖,在 15ms 的時候,render()
函數添加瞭 250 個子元素,然後使用setTimeout
註冊瞭一個新的宏任務,在 0ms 後進入宏任務隊列。註意此時,盡管render()
函數添加瞭 250 個子元素,但是事件循環還沒有到渲染瀏覽器這一步,所以頁面沒有出現 250 個新元素。
事件循環繼續執行:
到瞭 15ms,執行微任務隊列,假設需要執行 5ms。到瞭 20 ms,清空瞭微任務隊列,開始渲染瀏覽器,假設渲染需要 5ms,界面上出現瞭 250 個新元素。這次,隻花費瞭 15ms,就讓頁面上渲染出瞭元素,而不是一開始那樣卡頓瞭 2000ms 後才頁面才渲染!
接下來的事件循環就是一直重復 10ms 開始到 25ms 的動作瞭,直到所有子元素都渲染完畢。
通過改造後的foo()
函數,我們將卡頓的頁面優化成瞭觀感良好順暢的頁面。從新舊foo()
函數的代碼量來看,代碼數量的多少跟頁面順暢與否沒有太大關系。重點是理解事件循環中發生的事。
六、思考:劣質的優化
如果我將foo()
函數改寫成如下的形式,會怎麼樣,親自試一試,思考執行的事件循環和宏任務隊列中發生瞭什麼。
function foo() { const d = document.getElementById("container"); const size = 1000; const chunk = 50000 / size; for (let index = 0; index < chunk; index++) { setTimeout(() => { const e = document.createElement("div"); e.textContent = "NEW"; d.appendChild(e); }, 0); } }
到此這篇關於Javascript單線程和事件循環的文章就介紹到這瞭,更多相關JS單線程 內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- 一文搞懂如何避免JavaScript內存泄漏
- vue前端優雅展示後端十萬條數據面試點剖析
- 前端如何更好的展示後端返回的十萬條數據
- React團隊測試並發特性詳解
- JavaScript中MutationObServer監聽DOM元素詳情