Javascript的時序和同步機制

<譯自http://dev.opera.com/articles/view/timing-and-synchronization-in-javascript/>

時序問題是Javascript應用程序中最難解錯誤的來源之一。在開發中從來不出現的問題可能在終端用戶的慢速網絡和機器上冒出來。這些問題也可能是間斷性的,難以重現。

考慮一個簡單的例子:一個按鈕,以及與之關聯的事件處理函數。當按鈕被按下時,其它元素被修改。如果用戶在將被修改的元素被解析生成前按下按鈕,腳本將會失敗。開發人員可能不會注意到這個問題,因爲他是在快速的網絡和機器環境下測試的,在這種環境下整個頁面的解析和生成只在一瞬間就完成了。

本文將嘗試解釋當前瀏覽器中各種跟時間有關的Javascript問題。

基本知識

瀏覽器窗口有一個線程來執行HTML的解析、事件派發以及Javascript代碼的執行。Javascript代碼以下面兩種方式之一執行:

  1. 在<script>標籤中的頂級代碼在頁面載入時執行。
  2. 事件處理函數在事件派發過程中被處理。

由瀏覽器發起這兩種執行,它們都在同一個線程中運行,任何時候都只有一個代碼單元在運行。

基本上瀏覽器由事件驅動(代碼通過響應用戶輸入而運行),但在頁面載入期間,還受解析線程的驅動。

事件流

事件是瀏覽器發出的一個信號,表明窗口正要發生,或者已經發生了某件事。

事件處理程序是一個Javascript函數,註冊到一個對象和事件名上。當對應的事件發生在註冊的對象上時,事件處理程序即被調用。

所有的事件處理程序都是順序執行,在任何一個事件完全處理完畢(包括事件沿DOM樹冒泡及缺省響應)之前,下一下事件不會被處理。

缺省響應

缺省響應是瀏覽器事件模型中的最有意思的一環:它發生在沒有任何Javascript代碼需要執行的時候。比如,一個鏈接上的點擊事件的缺省響應是導航到該URL上。單選框按鈕的點擊事件的缺省響應是選中該單選框,等等。

缺省響應並不是一個事件處理程序,我們不能移除它或者改寫它,這一點是與我們的自定義事件處理程序不同的地方。但是,我們可以在事件派發過程中使用'preventDefault'函數來取消缺省響應的執行(在IE中通過event.returnValue來取消)。即使缺省響應被取消,相關的自定義事件處理器仍然會被激發,只是在此之後,缺省響應不再執行。

派發序列

象load這樣的事件只發生在相應的對象上('window'或者'document')。然而,針對文檔裏特定元素的事件,則在它們祖先級別節點的事件處理程序也會被激發。
當事件在目標節點上被激發之前,存在一個'捕獲(cpaturing)'階段,即位於目標節點的祖先節點上的那些節點可能截獲事件。然而事件捕獲並不是在所有的瀏覽器上都能可靠工作。
事件'起泡',即當它們在目標元素上激發之後,會依次在DOM樹上的祖先節點上也得到激發,直到遇到document對象,則在所有瀏覽器上適用。
事件在所有相關元素上被激發以及執行缺省響應,被稱作事件派發。
對非冒泡事件,派發順序是:
  1. 捕獲階段:事件從上至下發生在所有祖先節點上。
  2. 事件在目標元素上激發,即註冊在該元素上的事件處理程序被執行(以不確定的順序)。
  3. 缺省動作被執行(如果沒被某個處理器取消掉)。

對冒泡事件,派發順序是:

  1. 捕獲階段:事件從上至下發生在所有祖先節點上。
  2. 事件在目標元素上激發。
  3. 冒泡階段:事件在所有祖先元素上被激發,從下而上。
  4. 執行缺省響應(除非被某個事件處理器取消)。

通過調用'stopPropagation()(在IE中是cancelBubble())可以防止事件繼續冒泡,但缺省響應仍然會執行。因此取消冒泡和取消缺省響應是分開和獨立的操作。

事件模型的每個階段在DOM 3 Events規範中有詳細地解釋。

存在着一些奇怪的情況,缺省響應會在事件派發之前產生--但可能依然被取消。舉例來說,當一個單選框被點擊一,勾選標誌被生成,'checked'屬性也在事件派發前就已更新。然而,如果缺省動作在事件派發過程中被取消,那麼該update會在缺省響應階段被回滾:勾選標誌被移除,'checked'屬性被翻轉回去。

批量事件

一些事件成批到來:用戶的一次輸入會導致多個事件被派發。比如,當焦點從一個表單字段移到另一個字段時,前一個字段上發生'blur'(失去焦點)事件,後一個字段上發生'focus'(焦點移入)事件。兩個事件在概念上是同時發生(因爲它們是對同一個用戶輸入的響應),但實際上還是順序派發。

