原文地址:what’s wrong with extending the DOM
最近我驚奇地發現,網上很少有關於擴充DOM的文章。令人不安的是,這個看似不錯的做法的缺點並不是那麼的眾所周知。除了某些僻靜的社交圈。資訊的匱乏可以很好的解釋為什麼現代的一些指令碼和類庫依然會陷入這個圈套(DOM並沒有想象的那麼好)。我想通過展示一些與之關聯的問題來解釋一下,為什麼擴充DOM並不是一個好的做法。我也將給出一些可能的替換方案來替代這種不好的做法。
首先,我們還是要瞭解一下什麼是DOM擴充?和它是怎麼工作的?
它是怎麼工作的?
DOM擴充是一個簡單給DOM對象添加自訂方法和屬性的過程。自訂屬性是那些不存在的特殊的實現。那什麼是DOM對象呢?它是宿主對象的實現,比如element,event,document等等其他的DOM介面。通過擴充,方法和屬效能被直接地添加到對象上,或者直接擴充到它們的原型(prototype)上,如果運行環境支援的話(Firefox是支援在DOM對象的prototype上直接擴充的,而ie不支援)。最通常地對象擴充方法可能就是擴充DOM元素了,像prototype和mootools架構就是這樣做的。事件對象和document對象也同樣經常被擴充。
一些開發工具能顯示element對象的原型(比如firebug),下面是一個DOM擴充的例子:
Element.prototype.hide = function() { this.style.display = 'none';};...var element = document.createElement('p');element.style.display; // ' 'element.hide();element.style.display; // 'none'
正如你所見到的,“hide”函數首先被分配給element.prototype的hide屬性,然後從一個element直接被調用,然後這個元素樣式中的display屬性被設定為了“none”。
它的原理是,當建立一個p元素時,它在原型鏈上繼承了element對象的原型,也會有hide方法。
當hide屬性被調用時,它會搜尋整個原型鏈,直到找在element對象的原型上找到hide這個方法。
實際上,如果我們在一些現代瀏覽器中檢查p元素端的原型鏈,它可能看起來會像是這樣:
// "^" denotes connection between objects in prototype chaindocument.createElement('p');^HTMLParagraphElement.prototype^HTMLElement.prototype^Element.prototype^Node.prototype^Object.prototype^null
注意,p元素的原型鏈最近的祖先是HTMLParagraphElement.prototype,它是一個具體元素類型的對象。對於p元素來說,就是HTMLParagraphElement.prototype,那div元素的話,就是HTMLDivElement.prototype,a元素的話就是HTMLAnchorElement.prototype等等。
你或許會問,怎麼會有這麼奇怪的名字?
這些名字其實符合interfaces defined in DOM Level 2 HTML Specification的,這些名字也定義了繼承關係,在這些介面之間。比如:HTMLParagraphElement介面擁有HTMLElement介面所有的屬性和方法(說明),再比如:HTMLElement介面擁有Element介面所有的屬性和方法(說明)等等。
明顯地,如果我們在paragraph element(p標籤)的原型上面建立一個屬性,它將不會在anchor element(a標籤)上作用:
HTMLParagraphElement.prototype.hide = function() { this.style.display = 'none'; }; ... typeof document.createElement('a').hide; // "undefined" typeof document.createElement('p').hide; // "function"
這個是因為anchor element(a標籤)的原型上面沒有包含對HTMLParagraphElement.prototype(p標籤的具體原型)的引用,而是包含對HTMLAnchorElement.prototype(a標籤的具體原型)的引用。為了“修正”這一點,我們可以把屬性寫在更深層的祖先上面,如HTMLElement.prototype, Element.prototype 或 Node.prototype。 類似地,在Element.prototype上建立的屬性也不會在所有的節點上生效,只有在element類型的節點上生效。如果我們想讓所有的節點(例如,文本節點和注釋節點)上有個屬性的話,我們要在Node.prototype上分配屬性。說到文本節點和注釋節點,這是繼承介面通常尋找它們的方法:
document.createTextNode('foo'); // < Text.prototype < CharacterData.prototype < Node.prototypedocument.createComment('bar'); // < Comment.prototype < CharacterData.prototype < Node.prototype
現在,很重要的一點是,剛剛揭露的這裡DOM原型並不是有保證的。DOM Level2 說明書僅僅是定義了這些介面,和這些介面之間的繼承關係。它並沒有規定說一定要存在一個全域的Element屬性,referencing object that’s a prototype of all objects implementing Element interface.也沒有規定要存在一個全域的Node屬性,referencing object that’s a prototype of all objects implementing Node interface.
IE7以下就是這麼個環境的例子。它沒有顯示全域的Node, Element, HTMLElement,HTMLParagraphElement,或者他們的屬性。另一個這樣瀏覽器是Safari 2.x(像Safari 1.x一樣)
所以我們要處理這些不能“顯示”(expose)全域對象原型的環境呢?一個變通的方案是直接擴充DOM對象:
var element = document.createElement('p'); ... element.hide = function() { this.style.display = 'none'; }; ... element.style.display; // '' element.hide(); element.style.display; // 'none'
哪裡錯了呢?
通過原型對象擴張DOM元素聽起來很神奇。我們發揮了js原型本質的優勢,對DOM編程變的非常物件導向。事實上,DOM擴充看上如此誘人地有用已經有好些年頭了,Prototype Javascript library把它作為體繫結構中必要的部分。但是這個看起來無害的實踐下面隱藏著巨大的負載問題。一會我們就將看到,在跨瀏覽器編程中,它的弊遠大於利。DOM擴充是prototype.js犯過的最大的錯誤。
所以問題到底在哪裡?
缺少標準
正如我所提到的那樣,不是任何的標準裡面都有暴露對象原型的部分。DOM Level 2隻定義了介面和他們的繼承關係。為了完全符合DOM Level 2這一標準實現,沒有必要暴露那些全域的Node、Element、等等對象。其他任何方面也沒有這個需求。給定它們就有可能手工的擴充DOM對象,這看起來也不是一個很大的問題。但是真相是手工擴充是一個很慢而且不方便的過程(我們將在後面看到)。實際上,它的“快速”也僅限於很少一部分的瀏覽器,將來的可移植性和跨平台訪問能力也將不可靠(比如:手機裝置)。
宿主對象沒有規則可循
下一個DOM擴充的問題是DOM對象是宿主對象,宿主對象是最壞的一群東西。根據ECMA-262第3版,宿主對象被允許做一些事,不是其他對象能想象的到的。引用相關章節[8.6.2]:
Host objects may implement these internal methods with any implementation-dependent behaviour, or it may be that a host object implements only some internal methods and not others.
內部方法標準說的是[[Get]], [[Put]], [[Delete]], etc等等。注意它是怎麼說的,內部方法行為是依賴實現的。這意味著在調用[[Get]]時,宿主對象拋出異常是絕對正常的。而且不幸的是,這不只是一個理論。在IE裡,我們能容易的準確觀察到這些——宿主對象[[Get]]拋出異常的例子:
document.createElement('p').offsetParent; // "Unspecified error."new ActiveXObject("MSXML2.XMLHTTP").send; // "Object doesn't support this property or method"
擴充DOM對象像是行走在雷區。根據定義,你是正工作在一個允許表現的難以捉摸和完全不穩定的東西上。而且不止這些。它也可能靜默失敗,這是更壞的情況了。一個不穩定行為的例子是applet,object和embed,它們在分配屬性時,某些情況下會拋出異常。相似的災難發生在XML節點上:
var xmlDoc = new ActiveXObject("Microsoft.XMLDOM");xmlDoc.loadXML('bar');xmlDoc.firstChild.foo = 'bar'; // "Object doesn't support this property or method"
在IE裡還有些其他失敗的情況,像document.styleSheets[99999]會拋出“Invalid procedure call or argument”的錯誤,document.createElement(‘p’).filters會報“Member not found.”。不僅MSHTML DOM 是個問題。在Firefox中,重寫事件對象的target屬性會拋出“TypeError”,因為那些是唯讀,不能被重寫。在WebKit下又不太一樣,在分配“target”後,再在原始對象中調用,會靜默失敗。
當建立一個API給事件對象時,需要要考慮一下那些唯讀屬性,而不是專註在那些簡潔描述的名字上。
衝突的機會
基於DOM元素擴充的API很難去標度。當添加和修改核心API方法時,類庫的開發人員很難去標度它。類庫的使用者在添加特定域的擴充時,也是如此。根本的問題在於有可能衝突。DOM在流行的瀏覽器的實現中,都有API所有權。這些API並不是靜態,他會時常的根據瀏覽器版本的發布而更新。一些會被棄用,一些會被更新或添加。所以給DOM對象設定屬性和方法,可能會像是在打移動靶。
考慮到當今已經有很多數量的web環境在使用中,如果某個屬性已經不在DOM上不可能被告知。如果可以,那它是否能被重寫的呢?或者,重寫它時,會不會報錯呢?記住他可是一個宿主對象啊。如果我們能正常的重寫它,那它對DOM對象的其他部分又有什麼影響呢?是不是所有的東西會預期的那樣發生呢?如果在這個版本的瀏覽器中這一切都正常,誰有又能保證,在下個版本中,它不會採用相同的命名?這裡有一堆的問題在繼續。
打斷Prototype開發的關於擴充所有權的例子有“IE上textarea的wrap屬性”(和元素的wrap方法衝突),還有“Opera上表單控制元素的select方法”(和元素的select方法衝突)。即使這兩種情況已經在文檔中記錄,但這個意外還是很令人討厭。
擴充所有權不是唯一的問題。HTML5帶來了一籃子屬性和方法。大部分流行的瀏覽器已經開始相容他們。某些時候,WebForms為input元素定義了replace屬性,Opera決定將它添加到他們的瀏覽器中,這正好和Prototype中元素的relpace方法衝突了,它又一次的打斷了Prototype。
等一下,還有更多的問題。
由於DOM Level 0長期的傳統,有一個簡單的方法關閉表單元素的訪問表單控制,就是通過他們的name值。這意味著除了使用標準的元素鏈,還可以像這樣訪問他們:
<form action=""> <input name="foo"> </form> ... <script type="text/javascript"> document.forms[0].foo; // non-standard access // compare to document.forms[0].elements.foo; // standard access </script>
所以,如果你說你通過login方法擴充了form元素,可以檢查驗證資訊然後提交。而且表單中有一個表單控制項name值等於login,接下來發生的就不那麼可愛了:
<form action=""> <input name="login"> ... </form> ... <script type="text/javascript"> HTMLFormElement.prototype.login = function(){ return 'logging in'; }; ... $(myForm).login(); // boom! // $(myForm).login references input element, not `login` method </script>
每個擁有name值的表單控制項遮蔽了從原型鏈上繼承的屬性。在表單元素上出現異常和衝突的機會會更高一些。
這種情況在有name值的form元素上也是相似的,它們可以通過它們的name值直接在document上訪問:
<form name="foo"> ... </form> ... <script type="text/javascript"> document.foo; // [object HTMLFormElement] </script>
當擴充document對象的時候,有一個附加的風險就是會和form元素的name值衝突。如果指令碼運行在一個有大量html的古老應用程式裡,刪除這些name值將是一個煩瑣的工作,不是嗎?
使用一些種類的首碼會減輕這些問題,但也可能帶來一些副作用。
不要修改不屬於你的對象是一個避免衝突的最終解決方案。破壞這一規則的Prototype已經陷入了麻煩,當它自訂地重寫 document.getElementsByClassName的實現時。於此同時,那些不管有沒有修改DOM對象的指令碼(其他架構),在相同環境下,運行地更好。
效能開銷
正如我們前面看到的,不支援元素擴張的瀏覽器,像ie6,ie7,safari 2.x 等等,只能手動擴充。問題就是手動擴充很慢,不方便,而且無法衡量。慢的原因是對象需要擴充大量經常使用的屬性和方法。諷刺的是,這些瀏覽器已經是眾多瀏覽器中最慢的了。不方便的原因是對象需要先擴充後操作。所以在document.createElement(‘p’).hide()之前,你需要先$(document.createElement(‘p’)).hide()。這種方式對於Prototype的新手的來說,無疑是一個絆腳石。最後,手動擴充無法測量是因為添加API的方法影響效能是呈線性。這個問題上,如果在Element.prototype上有100個方法,在一個元素上就必須有100個分配被建立。如果在Element.prototype上有200個方法,在一個元素上就必須有200個分配被建立。
另一個對效能的打擊是關於事件對象的。Prototype通過相同的方式在它們上面擴充了一個集合的方法。不幸的是,瀏覽器中有些事件比如,mousemove, mouseover, mouseout, resize 等等,在一秒鐘內能被多次觸發。擴充他們中的任意一個都是非常昂貴的過程。那麼怎麼辦呢?讓事件對象只調用單個方法嗎?
最後,一旦你開始擴充元素,類庫API大多數情況下需要返回已擴充的元素。結果就是,像$這樣的查詢方法將在查詢裡,擴充一個簡單元素時就被終止了。很容易想象當我們談論成千上百個元素時的效能開銷,用這個過程處理的話。
IE的DOM一團糟
像前面章節展示的那樣,手動擴充DOM是混亂的。在IE裡面將更糟糕,下面會講為什麼。
我們都知道IE裡面有一個迴圈調用宿主和本機物件的漏洞,我們最好避開它。可是,給DOM元素添加一個方法的第一步就是建立這個迴圈的引用。老版的IE沒有暴露“object prototype”,我們只能沒有別的方法,只能在元素上直接擴充。循環參考和漏洞無法避免。事實上,Prototype的生命週期中,在這裡遭受了很多的損失。
另一個問題是IE DOM 映射每個元素屬性(attributes)和屬性(properties)之間的方式。實際上,屬性(attributes)和屬性(properties)在同一個命名空間裡,增加了衝突的機率,還有各種異常和不一致。如果自訂show屬性,會發生什麼,prototype會擴充它嗎。你會驚奇地發現,show屬性會被Prototype的Element#show方法重寫。extendedElement.getAttribute(‘show’)會返回一個函數的引用,而不是show屬性的值。類似地,extendedElement.hasAttribute(‘hide’)將返回true,即使沒有在元素上自訂hide屬性。IE8以下是沒有hasAttribute的,但是我們仍然能看到attribute/property的衝突:typeof extendedElement.attributes['show'] != “undefined” 。
最後,一個較少人知道的缺點是在ie中,添加屬性(properties)會引起迴流。所以僅僅是擴充元素就是一個相當昂貴的操作。放棄DOM中有缺陷的屬性(attributes)和屬性(properties)之間的映射是有道理的。
“額外獎勵”:瀏覽器bugs
如果這些還不夠的話(或許,你是一個受虐狂),這裡還有幾個比上面那些有過之無不及的bugs。
在Sarfri 3.x中的某些版本裡,就是在通過點擊瀏覽器導航上的返回按鈕,返回前一頁時,會擦去所有宿主對象的擴充。不幸的是,這個bug是無法被察覺的,為瞭解決這個問題,Prototype不得不做出一些可怕的事情。先嗅探出那個版本的Webkit,然後在window的unload事件上明確地禁止掉bfcache(for back-forward cache)。禁止bfcache,意味著瀏覽器將重新去擷取頁面,而不是從緩衝中讀取已儲存的頁面。
在ie8中,HTMLObjectElement.prototype和 HTMLAppletElement.prototype也有bug,object元素和applet元素不是這裡繼承的。你可以給HTMLObjectElement.prototype分配一個屬性,但是它在object元素上面不起作用,applets也是一樣的。所有這些對象又需要另外去手動擴充,這又是一項開銷。
對照其他流行的實現,ie8隻暴露一部分原型對象。比如,它有HTMLParagraphElement.prototype(和其他特殊的類型一樣),Element.prototype,但是沒有HTMLElement、HTMLElement.prototype, Node、Node.prototype。Element.prototype在ie8中也不是從Object.prototype繼承的。這本身並不是bugs,然而需要牢記的是:對不存在的node進行擴充沒有什麼好處。
用封裝來拯救
代替這個混亂的DOM擴充的方案,最常見的方法有對象封裝。這就是jQuery開始使用的方法,其他類庫也緊隨其後。這個想法很簡單。不在元素或事件上面直接的擴充,而是通過把他們封裝成另外的對象,然後在給他們委派方法。沒有衝突,不需要處理宿主對象這樣瘋狂的行為,漏洞更加容易管理,更容易在不正常的MSHTML DOM上操作,更好的效能,健全的維護和無痛的縮放。
你還能避免程式上的方法。
Prototype 2.0
好訊息是,prototype在下一個主要版本中將不會犯這種錯誤了。至於我所憂慮的,所有的核心開發人員已經都明白了以上提到的問題,封裝是在一個健全的發展方向。我不確定其他像mootools這樣的基於DOM擴充的類庫的計劃是什麼。據我所知,他們已經封裝events對象了,但是還是在擴充元素。我希望將來他們能離這個愚蠢的行為遠點。
可控的環境
……
編後記
下一次,你在使用某一個採用DOM擴充的類庫或架構時,最好也考慮一下風險。