javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

Little heaven發表於2018-12-13

本章包括以下內容:

Web應用的生命週期步驟

  • 從HTML程式碼到Web頁面的處理過程
  • JavaScript程式碼的執行順序
  • 與事件互動
  • 事件迴圈 我們對JavaScript的探索從客戶端Web應用開始,其程式碼也在瀏覽器提供的引擎上執行。為了打好後續對JavaScript語言和瀏覽器平臺的學習基礎,首先我們要理解Web應用的生命週期,尤其要理解JavaScript程式碼執行在生命週期的所有環節。

本章會完整探索客戶端Web應用程式的生命週期,從頁面請求開始,到使用者不同種類的互動,最後至頁面被關閉。首先我們來看看頁面是如何從HTML程式碼建立的。然後我們將集中探討JavaScript程式碼的執行,它給我們的頁面提供了大量互動。最後我們會看看為了響應使用者的動作,事件是如何被處理的。在這一系列過程中,我們將探索很多Web應用的基礎概念,例如DOM(Web頁面的一種結構化表示方式)和事件迴圈(它決定了應用如何處理事件)。讓我們開始學習吧!

你知道嗎?

  • 瀏覽器是否總是會根據給定的HTML來渲染頁面呢?
  • Web應用一次能處理多少個事件?
  • 為什麼瀏覽器使用事件佇列來處理事件?

2.1 生命週期概覽

典型客戶端Web應用的生命週期從使用者在瀏覽器位址列輸入一串URL,或單擊一個連結開始。例如,我們想去Google的主頁查詢一個術語。首先我們輸入了URL,www.google.com,其過程如圖2.1所示。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

圖2.1 客戶端Web應用的週期從使用者指定某個網站地址(或單擊某個連結)開始, 由兩個步驟組成:頁面構建和事件處理

從使用者的角度來說,瀏覽器構建了傳送至伺服器(序號2)的請求,該伺服器處理了請求(序號3)並形成了一個通常由HTML、CSS和JavaScript程式碼所組成的響應。當瀏覽器接收了響應(序號4)時,我們的客戶端應用開始了它的生命週期。 由於客戶端Web應用是圖形使用者介面(GUI)應用,其生命週期與其他的GUI應用相似(例如標準的桌面應用或移動應用),其執行步驟如下所示:

1.頁面構建——建立使用者介面;

2.事件處理——進入迴圈(序號5)從而等待事件(序號6)的發生,發生後呼叫事件處理器。

應用的生命週期隨著使用者關掉或離開頁面(序號7)而結束。現在讓我們一起看一個簡單的示例程式:每當使用者移動滑鼠或單擊頁面就會顯示一條訊息。本章會始終使用這個示例,如清單2.1所示。

2.2 頁面構建階段

當Web應用能被展示或互動之前,其頁面必須根據伺服器獲取的響應(通常是HTML、CSS和JavaScript程式碼)來構建。頁面構建階段的目標是建立Web應用的UI,其主要包括兩個步驟:

1.解析HTML程式碼並構建文件物件模型 (DOM);

2.執行JavaScript程式碼。

步驟1會在瀏覽器處理HTML節點的過程中執行,步驟二會在HTML解析到一種特殊節點——指令碼節點(包含或引用JavaScript程式碼的節點)時執行。頁面構建階段中,這兩個步驟會交替執行多次,如圖2.3所示。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

2.2.1 HTML解析和DOM構建

頁面構建階段始於瀏覽器接收HTML程式碼時,該階段為瀏覽器構建頁面UI的基礎。通過解析收到的HTML程式碼,構建一個個HTML元素,構建DOM。在這種對HTML結構化表示的形式中,每個HTML元素都被當作一個節點。如圖2.4所示,直到遇到第一個指令碼元素,示例頁面都在構建DOM。

注意圖2.4中的節點是如何組織的,除了第一個節點——html根節點(序號1)以外,所有節點都只有一個父節點。例如,head節點(序號2)父節點為html節點(序號1)。同時,一個節點可以有任意數量的子節點。例如,html節點(序號1)有兩個孩子節點:head節點(序號2)和body節點。同一個元素的孩子節點被稱作兄弟節點。(head節點和body節點是兄弟節點)儘管DOM是根據HTML來建立的,兩者緊密聯絡,但需要強調的是,它們兩者並不相同。你可以把HTML程式碼看作瀏覽器頁面UI構建初始DOM的藍圖。為了正確構建每個DOM,瀏覽器還會修復它在藍圖中發現的問題。讓我們看下面的示例,如圖2.5所示。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程
圖2.4 當瀏覽器遇到第一個指令碼元素時,它已經用多個HTML元素(右邊的節點)建立了一個DOM樹