如果是冒泡事件,則只有該事件的捕獲/冒泡階段和缺省響應完全執行以後,下一個事件纔會被派發。

該順序的一個例子是鼠標點擊某個按鈕並釋放的動作。此時'mouseup'事件和'click'事件都被激發,順序是:

'Mouse-up'事件的派發:

  1. 'click'事件的派發階段--所有捕獲事件處理器都已執行。
  2. 目標元素:事件在目標元素上激發。
  3. 'mouseup'的冒泡階段:事件在所有祖先級元素上激發。
  4. 缺省響應(本事件無缺省響應)。

'Click'事件的派發

  1. 捕獲階段-所有捕獲處理器都已執行。
  2. 目標元素:事件在目標元素上激發。
  3. 冒泡階段:'click'事件在所有祖先級元素上激發。
  4. 'click'的缺省響應被執行。

在事件派發中只能取消本事件的缺省響應。例如,'mouseup'事件處理程序取消了缺省響應(無意義,因爲'mouseup'沒有缺省響應)。這並不會阻止'click'事件的激發,因爲它們是不同的事件。

然而,缺省響應可能引起其它事件。假如在'submit'按鈕上發生一個'click'事件,其缺省動作是提交當前的表單,因此進而產生一個'submit'事件。因此,取消'click'事件上的缺省響應也一併取消了下一個事件。

事件隊列

事件的派發是爲了響應用戶輸入(通過鼠標或者鍵盤),或者象頁面載入完成這樣的內部事件。然而,輸入和派發是兩個異步操作。

用戶輸入可能發生在事件處理器正在執行的時候。這時動作被緩衝起來,當事件派發器再次執行時,這些被緩衝的動作對應的事件被一一派發。事件總是以正確的順序派發,但在動作和事件派發之間可能會有明顯的延遲,如果某些事件處理器代碼比較花時間的話。

當事件處理器執行時,IE和Mozilla完全停止對用戶的響應,就連工具條也會看上去被鎖住一樣。儘管用戶可能可以進行某些操作,比如點擊按鈕,但這些動作都只是被緩衝起來,而且不會有任何可見的反饋。這可能看上去讓用戶困惑,他們極有可能重複點擊按鈕,從而造成不期望的後果。甚至用戶可能認爲瀏覽器已經崩潰,因爲它似乎停止響應。

Opera比起來更能響應用戶操作,比如如果在腳本還在執行時點擊了一個按鈕,它能對用戶的操作給出可見的反饋。然而,事件仍然被存入緩衝隊列,仍然要順序派發,就象其它瀏覽器所做的那樣。因此缺省響應直到事件處理完畢之前,也不會被執行。同樣,這也會使用戶困惑,儘管可能不如象IE和Mozilla那樣。

基本準則是,事件處理代碼不能佔用太多時間。特別要當心異步的XMLHttpRequest請求,因爲它們可能造成顯著的延遲,從而凍住瀏覽器或者文檔窗口。

嵌套事件

有一個特別的場合,事件不是序列化的,而是嵌套的。如果在腳本中通過dispactchEvent(在IE中是fireEvent方法)方法來顯式地發出一個事件,該事件會被立即派發。只有當該事件(也包括缺省響應)處理完成後,原來的腳本纔會繼續運行。

同樣,DOM變更事件(這些事件在IE中不支持)也會被立即派發,並與DOM變更同步,比如當appendChild()被調用時。

渲染的時機

編程方式修改的DOM或者樣式並不會立即被渲染呈現給用戶。這一切取決於瀏覽器的實現。

例如,如果某元素的背景色被改變,DOM會立即反映這一修改(而且DOM修改事件會被立即派發且同步處理),但我們不能確知何時瀏覽器引擎會將該變化呈現在屏幕上。似乎Opera會立即將修改渲染出來,而Mozilla和IE則會等該事件處理完畢之後才做渲染。

Timeouts

方法setTimeout將指定的函數計劃在指定的時間以後被調度執行:

window.setTimeout(someFunc, 1000);

定時腳本在某種程度上象事件處理器一樣工作。儘管它們是響應某個超時,而非用戶輸入,但象用戶事件一樣被事件分派線程順序處理。

因此,你不能指望超時函數在指定的時間被運行。如果其它事件或者批量事件正在執行,超時腳本會被排入隊列等待。基本上我們可以確保該函數會在1秒中以後執行,但可能要等待的時間會長於1秒。

令入驚訝地由此產生了一個有用的功能。如果一個處理程序註冊爲超時0秒後執行,處理程序不會被立即執行,而是被立即存入隊列。它會在當前事件(包含缺省響應)執行完畢後立即執行。如果超時事件在一批事件的處理程序中間被創建(比如blur/focus,mouseup/click),超時事件會在該批事件完全完成後被調度。

