近期在研究異步編程的我對於setTimeout之類的東西異常敏感。在SegmentFault上看到了一個問題《關於SetTimeout時間設為0時》:提問者讀了一篇文章,原文解釋setTimeout延遲時間為0時會發生的事情,提問者提出了幾個文章中的幾個疑點。讀了那篇文章之后發現原文的作者對於setTimeout的理解和自己的認知有點出入,於是編寫了相關測試的代碼以求答案。最終編寫了這篇文章。
JavaScript - 前端開發交流群:377786580
起因
上午在SegmentFault上看到了這個問題《關於SetTimeout 時間設為0時》(注:SegmentFault正在調整備案,如不能訪問,請點擊這里),原提問者注明了問題來源:《JS setTimeout延遲時間為0的詳解》。這個問題來源也是轉載的,我后來找到了出處。
在問題來源的那篇的文章中(后者),講述了JS是單線程引擎:它把任務放到隊列中,不會同步去執行,必須在完成一個任務后才開始另外一個任務。
而后,轉載的那篇文章列出並補充了原文的栗子:
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>setTimeout</title>
<script type="text/javascript">
function get(id) {
return document.getElementById(id);
}
window.onload = function () {
//第一個例子:未使用setTimeout
get('makeinput').onmousedown = function () {
var input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('value', 'test1');
get('inpwrapper').appendChild(input);
input.focus();
input.select();
}
//第二個例子:使用setTimeout
get('makeinput2').onmousedown = function () {
var input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('value', 'test1');
get('inpwrapper2').appendChild(input);
//setTimeout
setTimeout(function () {
input.focus();
input.select();
}, 0);
}
//第三個例子,onkeypress輸入的時候少了一個值
get('input').onkeypress = function () {
get('preview').innerHTML = this.value;
}
}
</script>
</head>
<body>
<h1><code>setTimeout</code></h1>
<h2>1、未使用 <code>setTimeout</code></h2>
<button id="makeinput">生成 input</button>
<p id="inpwrapper"></p>
<h2>2、使用 <code>setTimeout</code></h2>
<button id="makeinput2">生成 input</button>
<p id="inpwrapper2"></p>
<h2>3、另一個例子</h2>
<p>
<input type="text" id="input" value="" /><span id="preview"></span>
</p>
</body>
</html>
代碼運行實例請戳這里。
原文中有這么一段話,描述的有點抽象:
JavaScript引擎在執行onmousedown時,由於沒有多線程的同步執行,不可能同時去處理剛創建元素的focus 和select方法,由於這兩個方法都不在隊列中,在完成onmousedown后,JavaScript 引擎已經丟棄了這兩個任務,正如第一種情況。而在第二種情況中,由於setTimeout可以把任務從某個隊列中跳脫成為新隊列,因而能夠得到期望的結果。
我看到這里就覺得非常不對勁了。因為按照這種任務會被丟棄的說法,那么只要在事件觸發的函數中再觸發其他的事件都會被丟棄,瀏覽器是絕對不會這么做的,於是我編寫了測試代碼:
window.onload = function () {
//第一個例子:未使用setTimeout
get('makeinput').onmousedown = function () {
var input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('value', 'test1');
get('inpwrapper').appendChild(input);
//按照文中的理論,這里的click不會被觸發,但它卻成功觸發了
get('inpwrapper').click();//觸發了inpwrapper的onclick事件
}
get('inpwrapper').onclick = function () {
alert('linkFly');
};
}
下面的onclick()最終是執行了:彈出了"linkFly"。
而在轉載的文中為了引人深思,又提出了第三個例子:
在此,你可以看看例子 3,它的任務是實時更新輸入的文本,現在請試試,你會發現預覽區域總是落后一拍,比如你輸 a, 預覽區並沒有出現 a, 在緊接輸入b時,a才不慌不忙地出現。
而文中最后留給大家的思考的問題,解決方案就是使用setTimeout再次調整瀏覽器的代碼任務運行隊列。
var domInput = get('input');
domInput.onkeypress = function () {
setTimeout(function () {
//第三個例子的問題就這樣就會被解決
get('preview').innerHTML = domInput.value;
})
}
原文和轉載的文章中都對setTimeout(fn,0)進行了思考,但原文指出的問題本質漏洞百出,所以才出了這篇文章,我們的正文,現在開始。
單線程的JavaScript
首先我們來看瀏覽器下的JavaScript:
瀏覽器的內核是多線程的,它們在內核制控下相互配合以保持同步,一個瀏覽器至少實現三個常駐線程:javascript引擎線程,GUI渲染線程,瀏覽器事件觸發線程。
- javascript引擎是基於事件驅動單線程執行的,JS引擎一直等待着任務隊列中任務的到來,然后加以處理,瀏覽器無論什么時候都只有一個JS線程在運行JS程序。
- GUI渲染線程負責渲染瀏覽器界面,當界面需要重繪(Repaint)或由於某種操作引發回流(reflow)時,該線程就會執行。但需要注意 GUI渲染線程與JS引擎是互斥的,當JS引擎執行時GUI線程會被掛起,GUI更新會被保存在一個隊列中等到JS引擎空閑時立即被執行。
- 事件觸發線程,當一個事件被觸發時該線程會把事件添加到待處理隊列的隊尾,等待JS引擎的處理。這些事件可來自JavaScript引擎當前執行的代碼塊如setTimeOut、也可來自瀏覽器內核的其他線程如鼠標點擊、AJAX異步請求等,但由於JS的單線程關系所有這些事件都得排隊等待JS引擎處理。(當線程中沒有執行任何同步代碼的前提下才會執行異步代碼)
js的單線程在這一段面試代碼中尤為明顯(理解即可,請不要嘗試...瀏覽器會假死的):
var isEnd = true;
window.setTimeout(function () {
isEnd = false;//1s后,改變isEnd的值
}, 1000);
//這個while永遠的占用了js線程,所以setTimeout里面的函數永遠不會執行
while (isEnd);
//alert也永遠不會彈出
alert('end');
在我工作中對js的認識,個人認為js的任務單位是函數。即,一個函數表示着一個任務,這個函數沒有執行結束,則在瀏覽器中當前的任務即沒有結束。
上面的代碼中,當前任務因為while的執行而造成永遠無法執行,所以后面的setTimeout也永遠不會被執行。它在瀏覽器的任務隊列中如圖所示:
setTimeout背后意味着什么
這篇文章一直在使用setTimeout為我們展現和理解js單線程的設計,只是它錯誤的使用了Event來進行演示,並過度解讀了Event。
這里原文和轉載的文章忽略了這些基礎的事件觸發,而且也偏偏挑了兩套本身設計就比較復雜的API:onmouseXXX系和onkeyXXX系。
onKeyXXX系的API觸發順序如圖:
而我個人所理解它們對應的功能:
- onkeydown - 主要獲取和處理當前按下按鍵,例如按下Enter后進行提交。在這一層,並沒有更新相關DOM元素的值。
- onkeypress - 主要獲取和處理長按鍵,因為onkeypress在長按鍵盤的情況下會反復觸發直到釋放,這里並沒有更新相關DOM元素的值,值得注意的是:keypress之后才會更新值,所以在長按鍵盤反復觸發onkeypress事件的時候,后一個觸發的onkeypress能得到上一個onkeypress的值。所以出現了onkeypress每次取值都會是上一次的值而不是最新值。
- onkeyup - 觸發onkeyup的DOM元素的值在這里已經更新,可以拿到最新的值,所以這里主要處理相關DOM元素的值。
流程就是上面的圖畫的那樣:
onkeydown => onkeypress => onkeyup
使用了setTimeout之后,流程應該是下面這樣子的:
onkeydown => onkeypress => function => onkeyup
使用setTimeout(fn,0)之后,在onkeypress后面插入了我們的函數function。上面所說,瀏覽器在onkeypress之后就會更新相關DOM元素的狀態(input[type=text]的value),所以我們的function里面可以拿到最新的值。
所以我們在onkeypress里面掛起setTimeout能拿到正確的值,下面的代碼可以測試使用setTimeout(fn,0)之后的流程:
window.onload = function () {
var domInput = get('input'), view = get('preview');
//onkeypress兼容性和說明:http://www.w3school.com.cn/jsref/jsref_events.asp
domInput.onkeypress = function () {
setTimeout(function () {
//這個函數在keypress之后,keyup之前執行
console.log('linkFly');
});
};
domInput.onkeyup = function () {
console.log('up');
};
};
然后我們再來談談原代碼中的示例1和示例2,示例1和示例2的區別在這里:
//示例1
input.focus();
input.select();
//示例2
setTimeout(function () {
input.focus();
input.select();
}, 0);
原文章中說示例1的focus()和select()在onmousedown事件中被丟棄,從而導致了沒有選中,但原文的作者忽略了他注冊的事件是:onmousedown。
我們暫且不討論onmouseXXX系的其他API,我們僅關注和點擊相關的,它們的執行順序是:
- mousedown - 鼠標按鈕按下
- mouseup - 鼠標按鈕釋放
- click - 完成單擊
我們在onmousedown里面新建了input,並且選中input的值(調用了input.focus(),input.select())。
那么為什么沒有被選中呢?這樣,我們來做一次測試,看看我們的onfocus到底是被丟棄了,還是觸發了。我們把原文的代碼進行改寫:
window.onload = function () {
var makeBtn = get('makeinput');
//觀察onmouseXXX系完成整個單擊的順序
makeBtn.onmousedown = function (e) {
console.log(e.type);
var input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('value', 'test1');
get('inpwrapper').appendChild(input);
input.onfocus = function () {//觀察我們新生成的input什么時候獲取焦點的,或者它有沒有像原文作者說的那樣被丟棄了
console.info('input focus');
};
input.focus();
input.select();
}
makeBtn.onclick = function (e) {
console.log(e.type);
};
makeBtn.onmouseup = function (e) {
console.log(e.type);
};
makeBtn.onfocus = function () {//觀察我們生成按鈕什么時候獲取焦點的
console.log('makeBtn focus');
}
};
代碼運行的結果是這樣的:
我們的input focus執行了——那么它為什么沒有獲取到焦點呢?我們再看看后面執行的函數:我們點擊的按鈕,在mousedown之后,才獲得焦點,也就是說:我們的input本來已經得到了focus(),但在onmousedown之后,我們點擊的按鈕才遲遲觸發了自己的onfocus(),導致我們的input被覆蓋。
我們再加上setTimeout進行測試:
window.onload = function () {
var makeBtn = get('makeinput');
makeBtn.onmousedown = function (e) {
console.log(e.type);
var input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('value', 'test1');
get('inpwrapper').appendChild(input);
input.onfocus = function () {
console.info('input focus');
};
//我們加上setTimeout,看看會發生什么
setTimeout(function () {
input.focus();
input.select();
});
}
makeBtn.onclick = function (e) {
console.log(e.type);
};
makeBtn.onmouseup = function (e) {
console.log(e.type);
};
makeBtn.onfocus = function () {
console.log('makeBtn focus');
}
};
執行結果是這樣:
可以看見當我們點擊"生成"按鈕的時候,按鈕的focus正確的執行了,然后才執行了input focus。
在示例1中,我們在onmousedown()中執行了input.focus()導致input得到焦點,而onmousedown之后,我們點擊的按鈕才遲遲得到了自己的焦點,造成了我們input剛拿到手還沒焐熱的焦點被轉移。
而示例2中的代碼,我們延遲了焦點,當按鈕獲得焦點之后,我們的input再把焦點搶過來,所以,使用setTimeout(fn,0)之后,我們的input可以得到焦點並選中文本。
這里值得思考的focus()的執行時機,根據這次測試觀察,發現focus事件好像掛載在mousedown之內的最后面,而不是直接掛在mousedown的后面。它和mousedown仿佛是一體的。
我們使用setTimeout之前的任務流程是這樣的(->表示在上一個任務中,=>表示在上一個任務后):
onmousedown -> onmousedown中執行了input.focus() -> button.onfocus => onmouseup => onclick
而我們使用了setTimeout之后的任務流程是這樣的:
onmousedown -> button.onfocus => input.focus => onmouseup => onclick
而從上面的流程上我們得知了另外的消息,我們還可以把input.focus掛在mouseup和click下,因為在這些事件之前,我們的按鈕已經得到過焦點了,不會再搶我們的焦點了。
makeBtn.click = function (e) {
console.log(e.type);
var input = document.createElement('input');
input.setAttribute('type', 'text');
input.setAttribute('value', 'test1');
get('inpwrapper').appendChild(input);
input.onfocus = function () {//觀察我們新生成的input什么時候獲取焦點的
console.info('input focus');
};
input.focus();
input.select();
}
我們應該認識到,利用setTimeout(fn,0)的特性,可以幫助我們在某些極端場景下,修正瀏覽器的下一個任務。
到了這里,我們已經可以否定原文所說的:"JavaScript引擎已經丟棄了這兩個任務"。
我仍然相信,瀏覽器是愛我們的(除了IE6和移動端一些XXOO的瀏覽器!!!!)瀏覽器並不會平白無故的丟棄我們辛勞寫下的代碼,多數時候,只是因為我們沒有看見背后的真相而已。
當我們踏進計算機的世界寫下"hello world"的時候就應該堅信,這個二進制的世界里,永遠存在真相。
參考和引用
JavaScript - 前端開發交流群:377786580