圖2.5展示了一個簡單的錯誤HTML程式碼示例,頁面中的head元素中錯誤地包含了一個paragraph元素。head元素的一般用途是展示頁面的總體資訊,例如,頁面標題、字元編碼和外部樣式指令碼,而不是用於類似本例中的定義頁面內容。故而這裡出現了錯誤,瀏覽器靜默修復錯誤,將段落元素放入了理應放置頁面內容的body元素中,構造了正確的DOM(如圖2.5右側)。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

HTML規範和DOM規範 當前HTML的版本是HTML5, 可以通過 html.spec.whatwg.org/ 檢視當前版本中有哪些可用特性。你若需要更易讀的文件,我們向你推薦Mozilla的HTML5指南,可通過https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5 檢視。

而另一方面,DOM的發展則相對緩慢。當前的DOM版本是DOM3,可以通過 dom.spec.whatwg.org/ 檢視該標準。同樣,Mozilla也為DOM提供了一份報告,可以通過https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model 進行檢視。

在頁面構建階段,瀏覽器會遇到特殊型別的HTML元素——指令碼元素,該元素用於包括JavaScript程式碼。每當解析到指令碼元素時,瀏覽器就會停止從HTML構建DOM,並開始執行JavaScript程式碼。

2.2.2 執行JavaScript程式碼

所有包含在指令碼元素中的JavaScript程式碼由瀏覽器的JavaScript引擎執行,例如,Firefox的Spidermonkey引擎, Chrome 和 Opera 的 V8引擎和Edge的(IE的)Chakra引擎。由於程式碼的主要目的是提供動態頁面,故而瀏覽器通過全域性物件提供了一個API 使JavaScript引擎可以與之互動並改變頁面內容。

JavaScript中的全域性物件 瀏覽器暴露給JavaScript 引擎的主要全域性物件是window物件,它代表了包含著一個頁面的視窗。window物件是獲取所有其他全域性物件、全域性變數(甚至包含使用者定義物件)和瀏覽器API的訪問途徑。全域性window物件最重要的屬性是document,它代表了當前頁面的DOM。通過使用這個物件,JavaScript程式碼就能在任何程度上改變DOM,包括修改或移除現存的節點,以及建立和插入新的節點。

讓我們看看清單2.1中所示的程式碼片段:

var first = document.getElementById("first");
複製程式碼

這個示例中使用全域性document物件來通過ID選擇一個元素,然後將該元素賦值給變數first。隨後我們就能在該元素上用JavaScript程式碼來對其作各種操作,例如改變其文字內容,修改其屬性,動態建立和增加新孩子節點,甚至可以從DOM上將該元素移除。

瀏覽器API 本書自始至終都會描述一系列瀏覽器內建物件和函式(例如,window和document)。不過很遺憾,瀏覽器所支援的全部特性已經超出本書探討JavaScript的範圍。幸好Mozilla為我們提供支援,通過https://developer.mozilla.org/en-US/docs/Web/API,你可以查詢到WebAPI介面的當前狀態。

對瀏覽器提供的基本全域性物件有了基本瞭解後,我們可以開始看看JavaScript程式碼中兩種不同型別的定義方式。

JavaScript程式碼的不同型別

我們已能大致區分出兩種不同型別的JavaScript程式碼:全域性程式碼和函式程式碼。清單2.2會幫你理解這兩種型別程式碼的不同。

清單2.2 JavaScript全域性程式碼和函式程式碼
<script>
   function addMessage(element, message){
     var messageElement = document.createElement("li");
     messageElement.textContent = message;     ⇽---  函式程式碼指的是包含在函式中的程式碼
     element.appendChild(messageElement);
   }
   var first = document.getElementById("first");
   addMessage(first, "Page loading");     ⇽---  全域性程式碼指的是位於函式之外的程式碼
</script>
複製程式碼

這兩類程式碼的主要不同是它們的位置:包含在函式內的程式碼叫作函式程式碼,而在所有函式以外的程式碼叫作全域性程式碼。