<譯註:在“長時間運行的腳本”一節,作者演示了爲什麼這個功能實際上相當重要。>

非用戶事件

其它非用戶發起的事件有:

  • 頁面加載事件
  • 超時事件
  • 異步XMLHttpRequest操作數據接收完成時的回調

這些事件象用戶發起的其它事件一樣被放入事件分發隊列。這意味着,XMLHttpRequest響應處理程序不會在數據接收完成時立刻調用,而是被排在事件分發隊列中等待執行。

Alerts

警告對話框(以及相關聯的confirm/prompt對話框)有一些奇怪的特性。

當腳本發起對話框後,腳本被掛起,只到對話框關閉。從這個意義上講它們是同步執行的。腳本等待alert()函數返回後才能繼續運行。

微妙的是有一些瀏覽器在alert對話框出現並等待用戶操作時仍然允許事件分發。這意味着當腳本被掛起,等待alert()函數返回時,其它事件分發處理中的還可能有函數運行。

用戶接口事件,比如mouseup和click不會在alert對話框存在期間被激發,因爲alert對話框是模態對話框且捕獲所有用戶輸入。但非用戶發起的事件,比如頁面加載,超時事件和異步XMLHttpRequest事件仍然有可能被激活。

頁面載入

瀏覽器下載文檔時的同時也漸進式地解析和渲染頁面。

大多數外部資源,如圖片,插件媒體,是被異步地載入的。當解析器遇到img,embed,iframe和object標籤時,會產生一個新的線程。這個線程會在主頁面解析線程之外獨立地下載、解析和渲染該外部資源。在iframes中的頁面也是異步加載的。

外部樣式表是一個例外。一些瀏覽器象對待圖片一樣異步地下載它們,一些瀏覽器同步下載它們,以避免樣式表載入時全部重新渲染整個文檔。(這也防止了早期已顯示的內容在更換樣式表時閃爍)。換句話說,不要依賴這些特殊的行爲。

Javascript塊的執行

腳本元素被同步解析。當一個腳本元素指向外部鏈接時,頁面的解析工作暫停,直到該外部腳本完全下載並解析運行之後才恢復。

內聯腳本塊在結束標籤遇到時完成解析並執行。

腳本塊的執行

Javascript腳本分兩階段處理。先是解析,然後是執行。在解析階段,驗證代碼是否符合語言規範,如果有語法錯誤,腳本不會被執行。

在執行階段,所有頂級語句會被執行。頂級語句是指除開函數內部代碼以外的所有語句。頂級語句可能含有對同一塊代碼中函數的前向引用,由於函數的聲明已經在代碼解析階段被處理過了,所以下面的代碼可以工作:

<script>
  var x = getMagicNumber();
  function getMagicNumber() { return 117; }
</script>

然而,下面的代碼不會工作,因爲函數表達式的求值是在運行時完成的:

<script>
  var x = getMagicNumber(); // ERROR! getMagicNumber is undefined!
  var getMagicNumber = function() { return 117; }
</script>

下面的代碼也不能正常工作,因爲代碼段都是在遇到結束標籤時立即執行的:

<script>
  alert(getMessage());
</script>
<script>
  function getMessage() { return "Hello!"; }
</script>

使用document.write()

腳本可能使用document.write()直接產生HTML輸出。該輸出首先被緩存,直到該段腳本結束執行時才被解析。輸出中可能又包含腳本段,它們會在解析的過程中被執行。

生成的HTML輸出緊跟在當前腳本段的後面。

DOM構建

解析器在頁面加載時增量式地構建DOM樹。每遇到一個新標籤,一個空的元素就被插入到DOM樹中。當開始標籤解析完成後,一個非空標籤就被插入到DOM中<譯註:原文:An empty element is inserted in the DOM when the tag is parsed. A non-empty element is inserted when the opening tag is parsed.可能是指元素的屬性值指定在開始標籤中,因此當該標籤解析完成時,元素就不爲空。>例如,當解析器開始解析文檔內容時,body元素就在DOM中存在且可用了。

注意DOM不一定完全對應輸入的HTML內容。例如HTML和head標籤,即便它們不出現在HTML中,這些元素也會被構建在DOM中。

如果HTML源代碼無效,比如,title元素出現在body元素中,瀏覽器會重調DOM,使之有效。因此不能認爲DOM樹的構建是按序構建的。

延遲的代碼段加載

腳本的同步加載有一個缺點:如果文檔頭裏有太多代碼要下載和執行,那麼頁面的渲染可能就會有顯著地延遲。

爲了減輕這個副作用,我們可以使用script元素的defer屬性值。該屬性指示瀏覽器可以異步加載腳本。然而,我們不能肯定當腳本執行時,它是在頁面加載完成以前還是以後。Opera瀏覽器則完全忽略該屬性。

<script defer> 
   alert("this message will appear at some unpredictable time during page load"); 
</script>

