《JavaScript 模式》讀書筆記(8)— DOM和瀏覽器模式1
在本書的前面章節中,我們主要集中關注於核心JavaScript(ECMAScript),而並沒有太多關注在瀏覽器中使用JavaScript的模式。本章將探索一些瀏覽器特定的模式,因為瀏覽器是使用JavaScript最為常見的環境。同時也是很多人不喜歡使用JavaScript的原因,他們認為JavaScript只是一種瀏覽器指令碼。考慮到在瀏覽器中存在很多前後矛盾的主機物件和DOM實現,這種想法也是可以理解的。很明顯通過使用一些較好的可以減少客戶端指令碼負擔的實踐技巧,可以獲益頗多。
在本章您將看到模式被劃分為幾類,包含DOM指令碼、事件處理、遠端指令碼、頁面載入JavaScript的策略和在產品網站上配置JavaScript的步驟等。
但是首先,讓我們簡單的從哲學角度來探索如何處理客戶端的指令碼。
一、關注分離
在網站應用程式的開發過程中主要關心如下三個內容:
內容(Content):HTML的文件。
表現(Presentation):指定文件外觀的CSS樣式。
行為(Behavior):處理使用者互動和文件各種動態變化的JavaScript。
將這三部分儘可能的相互獨立,可以改進將應用程式交付給大量各種使用者終端的效果,圖形化的瀏覽器、文字瀏覽器、針對殘疾使用者的輔助技術、移動裝置等。關注分離(separation of concerns)也體現了漸進增強(progressive enhancement)的思想,最簡單的使用者終端可以具有最基本的體現(僅能顯示HTML文件),並隨著使用者終端能力的改進而獲取更佳的使用者體驗。如果瀏覽器支援CSS,那麼使用者將可以看到文件更好的表現方式。如果瀏覽器支援JavaScript,那麼該文件更大程度上看起來像一個應用程式,並將獲取更多增強使用者體驗的特性。
在實際中,關注分離意味著:
- 通過將css關閉來測試頁面是否仍然可用,內容是否依然可讀。
- 將JavaScript關閉來測試頁面仍然可以執行其正常功能,所有的連結(不包含href = "#" 的例項)是否能夠正常工作,所有的表單可以正常工作並正確提交資訊。
- 使用例如headings和lists這樣與以上有意義的HTML元素。
JavaScript層(行為)應該是不引人注意的,也就是說,JavaScript層應該不會給使用者造成不便,例如在不支援JavaScript的瀏覽器中不會造成網頁不可用等問題,JavaScript應該是用來加強網頁功能,而不能成為網頁正常工作的必須元件。
常見的用於處理瀏覽器差異性的技術是特性檢測技術(capability detection)。該技術建議不要使用使用者代理來嗅探程式碼路徑,而應該在執行環境中檢查是否有所需的屬性或方法。通常將使用代理嗅探這種方法看作一種反模式。有時候這是不可避免的,但是應該在使用特性檢測技術無法獲得確定性結論時(或者會導致極大的效能損失時),不得已才使用代理嗅探。
// 反模式 if(navigator.userAgent.indexOf('MSIE') !== -1) { document.attachEvent('onclick',console.log); } // 比較好的做法 if(document.attachEvent) { document.attachEvent('onclick',console.log); } // 更具體的做法 if(typeof document.attachEvent !== 'undefined') { document.attachEvent('onclick',console.log); }
採用關注分離還有助於開發、維護、和升級現有Web應用程式,因為當發生故障時,可以知道去什麼地方排錯。當是JavaScript發生錯誤時,無需檢視HTML程式碼和CSS程式碼來查錯。
二、DOM指令碼
使用頁面的DOM樹是客戶端JavaScript最常用的任務。這也是頭痛的主要原因(JavaScript因此獲得一些不好的名聲),因為不同的瀏覽器在DOM方法的實現方面並不一致。這也是為什麼使用一個好的JavaScript類庫(該類庫可以抽象出不同瀏覽器的區別)可以顯著加快開發進度。
讓我們來看看在訪問和修改DOM樹時推薦的一些模式(主要是出於效能方面考慮)。
DOM訪問
dom訪問的代價是昂貴的,它是制約JavaScript效能的主要瓶頸。這因為dom通常是獨立於JavaScript引擎而實現的。從瀏覽器的視角看,採用該方法是有意義的,因為有的JavaScript應用程式可能根本就不需要DOM。而且除JavaScript以外的其他程式(例如IE中的VBScript)也可以用來和頁面的DOM共同工作。
總之DOM的訪問應該減少到最低。這意味著:
- 避免在迴圈中使用DOM訪問。
- 將DOM引用分配給區域性變數,並使用這些區域性變數。
- 在可能的情況下使用selector API。
- 當在HTML容器中重複使用時,快取重複的次數(參考第二章)。
請看如下範例,儘管第二種方式迴圈語句更長,但針對不同的瀏覽器,它會比第一種方法快上幾十倍到幾百倍。
// 反模式 for (var i = 0; i < 100; i+= 1) { document.getElementById('result').innerHTML += i + " ,"; } // 更好的方式,使用了局部變數 var i, content = " "; for (let i = 0; i < 100; i+= 1) { content += i + " ,"; } document.getElementById("result").innerHTML = content
接下來的一個片段中第二個範例是更好的使用方法(使用了局部變數風格),儘管其需要額外的一橫程式碼和一個變數:
// 反模式 var padding = document.getElementById("result").style.padding, margin = document.getElementById("result").style.margin; // 更好的做法 var style = document.getElementById("result").style, padding = style.padding, margin = style.margin;
可以採用如下方法來使用selector API:
document.querySelector("ul .selected");
document.querySelectorAll("#widget .class");
這些方法接受一個CSS選擇字串並返回一個匹配該選擇的DOM節點列表。該選擇方法在現在主流的瀏覽器(IE從8.0以後都支援)中都是支援的,並且會比使用其他DOM方法來自己實現選擇要快得多。最近一些最新版本的流行JavaScript庫利用了selector API,因此最好是使用個人喜好的最新版本的JavaScript庫。
為經常訪問的元素增加id屬性是一個很好的做法,因為document.getElementById(myid)是最簡單快捷查詢節點的方法。
操縱DOM
除了訪問DOM元素以外,通常還需要修改、刪除或增加DOM元素。更新DOM會導致瀏覽器重新繪製品目,也經常會導致reflow(也就是重新計算元素的幾何位置),這樣會帶來巨大的開銷。
通常的經驗法則是儘量減少更新DOM,這也就意味著將DOM的改變分批處理,並在“活動”文件書之外執行這些更新。
當需要建立一個相對比較大的子樹,應該在子樹完全建立之後再將子樹新增到DOM樹中。這時可以採用文件碎片(document fragment)技術來容納所有節點。
下面將介紹如何不立即新增節點:
// 反模式 // 在建立時立即新增節點 var p,t; p = document.createElement('p'); t = document.createTextNode('first paragraph'); p.appendChild(t); document.body.appendChild(p); p = document.createElement('p'); t = document.createTextNode('second paragraph'); p.appendChild(t); document.body.appendChild(p);
建立文件碎片來離線升級節點資訊是更好的做法。當將文件碎片新增到DOM樹時,不是將碎片本身新增到DOM樹中,而是將文件碎片的內容新增進DOM樹中。該操作是十分方便的。文件碎片是一種很好的方法,可以用來封裝許多節點資訊,甚至這些節點並沒有合適的父節點(例如,文章不在div元素範圍內)。
接下來是一個使用文件碎片的範例:
var p,t, frag; frag = document.createDocumentFragment(); p = document.createElement('p'); t = document.createTextNode('first paragraph'); p.appendChild(t); frag.appendChild(p); p = document.createElement('p'); t = document.createTextNode('second paragraph'); p.appendChild(t); frag.appendChild(p); document.body.appendChild(frag);
在這個範例中活動的文件僅僅更新了一次並觸發一次螢幕重繪。而如果採用之前的反模式,沒執行一個段落都會重繪一次。
在為DOM樹新增新節點時文件碎片是非常有用的。但在更新DOM現有的部分時,仍然可以批處理提交修改。具體方法是:為需要修改的子樹的根節點建立一個克隆景象,然後對該克隆景象做所有的修改操作操作,在完成修改操作後用克隆映象替換原來的子樹。
var oldnode = document.getElementById('result'), clone = oldnode.cloneNode(true); // 處理克隆映象... // 完成後: oldnode.parentNode.replaceChild(clone, oldnode);
事件
處理瀏覽器事件(例如單擊、滑鼠移動等)是瀏覽器指令碼領域中一個有許多不一致性並導致工作失敗的源頭。JavaScript庫可以減少為了支援IE(在IE9.0之前的版本)和符合W3C規範的實現所做的雙重工作。
讓我們重溫關於瀏覽器事件的要點,因為可能並不總是為簡單的網頁使用某個現有的庫,有可能還會建立自己的庫。
事件處理
通常事件處理是通過為元素附加事件監聽器來實現的,例如有一個按鈕,該按鈕在每次單擊後都會增加一次計數。可以增加一個內聯的onclick屬性,該屬性在所有的瀏覽器中都可以正常工作,但是該屬性會和關注分離和漸進增強有衝突。因此,應該爭取在JavaScript中附加監聽器,並放置於所有標記之外。
假定有如下標記:
<button id="clickme">Click me: 0</button>
可以為該節點的onclick屬性分配一個函式,但這種做法只能指定一個函式:
// 次優解決方案 var b = document.getElementById('clickme'), count = 0; b.onclick = function () { count += 1; b.innerHTML = "Click me: " + count; }
如果希望在一次單擊後執行多個函式功能,仍然維持採用現在的鬆耦合模式是無法做到的。技術上來說,可以檢查onclick是否已經包含一個函式,如果包含了一個函式,那麼就將現有的函式功能新增到新函式中,並用新函式替換onclick中的原有函式的屬性。但更清晰的方法是使用addEventListener()方法。在IE8.0之前的版本中沒有該方法,在這些老版本瀏覽器中應該使用attachEvent()。
讓我們回顧一下初始化分支模式(參考第四章),可以看到定義跨瀏覽器事件監聽器工具的一種比較好的實現範例。現在無序探究所有的細節,讓我們先嚐試為按鈕新增一個監聽器:
var b = document.getElementById('clickme'); if(document.addEventListener) { //W3C b.addEventListener('click',myHandler,false); } else if(document.attachEvent) { // IE b.attachEvent('onclick', myHandler); } else { // 終極手段 b.onclick = myHandler; }
現在一旦按鈕被點選,myHandler()函式將會執行,該函式會增加按鈕上面“clickme:0”中的數值。讓我們假定有多個按鈕,並且這些按鈕共享同一個myHandler()函式。考慮到可以從每次點選時建立的事件物件中獲取數值,因此為每個數值維持按鈕節點和計數器之間引用是十分低效的。
讓我們先來看看對此的解決方案,然後再加以評論:
function myHandler(e) { var src, parts; // 獲取事件和源元素 e = e || window.event; src = e.target || e.srcElement; // 實際工作:升級標籤 parts = src.innerHTML.split(": "); parts[1] = parseInt(parts[1], 10) + 1; src.innerHTML = parts[0] + ": " + parts[1]; // 無冒泡 if(typeof e.stopPropagation === 'function') { e.stopPropagation(); } if(typeof e.cancelBubble !== 'undefined') { e.cancelBubble = true; } // 阻止預設操作 if (typeof e.preventDefault === "function") { e.preventDefault(); } if (typeof e.returnValue !== "undefined") { e.returnValue = false; } }
這個事件處理函式分為四個部分:
- 首先需要獲取對事件物件的訪問權,該事件物件包含了關於事件和觸發該事件的網頁元素的資訊。事件物件被傳遞給回撥事件處理器,而不是使用o'clock屬性(可以通過全域性屬性windows.event來獲取訪問權)。
- 第二部分是處理升級標籤的實際工作。
- 接下來第三部分是取消事件的傳播。在當前特定的範例中,這一部分可以省略,不是必須的。但是通常如果不這樣做,會導致事件傳播到根文件,甚至是傳播到window物件中。在這個部分需要採用兩種方法實現,一種是W3C標準方法(stopPropagation());另外一種是IE特有的方法(cancelBubble)。
- 最後,如果需要時,要阻止執行預設操作。一些事件擁有預設操作,但可以使用preventDefault()來阻止預設操作(在IE中,通過將returnValue設定為false來實現)。
如您所見,這樣的做法包含很多重複性工作,因此按照第7章討論的那樣使用正面方法建立自己的事件工具是十分有意義的。
上面程式碼的示例地址在http://www.jspatterns.com/book/8/click.html。
事件授權
事件授權模式得益於事件冒泡,會減少為每個節點附加的事件監聽器數量。如果在div元素彙總有10個按鈕,只需要為該div元素附加一個事件監聽器就可以實現為每個按鈕分別附加一個監聽器的效果。
我們可以簡單的來看一個示例:
<div id="click-wrap"> <button>Click me: 0</button> <button>Click me too: 0</button> <button>Click me three: 0</button> </div>
可以使用如上的標記,可以通過為“click-wrap”div附加監聽器來代替為每一個按鈕都附加監聽器。然後只需要對之前範例中使用的myHandler()函式做微小修改(需要過濾不感興趣的點選事件),就可以直接使用。在這種情況下,只需尋找按鈕的點選事件,而同一個div元素中其他點選事件都會被忽略。
對myHandler()需要做的修改就是判斷時間的nodeName是否為“button”,如果是,則執行函式功能:
// ... // 獲取事件和源元素 e = e || window.event; src = e.target || e.srcElement; if(src.nodeName.toLowerCase() !== "button") { return; }
// ...
事件授權的缺點在於如果碰巧沒有感興趣的事件發生,那麼增加的小部分程式碼就顯得沒用了。但是採用該模式所獲的收益(效能和更為清晰的程式碼)遠遠大於缺點,因此強烈推薦使用該模式。
最近的JavaScript庫通過API,使得事件授權更為簡便。舉例來說,YUI3有一個Y.delegate()方法,該方法可以制定一個CSS選擇器來匹配封裝,並使用另外一個選擇器來匹配感興趣的節點。這是十分方便的,因為當事件在關注的節點之外發生時,回撥事件函式實際上並沒有被呼叫。在這種情形下,附加一個事件監聽器的程式碼是十分簡便的,如下所示:
Y.delegate('click', myHandler, "#click-wrap", "button");
由於YUI將各種瀏覽器的區別抽象出來了,可以由使用者決定事件的來源,因此回撥函式將變得更為簡便:
function myHandler(e) { var src = e.target, parts; parts = src.get('innerHTML').split(": "); parts[1] = parseInt(parts[1], 10) + 1; src.set('innerHTML', parts[0] + ": " + parts[1]); e.halt(); }
完整的例子在http://www.jspatterns.com/book/8/click-y-delegate.html。