這兩種程式碼在執行中也有不同(隨後你將能看到一些其他的不同,尤其在第5章中)。全域性程式碼由JavaScript引擎(後續會做更多解釋)以一種直接的方式自動執行,每當遇到這樣的程式碼就一行接一行地執行。例如,在清單2.2中,定義addMessage函式的全域性程式碼片段使用內建方法getElementById來獲取ID為first的元素,然後再呼叫addMessage函式,如圖2.6所示,每當遇到這些程式碼就會一個個執行。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程
圖2.6 執行JavaScript程式碼時的程式執行流

反過來,若想執行函式程式碼,則必須被其他程式碼呼叫:既可以是全域性程式碼(例如,由於全域性程式碼的執行過程中執行了addMessage函式程式碼,所以addMessage函式被呼叫),也可以是其他函式,還可以由瀏覽器呼叫(後續會做更多解釋)。 在頁面構建階段執行JavaScript程式碼 當瀏覽器在頁面構建階段遇到了指令碼節點,它會停止HTML到DOM的構建,轉而開始執行JavaScript程式碼,也就是執行包含在指令碼元素的全域性JavaScript 程式碼 (以及由全域性程式碼執行中呼叫的函式程式碼)。讓我們看看清單2.1中的示例。

圖2.7顯示了在全域性JavaScript程式碼被執行後DOM的狀態。讓我們仔細看看這個執行過程。首先定義了一個addMessage函式:

function addMessage(element, message){
   var messageElement = document.createElement("li");
   messageElement.textContent = message;
   element.appendChild(messageElement);
   }
複製程式碼

然後通過全域性document物件上的getElementById方法從DOM上獲取了一個元素:

var first = document.getElementById("first");
複製程式碼

這段程式碼後緊跟著對函式addMessage 的呼叫:

addMessage(first, "Page loading");
複製程式碼

這條程式碼建立了一個新的li元素,然後修改了其中的文字內容,最後將其插入 DOM中。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程
圖2.7 當執行了指令碼元素中的JavaScript程式碼後,頁面中的DOM結構

這個例子中,JavaScript通過建立一個新元素並將其插入DOM節點修改了當前的DOM結構。一般來說,JavaScript 程式碼能夠在任何程度上修改DOM結構:它能建立新的節點或移除現有DOM節點。但它依然不能做某些事情,例如選擇和修改還沒被建立的節點。這就是為什麼要把script元素放在頁面底部的原因。如此一來,我們就不必擔心是否某個HTML元素已經載入為DOM。

一旦JavaScript引擎執行到了指令碼元素中(如圖2.7中的addMessage函式返回)JavaScript程式碼的最後一行,瀏覽器就退出了JavaScript執行模式,並繼續餘下的HTML構建為DOM節點。在這期間,如果瀏覽器再次遇到指令碼元素,那麼從HTML到DOM的構建再次暫停,JavaScript執行環境開始執行餘下的JavaScript程式碼。需要重點注意:JavaScript應用在此時依然會保持著全域性狀態。所有在某個JavaScript程式碼執行期間使用者建立的全域性變數都能正常地被其他指令碼元素中的JavaScript程式碼所訪問到。其原因在於全域性window物件會存在於整個頁面的生存期之間,在它上面儲存著所有的JavaScript變數。只要還有沒處理完的HTML元素和沒執行完的JavaScript程式碼,下面兩個步驟都會一直交替執行。

1.將HTML構建為DOM。

2.執行JavaScript程式碼。

最後,當瀏覽器處理完所有HTML元素後,頁面構建階段就結束了。隨後瀏覽器就會進入應用生命週期的第二部分:事件處理。

2.3 事件處理

客戶端Web 應用是一種GUI應用,也就是說這種應用會對不同型別的事件作響應,如滑鼠移動、單擊和鍵盤按壓等。因此,在頁面構建階段執行的JavaScript程式碼,除了會影響全域性應用狀態和修改DOM外,還會註冊事件監聽器(或處理器)。這類監聽器會在事件發生時,由瀏覽器呼叫執行。有了這些事件處理器,我們的應用也就有了互動能力。在詳細探討註冊事件處理器之前,讓我們先從頭到尾看一遍事件處理器的總體  思想。

2.3.1 事件處理器概覽

瀏覽器執行環境的核心思想基於:同一時刻只能執行一個程式碼片段,即所謂的單執行緒執行模型。想象一下在銀行櫃檯前排隊,每個人進入一支隊伍等待叫號並“處理”。但JavaScript則只開啟了一個營業櫃檯!每當輪到某個顧客時(某個事件),只能處理該位顧客。