延遲腳本不能使用document.write(),因爲它們不與解析器同步。

腳本塊總是按他們在文檔中出現的順序來執行,無論它們是否有着defer屬性。因此,如果沒有defer屬性的腳本元素在有defer屬性的元素後面,解析器必須先完成延遲腳本的加載和執行,然後才能進行非延遲腳本的解析和運行。這就消除了defer屬性的意義,因此必須將非延遲腳本始終放在延遲腳本的前面。

由於這些原因,defer屬性在決定代碼段運行時機上並不可靠,它僅僅允許部分瀏覽器在處理一個代碼段的同時繼續解析文檔。

漸進式渲染

頁面的視覺呈現的渲染並非與DOM構建同步。這個時機基本無法預言。取決於網絡速度和頁面大小,瀏覽器可能等到整體頁面完全下載完畢後再渲染,也可能一次渲染一小部分。

當頁面開始渲染時,用戶接口就開始響應用戶輸入事件。如果事件處理程序引用了還未生成的DOM元素,這就可能導致前向引用問題。

可能出錯的代碼示例:

<button 
οnclick="document.getElementById('lamp').backgroundColor = 'yellow'">
  Click here to turn on lamp!
</button>
<div id='lamp'>O</div>

問題可能發生在當用戶點擊按鈕時,lamp元素還沒有生成。事件處理程序應該避免引用在其後定義的元素。

在更爲複雜的用戶接口中,想要在頁面控制間避免前向引用可能是不切實際的。相反,所有控制應該一開始處於禁用狀態,直到'onload'事件處理程序來激活它們。此時可以確保所有頁面元素都已加載。

注意'onload'事件需要等待所有的圖片(以及楨等)完成加載。如果頁面上有一些大的圖片,這可能需要佔用一時間。一個變通方法是在頁面底部嵌入一個內聯腳本,來激活整個頁面。這將在頁面完成加載時得到執行,但不依賴外部資源的加載。

長時間運行的腳本

理想地,Javascript代碼不應該長時間運行,因爲它們會中斷用戶體驗。然而有時候可能無法避免這些長時間運行的腳本。在這種情況下,應該給用戶顯示一個“請等待”的消息或者進度條,來指示瀏覽器並沒有死掉。問題是這條消息必須在可能長時間運行的處理開始之前就要顯示。

下面是一段示例僞代碼:

headlineElement.innerHTML = "Please wait...";
performLongRunningCalculation();
headlineElement.innerHTML = "Finished!";

在IE和Mozilla中,“請等待”的消息並不會向用戶顯示,因爲該消息只在腳本結束時,瀏覽器纔會將其渲染到飛屏幕上。在Opera中,“請等待”消息則會在計算正在進行時顯示。

如果我們想要消息也能正常地在IE和Mozilla中顯示,我們必須將立即控制交還給UI,以便消息可以在計算開始前被渲染:

headlineElement.innerHTML = "Please wait...";
function doTheWork() {
   performLongRunningCalculation();
   headlineElement.innerHTML = "Finished!";
}
setTimeout(doTheWork, 0);

現在setTimout的技巧保證了消息在瀏覽器被阻塞前就能顯示出來。當然瀏覽器仍然可能在計算進行時被“凍”住,因此這個技巧並不是十分優雅。如果我們想要完全防止瀏覽器被“凍”住,則需要將計算進程分解成一些小的函數,並使用setTimeout將它們鏈接在一起。不過這樣立即就增加了代碼的複雜度。

競爭條件

每個窗口(以及楨)都有自己的消息隊列。

在Opera中,每個窗口都有自己的Javascript線程。在iframes中的窗口也是這樣。結果是在不同楨中激活的消息處理程序可能同時執行。如果這些同時執行的代碼共享了數據(比如頂層窗口的屬性),我們就有可能遇到競爭條件。

我不在此贅述競爭條件的危害,只想指出這可能導致非常令人困惑的錯誤。

解決方案之一是,讓消息處理函數始終在頂層窗口的消息處理隊列中排隊,即使它們是被其它楨激發的事件。

假設一個頁面含有一個iframe。這個iframe有一個頁面'onload'處理函數,將在頁面中執行。

// bad onload function in frame:
window.top.notifyFrameLoaded()

這樣做是危險的,因爲'onload'事件可能在包含頁面執行其它腳本時執行。該消息可以被放入隊列:

// good onload function in frame
window.parent.setTimeout(window.top.notifyFrameLoaded, 0)

這段代碼中重要的部分是使用setTimeout將消息處理排入父窗口消息隊列中。

關於時間的建議

  • 不要寫長時間運行的腳本
  • 不要使用同步的XMLHttpRequest
  • 不要使在不同楨中激活代碼訪問全局狀態。
  • 不要使用alert對話框來調試,因爲這有可能完全改變程序的邏輯。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章