前端工程師們工作久了,一般都會在某些地方看見過這樣的代碼:
setTimeout(function(){
// TODO
}, 0);
舉個實例,移動端我們經常會用的一個庫叫做iScroll來模仿iOS系統裡面的滾動反彈效果,而它的官方文檔裡面就有類似的代碼建議:
上面其實也說到了setTimeout( , 0)的作用,就是當你改動了DOM後,讓浏覽器有一點空余的時間來重繪這個頁面。可能道理大家都懂,但是為什麼啊??下面讓我們通過實例來研究說明setTimeout(, 0)的工作原理。
想象一下頁面中有一個 ”do something“ 按鈕和一個顯示結果的 DIV。
”do something“ 按鈕中點擊事件
onclick
的回調函數 ”LongCalculate()“ 中干了兩件事:
- 執行一個非常耗時的計算(大約3分鐘)。
- 把上面計算的結果輸出到結果 DIV 裡面。
現在,你的用戶開始測試這個功能,點擊 “do something” 按鈕,接著頁面就似乎在3分鐘內什麼也沒干,用戶煩躁不安,再次點了一下按鈕,又等了一分鐘,也是什麼都沒有發生,然後再次點擊了按鈕。。。
問題很明顯:你需要有一個“狀態” DIV,用來展示現在進行的情況。下面展示的最新的處理。
所以你添加了一個“狀態” DIV(剛開始是空的),接著調整
onclick
的回調函數(函數LongCalc()
),調整後該回調函數執行以下4個步驟:
- 改變狀態 DIV 的內容為 “Calculating... may take ~3 minutes” 。
- 執行一個非常耗時的計算(大約3分鐘)。
- 把上面計算的結果輸出到結果 DIV 裡面。
- 改變狀態DIV的內容為 “Calculation done”。
修改完畢,你興高采烈地叫你的用戶再來測試以下。
他們滿臉不爽的又走過來說,他們點擊按鈕的時候,狀態 DIV 根本都不會顯示 "Calculation..." 這個狀態!!!
你絞盡腦汁,萬思不得其解。到 StackOverflow瘋狂提問(或者閱讀文檔和問Google),接著你發現問題所在了:
浏覽器把所有事件觸發的待執行任務( UI 任務和 JavaScript 命令)都放到同一個隊列裡面。並且不幸的是,重繪狀態 DIV 的內容為 ”Calculating...“ 是一個分離的待執行任務,這個任務會放到隊列的最後面!
下面是你的用戶測試過程中的事件分解和隊列中的內容:
- 隊列:
[Empty]
- 事件:點擊按鈕。事件觸發後隊列的內容:
[Execute OnClick handler(line 1-4)]
- 事件:執行回調函數的第一行代碼(也就是改變狀態 DIV 的值)。事件觸發後隊列的內容:
[Execute OnClick handler(lines 2-4), re-draw Status DIV with new "Calculating" value]
。請注意當DOM元素改變的瞬間,需要一個新的事件來重繪這個DOM。這個事件通過改變DOM元素觸發,並且會被放到隊列的最後面。- 注意!!!注意!!!下面詳細解釋
- 事件:執行回調函數的第二行代碼(耗時的計算)。事件觸發後隊列的內容:
[Execute OnClick handler(lines 3-4), re-draw Status DIV with "Calculating" value]
。- 事件:執行回調函數的第三行代碼(計算結果輸出到結果 DIV )。事件觸發後隊列的內容:
[Execute OnClick handler(line 4), re-draw Status DIV with "Calculating" value, re-draw result DIV with result]
。- 事件:執行回調函數的第四行代碼(結果 DIV 的狀態改為 “DONE” )。事件觸發後隊列的內容:
[Execute OnClick handler, re-draw Status DIV with "Calculating" value, re-draw result DIV with result; re-draw Status DIV with "DONE" value]
。- 事件:執行回調函數隱含的
return
。從隊列中移除 “Execute OnClick handler”,然後執行隊列中的下一個任務。- 注意:由於我們已經完成了計算,3分鐘已經過去。重繪事件還沒有發生!!!
- 事件:重繪狀態 DIV 的內容為 “Calculating” 。把這個重繪任務從隊列中去掉。
- 事件:使用計算的結果重繪結果 DIV 。把這個重繪任務從隊列中去掉。
- 事件:重繪狀態 DIV 為 “Done”。把這個重繪任務從隊列中去掉。眼尖的讀者可能注意到在計算完結之後 “Calculating” 在微秒之間一閃而過。
因此,潛在的問題就是重繪狀態 DIV 這個事件被放到了隊列的最後,放到了耗時3分鐘的計算後面,所以這個重繪在計算完成前都沒有執行。
要解決這個問題,就要使用
setTimeout()
了。那麼怎樣解決?因為通過setTimeout
調用需要長時間執行的代碼的時候,其實是創建了兩個事件:setTimeout
自身的執行事件,和之後才進隊列的代碼執行事件。(由於 0 秒 timeout)So, to fix your problem, you modify your
onClick
handler to be TWO statements (in a new function or just a block withinonClick
):
改變狀態 DIV 的內容為 “Calculating... may take ~3 minutes” 。
執行
setTimeout()
,在0秒後執行LongCalc()
函數。
LongCalc()
函數基本上和上面的一樣,但明顯地,不用再在裡面改變狀態 DIV 的內容為 “Calculating” ,而且計算也不會立刻執行。所以呢,現在的事件順序和隊列會變成怎樣呢?
- 隊列:
[Empty]
- 事件:點擊按鈕。事件觸發後隊列的內容:
[Execute OnClick handler(status update, setTimeout() call)]
- 事件:執行onclick回調函數中的第一行(改變狀態 DIV 的值)。事件觸發後隊列的內容:
[Execute OnClick handler(which is a setTimeout call), re-draw Status DIV with new "Calculating" value]
。- 事件:執行onclick回調函數中的第二行(執行 setTimeout )。事件觸發後隊列的內容:
[re-draw Status DIV with "Calculating" value]
。隊列在0+秒內不會有新事件入棧。- 事件:0+秒之後timeout計時器完成計時。事件觸發後隊列的內容:
[re-draw Status DIV with "Calculating" value, execute LongCalc (lines 1-3)]
。- 事件:重繪狀態 DIV 的內容為 ”Calculating“。事件觸發後隊列的內容:
[execute LongCalc (lines 1-3)]
。注意,這次的重繪事件可能會在timeout計時器完成計時之前執行,不過這沒關系。- ...
萬歲 ! 狀態 DIV 在執行計算前成功更新為 “Calculating...” !!!下面是JSFiddle中解釋這個例子的代碼:http://jsfiddle.net/C2YBE/31/
HTML code:
<table border=1> <tr><td><button id='do'>Do long calc - bad status!</button></td> <td><div id='status'>Not Calculating yet.</div></td> </tr> <tr><td><button id='do_ok'>Do long calc - good status!</button></td> <td><div id='status_ok'>Not Calculating yet.</div></td> </tr> </table>
JavaScript code: (Executed on onDomReady and may require jQuery 1.9)
function long_running(status_div) { var result = 0; // Use 1000/700/300 limits in Chrome, // 300/100/100 in IE8, // 1000/500/200 in FireFox // I have no idea why identical runtimes fail on diff browsers. for (var i = 0; i < 1000; i++) { for (var j = 0; j < 700; j++) { for (var k = 0; k < 300; k++) { result = result + i + j + k; } } } $(status_div).text('calclation done'); } // Assign events to buttons $('#do').on('click', function () { $('#status').text('calculating....'); long_running('#status'); }); $('#do_ok').on('click', function () { $('#status_ok').text('calculating....'); // This works on IE8. Works in Chrome // Does NOT work in FireFox 25 with timeout =0 or =1 // DOES work in FF if you change timeout from 0 to 500 window.setTimeout(function (){ long_running('#status_ok') }, 0); });
上面的解釋已經很清楚了,但還是有點抽象。為了更進一步的加深對這個原理的理解,我個人使用Chrome的Timeline工具再進行一次分析,也看看有沒有什麼新的發現。
為了使數據更加清晰,我把上面js中的jQuery代碼都更換為原生的api。流程內容其實什麼都沒有改變:
var status_ok = document.getElementById('status_ok');
var do_ = document.getElementById('do');
var status = document.getElementById('status');
var do_ok = document.getElementById('do_ok');
function long_running(status_div) {
var result = 0;
// Use 1000/700/300 limits in Chrome,
// 300/100/100 in IE8,
// 1000/500/200 in FireFox
// I have no idea why identical runtimes fail on diff browsers.
for (var i = 0; i < 1000; i++) {
for (var j = 0; j < 700; j++) {
for (var k = 0; k < 300; k++) {
result = result + i + j + k;
}
}
}
document.getElementById(status_div).innerText = 'calclation done';
}
// Assign events to buttons
do_.onclick = function() {
status.innerText = 'calculating....';
long_running('status');
};
do_ok.onclick = function() {
status_ok.innerText = 'calculating...';
window.setTimeout(function() {long_running('status_ok')}, 0);
};
接下來再放上Timeline的兩張事件記錄圖,左邊為沒有使用setTimeout的,右邊為使用了setTimeout的:
先看看沒有使用setTimeout時的事件記錄:
document.getElementById(status_div).innerText = 'calclation done';
。這份記錄和stackoverflow中的解釋基本吻合,但還記得上面說過這樣一句嗎:
眼尖的讀者可能注意到在計算完結之後 “Calculating” 在微秒之間一閃而過。
實際情況是用戶永遠沒可能看到 “Calculating” 這個狀態,因為浏覽器的優化功能,把兩個重繪操作合並成一個了。
接下來看看使用了setTimeout的情況:
status_ok.innerText = 'calculating...';
引起的重繪操作。document.getElementById(status_div).innerText = 'calclation done';
引起的重繪操作。因此,我們就可以很確定的說,setTimeout( , 0)的作用其實就是在進行復雜計算前,騰出一點時間讓浏覽器可以完成重繪相關的Layout、Paint等操作。
不知道大家看到這裡有沒有這樣一個疑問:setTimeout( ,0)騰出的時間一定足夠讓浏覽器執行Layout、Update Layer Tree和Paint等一連串的動作嗎?先給出一個答案,不一定!
在這裡我繼續拋出一張圖,這張圖是我用上面一模一樣的代碼記錄出來的(使用setTimeout的情況下):
大家注意到紅框裡面的內容了嗎,浏覽器要繪制 “calculating...” 的最後一步Paint事件前,Timer計時器倒計時完畢,執行計算代碼了!所以最終都沒有Paint出來!執行完計算之後,直接合並重繪操作,顯示內容 “calclation done” 了。所以這次即使是用了setTimeout( , 0)我也是看不到 “calculating...” 這個狀態的。
所以為了保證每次的顯示效果都正常,大家可以把setTimeout( , 0)中的倒計時間設置更久,例如20、30又或者200、300。具體應該是多少需要根據我們重繪DOM的復雜程度來決定。
其實上面我給出的iScroll文檔說明中也說明過這個問題:
Consider that if you have a very complex HTML structure you may give the browser some more rest and raise the timeout to 100 or 200 milliseconds.
This is generally true for all the tasks that have to be done on the DOM. Always give the renderer some rest.
最後的總結:使用setTimeout( , 0)可以讓我們在進行復雜運算前騰出時間,使浏覽器完成渲染頁面相關的操作。進行復雜的渲染時,也要相對的把倒計時的時間延長,以保證有足夠的時間。
(大家還可以到我的Github上面獲得更好的閱讀體驗,因為博客園的markdown樣式太丑了。。。)
(如果對這篇文章有疑問,大家可以在下面評論,我會盡快給出答復。)