你所需要的僅僅是一個在營業櫃檯(所有人都在這個櫃檯排隊!)的職員為你處理工作,幫你訂製全年的財務計劃。當一個事件抵達後,瀏覽器需要執行相應的事件處理函式。這裡不保證使用者總會極富耐心地等待很長時間,直到下一個事件觸發。所以,瀏覽器需要一種方式來跟蹤已經發生但尚未處理的事件。為實現這個目標,瀏覽器使用了事件佇列,如圖2.8所示。

所有已生成的事件(無論是使用者生成的,例如滑鼠移動或鍵盤按壓,還是伺服器生成的,例如Ajax事件)都會放在同一個事件佇列中,以它們被瀏覽器檢測到的順序排列。如圖2.8的中部所示,事件處理的過程可以描述為一個簡單的流程圖。

  • 瀏覽器檢查事件佇列頭;
  • 如果瀏覽器沒有在佇列中檢測到事件,則繼續檢查;
  • 如果瀏覽器在佇列頭中檢測到了事件,則取出該事件並執行相應的事件處理器(如果存在)。在這個過程中,餘下的事件在事件佇列中耐心等待,直到輪到它們被處理。

由於一次只能處理一個事件,所以我們必須格外注意處理所有事件的總時間。執行需要花費大量時間執行的事件處理函式會導致Web應用無響應!(如果聽起來還不太明確,不要擔心,第13章中我們還會學習事件迴圈,再看看它是如何損害Web應用在感受上的效能的)。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

圖2.8 客戶端Web應用的週期從使用者指定某個網站地址(或單擊某個連結)開始。 由兩個步驟組成:頁面構建和事件處理

重點注意瀏覽器在這個過程中的機制,其放置事件的佇列是在頁面構建階段和事件處理階段以外的。這個過程對於決定事件何時發生並將其推入事件佇列很重要,這個過程不會參與事件處理執行緒。

事件是非同步的

事件可能會以難以預計的時間和順序發生(強制使用者以某個順序按鍵或單擊是非常奇怪的)。我們對事件的處理,以及處理函式的呼叫是非同步的。如下型別的事件會在其他型別事件中發生。

瀏覽器事件,例如當頁面載入完成後或無法載入時; 網路事件,例如來自伺服器的響應(Ajax事件和伺服器端事件); 使用者事件,例如滑鼠單擊、滑鼠移動和鍵盤事件; 計時器事件,當timeout時間到期或又觸發了一次時間間隔。 Web應用的JavaScript程式碼中,大部分內容都是對上述事件的處理!

事件處理的概念是Web應用的核心,你在本書中的例子會反覆看到:程式碼的提前建立是為了在之後的某個時間點執行。除了全域性程式碼,頁面中的大部分程式碼都將作為某個事件的結果執行。

在事件能被處理之前,程式碼必須要告知瀏覽器我們要處理特定事件。接下來看看如何註冊事件處理器。 ####2.3.2 註冊事件處理器 前面已經講過了,事件處理器是當某個特定事件發生後我們希望執行的函式。為了達到這個目標,我們必須告知瀏覽器我們要處理哪個事件。這個過程叫作註冊事件處理器。在客戶端Web應用中,有兩種方式註冊事件。

  • 通過把函式賦給某個特殊屬性;
  • 通過使用內建addEventListener方法。 例如,編寫如下程式碼,將一個函式賦值給window物件上的某個特定屬性onload:
window.onload = function(){};
複製程式碼

通過這種方式,事件處理器就會註冊到load事件上(當DOM已經就緒並全部構建完成,就會觸發這個事件)。(如果你對賦值操作符右邊的記法有些困惑,不要擔心,隨後的章節中我們會細緻地講述函式)類似,如果我們想要為在文件中body元素的單擊事件註冊處理器,我們可以輸入下述程式碼:

document.body.onclick = function(){};
複製程式碼

把函式賦值給特殊屬性是一種簡單而直接的註冊事件處理器方式。但是,我們並不推薦你使用這種方式來註冊事件處理器,這是因為這種做法會帶來缺點:對於某個事件只能註冊一個事件處理器。也就是說,一不小心就會將上一個事件處理器改寫掉。幸運的是,還有一種替代方案:addEventListener方法讓我們能夠註冊儘可能多的事件,只要我們需要。如下清單使用了清單2.1中的示例,向你展示這種便捷的用法。

清單2.3 註冊事件處理器
<script>
   document.body.addEventListener("mousemove", function() {    ⇽---  為mousemove事件註冊處理器
      var second = document.getElementById("second");
      addMessage(second, "Event: mousemove");
   });
   document.body.addEventListener("click", function(){    ⇽---  為click事件註冊處理器
      var second = document.getElementById("second");
      addMessage(second, "Event: click");
   });
</script>
複製程式碼

本例中使用了某個HTML元素上的內建的方法addEventListener,並在函式中指定了事件的型別(mousemove事件或click)和事件的處理器。這意味著當滑鼠在頁面上移動後,瀏覽器會呼叫該函式新增一條訊息到ID為second的list元素上,"Event: mousemove"(類似,當body被單擊時,"Event: click"也會被新增到同樣的元素上)。 現在你學習瞭如何建立事件處理器,讓我們回憶下前面看到的簡單流程圖,然後仔細看看事件是如何被處理的。

2.3.3 處理事件

事件處理背後的主要思想是:當事件發生時,瀏覽器呼叫相應的事件處理器。如前面提到的,由於單執行緒執行模型,所以同一時刻只能處理一個事件。任何後面的事件都只能在當前事件處理器完全結束執行後才能被處理!

讓我們回到清單2.1中的應用。圖2.9展示了在使用者快速移動和單擊滑鼠時的執行情況。

讓我們看看這裡發生了什麼。為了響應使用者的動作,瀏覽器把滑鼠移動和單擊事件以它們發生的次序放入事件佇列:第一個是滑鼠移動事件,第二個是單擊事件序號1。

在事件處理階段中,事件迴圈會檢查佇列,其發現佇列的前面有一個滑鼠移動事件,然後執行了相應的事件處理器序號2。當滑鼠移動事件處理器處理完畢後,輪到了等待在佇列中的單擊事件。當滑鼠移動事件處理器函式的最後一行程式碼執行完畢後,JavaScript引擎退出事件處理器函式,滑鼠移動事件完整地處理了序號3,事件迴圈再次檢查佇列。這一次,在佇列的最前面,事件迴圈發現了滑鼠單擊事件並處理了該事件。一旦單擊處理器執行完成,佇列中不再有新的事件,事件迴圈就會繼續迴圈,等待處理新到來的事件。這個迴圈會一直執行到使用者關閉了Web應用。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

圖2.9 兩個事件——滑鼠移動和單擊中的事件處理階段示例

現在我們有了個總體的認識,理解了事件處理階段的所有步驟。讓我們看看這個過程是如何影響DOM的(如圖2.10所示)。執行滑鼠移動處理器時會選擇第二個列表元素,其ID為second。

javascript忍者祕籍(第二版)翻譯學習 第2章 執行時的頁面構建過程

圖2.10 當滑鼠移動和滑鼠點選事件都處理完成後,例項應用的DOM樹結構

然後通過使用addMessage,使用文字“Event: mousemove”新增了一個新的列表項元素序號1。一旦滑鼠移動處理器結束後,事件迴圈執行單擊事件處理器,從而建立了另一個列表元素序號2,並附加在ID為second的第二個列表元素後。

對Web應用客戶端的生命週期有了清晰的理解後,本書的下一部分,我們會開始聚焦於JavaScript語言,理清函式的來龍去脈。

2.4 小結

  • 瀏覽器接收的HTML程式碼用作建立DOM的藍圖,它是客戶端Web應用結構的內部展示階段。
  • 我們使用JavaScript程式碼來動態地修改DOM以便給Web應用帶來動態行為。
  • 客戶端Web應用的執行分為兩個階段。
  1. 頁面構建程式碼是用於建立DOM的,而全域性JavaScript程式碼是遇到script節點時執行的。在這個執行過程中,JavaScript程式碼能夠以任意程度改變當前的DOM,還能夠註冊事件處理器——事件處理器是一種函式,當某個特定事件(例如,一次滑鼠單擊或鍵盤按壓)發生後會被執行。註冊事件處理器很容易:使用內建的addEventListener方法。
  2. 事件處理——在同一時刻,只能處理多個不同事件中的一個,處理順序是事件生成的順序。事件處理階段大量依賴事件佇列,所有的事件都以其出現的順序儲存在事件佇列中。事件迴圈會檢查事件佇列的隊頭,如果檢測到了一個事件,那麼相應的事件處理器就會被呼叫。

2.5 練習

1.客戶端Web應用的兩個生命週期階段是什麼?

2.相比將事件處理器賦值給某個特定元素的屬性上,使用addEventListener方法來註冊事件處理器的優勢是什麼?

3.JavaScript引擎在同一時刻能處理多少個事件?

4.事件佇列中的事件是以什麼順序處理的?

相關文章