Dojo 最佳實踐 - 如何防止瀏覽器內存泄漏

Dojo 最佳實踐 - 如何防止瀏覽器內存泄漏

胡 曠, 高級軟件工程師, IBM
劉 萬榮, 高級軟件工程師, IBM
李 春玲, 高級軟件工程師, IBM

簡介: 隨着 Ajax 應用的飛速發展,瀏覽器端 JavaScript 代碼應用的規模和複雜度和傳統 Web 應用相比已不可同日而語。各種功能強大的小部件窗口 (widget),各種眩目的特效,在瀏覽器端都需要消耗越來越多的內存資源。而如何正確的創建和釋放這些資源,保證用戶在長時間的使用過程中不會因內存泄漏導致瀏覽器應用的性能、體驗降低,也日漸凸現重要。

對於瀏覽器端,尤其是 Internet Explorer 的內存泄漏問題及解決方法,已經有很深入和廣泛的討論。而本文將更多的講解作爲一個 Dojo 開發人員,如何正確使用 Dojo 的相關技術,遵循 Dojo 的編程模式來避免瀏覽器的內存泄露問題。

Ajax 應用新的挑戰

Ajax 技術已經被廣泛的應用,其給 Web 用戶帶來全新的使用體驗同時,也給 Web 開發人員帶來了各種各樣新的挑戰。Ajax 應用中瀏覽器端內存泄露問題便是其中之一。作爲一名 Web 前端開發人員,如果某天系統測試人員給您開了一個名爲“瀏覽器端內存泄露問題”的 Bug, 千萬別感到意外,因爲您正處在 web2.0 時代。

Internet Explorer 和 Mozilla Firefox 是使用人數最多的兩個網頁瀏覽器,因而我們主要討論 JavaScript 在這兩個瀏覽器中的內存泄露問題。在這兩個瀏覽器中,用來管理 DOM 對象的組件對象模型(component object model)是導致 JavaScript 內存泄露的罪魁禍首。不管是原生的 Windows COM,還是 Mozilla 的 XPCOM 都使用引用計數(reference-counting)垃圾回收機制來分配和回收內存。然而用來管理 DOM 對象內存的引用計數機制並不總是和應用於 JavaScript 的標誌和清除(mark-and-sweep)垃圾回收機制相兼容。問題便由此而來。

關於 JavaScript 內存泄露模式以及如何避免內存泄露,已經有很多經典參考資料(參見本文後面的參考資源)。但是由於 Dojo 工具集對於 JavaScript 所做的封裝,使得這些資料對於 Dojo 開發人員,卻並不很實用。而本文將關注於如何正確使用 Dojo 的相關技術,遵循 Dojo 的編程模式來避免瀏覽器的內存泄露問題,主要涉及到:

  • 如何正確使用 Dojo 事件機制來避免內存泄露
  • 如何正確使用 Dojo API 來銷燬 DOM 節點
  • 如何正確析構 Dojo 小部件(Widget)來避免內存泄露
  • 如何正確使用 dojo.create API 來避免內存泄露
  • 如何更好地設計 UI 代碼來避免內存泄露

文中將輔以我們在軟件開發中遇到的真實案例,來講解如何使用這些編程模式來避免內存泄露問題。

在閱讀下面章節前,請務必先閱讀參考資料中提到的 Understanding and Solving Internet Explorer Leak Patterns 一文。

Dojo 事件機制與避免內存泄漏

在 JavaScript 編程中,我們經常會用一個 JavaScript 函數來響應並處理某個 DOM 節點的特定事件。而這恰恰也是最容易引入循環引用(circular references)而最終導致內存泄露的地方。在 Dojo 中,其實我們只要按照一定的編程模式,便能很好地避免循環引用帶來的問題。

Dojo 事件機制提供的 dojo.connect API 能夠讓我們方便地把一個 JavaScript 函數關聯上某個 DOM 節點事件。Dojo 事件機制對這一操作過程的包裝,讓我們能夠非常容易地處理這種關聯所帶來的循環引用。


清單 1. 使用 dojo.connect API
				 
 // 關聯一個 JavaScript 函數與一個 DOM 節點事件       
 var myConnection = dojo.connect(domNode, "onclick", scope, "onClickHandler"); 

在清單 1 中通過使用 dojo.connect API ,我們用 onClickHandler 函數來響應並處理 domNode 節點拋出的 onclick 事件。在 dojo.connect 執行完之後,它將返回一個值,代表剛關聯的 JavaScript 函數與 DOM 節點事件之間的聯繫,我們稱之爲“連接”(connection)。而該“連接”正是消除循環引用的關鍵!方法很簡單,當該“連接”不需要的時候,比如被關聯的 DOM 節點被銷燬的時候,通過使用 dojo.disconnect API 來斷開該“連接”。這樣,被關聯的 JavaScript 對象與 DOM 節點之間的循環引用就被斷開了。也就避免一個潛在的內存泄露問題。dojo.disconnect API 使用方法見清單 2。


清單 2. 使用 dojo.disconnect API
				 
 // 在必要的時候斷開“連接”
 dojo.disconnect(myConnection); 

在開發 Dojo 小部件的時候,我們也需要消除 DOM 節點與 JavaScript 函數關聯帶來的循環引用問題,只是方法稍有不同。在小部件開發中,可以使用小部件基類 dijit._Widget 提供的 connect 方法來關聯 JavaScript 方法和 DOM 節點事件。相比使用 dojo.connect, 使用基類 dijit._Widget 提供的 connect 方法會讓我們的代碼更簡潔。我們並不需要關心用 connect 之後生成的“連接”(connections)以及何時斷開它們。基類 dijit._Widget 提供的 connect 方法會把生成的“連接”自動存儲起來。當小部件被銷燬時,這些存儲的“連接”也會連同一起被自動銷燬(參考“Dojo 小部件析構與避免內存泄漏”小節圖 1 中小部件銷燬過程中的 disconnect 階段)。很明顯,這樣的使用方式,讓我們的代碼顯得更加簡潔與容易維護。


清單 3.使用 dijit._Widget 基類的 connect 方法
				 
 // 在小部件開發中關聯 JavaScript 函數與 DOM 事件
 this.connect(domNode, "onclick", "onClickHandler"); 

熟悉 Dojo 小部件開發的讀者可能會想到小部件開發中另外一種通過模板技術關聯 JavaScript 函數與 DOM 節點事件的方式,使用小部件模板中的 dojoAttachEvent 屬性。那麼,使用該技術的話,我們是否需要手工處理“連接”呢?和使用 dijit._Widget 基類的 connect 方法一樣,答案是不需要!對於在小部件模板中使用 dojoAttachEvent 屬性的方式,Dojo 也會幫我們自動處理產生的“連接”,不需要我們再寫任何額外的代碼。


清單 4. 使用小部件模板技術中的 dojoAttachEvent 屬性
				 
 // 小部件模板文件片段
…
 <div class="close" dojoAttachEvent="onclick:closeHelpBox">X 
 </div> 
…

 // 小部件 JavaScript 定義文件片段

 closeHelpBox: function(event) 
 { 
 this.destroy(); 
 } 
…

對於非小部件開發的情況,我們必須使用 dojo.connect API,並手動地使用 dojo.disconnect API 處理“連接”。一種比較好的模式是定義一個幫助方法用來註冊特定上下文內生成的所有“連接”,當該上下文結束時一併切斷註冊的所有“連接”。參考清單 5 中代碼。


清單 5. 非小部件開發情況,使用 dojo.connect API 的編程模式
				 
 // 定義幫助方法
 connectionHelper = { 
 scopes:{}, 
 connect : function(/*string*/ scope, 
 /*Object|null*/ obj, 
 /*String*/ event, 
 /*Object|null*/ context, 
 /*String|Function*/ method){ 
 var conn = dojo.connect(obj, event, context, method); 
 if(!this.scopes[scope]){ 
 this.scopes[scope] = []; 
 } 
 this.scopes[scope].push(conn); 
 return conn; 
 }, 
 clear: function(/*string*/ scope){ 
	 if(this.scopes[scope]){ 
		 dojo.forEach(this.scopes[scope], dojo.disconnect) 
	 } 
 } 
 } 

Dojo.destroy , dojo.empty 與 DOM 銷燬

當 DOM 節點不需要時,明確地銷燬它也是一個很好的習慣。這會減少內存的佔用,提升程序的性能。我們可以使用 Dojo 提供的 dojo.destroy API 和 dojo.empty API。這兩個 API 的差別在於 dojo.destroy 會銷燬包括參數指定的 DOM 節點本身及所有子節點,而 dojo.empty 只會銷燬子節點。

Dojo 小部件 (Widget) 析構與避免內存泄漏

所有的內存泄露問題都是由於程序中已不使用,卻未能釋放的資源引起。在 Dojo 中,Dojo 小部件可以被認爲聚合了多種資源,例如 DOM 對象,JavaScript 對象,事件連接(使用 dojo.connect 生成的 connections)等。所以在使用 Dojo 小部件時,要避免內存泄露,首先需要掌握的便是在正確的時候,使用正確的方法銷燬 Dojo 小部件。

當發生頁面跳轉或頁面部分內容刷新時,其中的小部件就不再需要了。此時,我們需要調用 Dojo 小部件基類 dijit._Widget 的 destroyRecursive API 來銷燬小部件。圖 1 列舉出了 Dojo 小部件銷燬過程中的幾個主要階段:

  • destroyRecursive:小部件銷燬過程入口
  • destroyeDscendants:銷燬小部件中嵌套的子小部件
  • destroy:釋放小部件本身的資源
  • uninitialize:擴展點,用以釋放自定義的資源
  • disconnect:切斷小部件中生成的“連接”(connections)
  • destroyRendering:銷燬小部件中的 DOM 節點

當 destroyRecursive 被調用時 , 圖 1 中的各階段按照從左至右深度遍歷的順序依次執行。這裏需要注意的是,在銷燬小部件時,開發人員需要調用的只是 destroyRecursive API,而非 destroy API。DestroyRecursive API 會在調用 destroy API 之前先調用 destroyeDscendants API 銷燬嵌套的子孫小部件。另外,開發人員可以通過覆寫 uninitialize API ,在小部件銷燬過程中來釋放自己定義的一些資源。


圖 1.Dojo 小部件銷燬過程
圖 1.Dojo 小部件銷燬過程 

清單 6.使用 dijit._Widget 的 destroyRecursive API 銷燬小部件
 var myWidget = dijit.byId("widgetId"); 
     if (myWidget && myWidget.destroyRecursive) 
               // 銷燬 myWidget 小部件
               myWidget.destroyRecursive(); 

從以上小部件銷燬過程我們可以看出對於嵌套的小部件,我們只需要確保最頂層的小部件的 destroyRecursive API 被調用就可以了。其銷燬過程會保證嵌套的子孫小部件也能被正確地銷燬。然而,某些使用 Dojo 小部件的應用場景我們需要特別注意。我們知道大多數 Dojo 小部件在使用時需要明確指定一個 DOM 節點,用於掛載該小部件。但是某些 Dojo 小部件的使用卻並不需要明確指定要依附的 DOM 節點,比如 dijit Menu, dijit Dialog 等。以 dijit.Menu 小部件爲例,在使用編程的方式來創建 dijit.Menu 小部件時,通常並不需要指定一個 DOM 節點作爲附着點。默認情況,該小部件將依附於 body 標籤的最底部。如果這種情況下您忘記了銷燬它,內存泄露便悄然發生了。

圖 2 所示是我們在開發中曾碰到的一個真實案例,紅色框中的下拉菜單是通過編程的方式使用 dijit.Menu 小部件生成。從視圖上看,它依附於一個下拉菜單按鈕上,但我們並沒有給它指定一個依附的 DOM 節點。當這個視圖需要銷燬時,我們只明確銷燬了按鈕小部件,卻忘記了銷燬菜單小部件。最後導致每次視圖切換都會帶來 2M 的內存泄露。圖 3 是我們使用 sIEve 工具檢測該視圖切換時生成的瀏覽器內存使用情況圖,右側陡升的曲線便說明了問題的嚴重性。


圖 2.dijit.Menu 小部件引起的內存泄露
圖 2.dijit.Menu 小部件引起的內存泄露 

圖 3.dijit.Menu 小部件內存泄露導致的內存使用曲線
圖 3.dijit.Menu 小部件內存泄露導致的內存使用曲線 

爲了解決這個問題,一般有兩種方式:在生成該類小部件時,明確的把該小部件依附於另一個小部件的 DOM 節點上,這樣當上層的小部件被銷燬時,按照 Dojo 小部件銷燬流程,底部的子小部件也會被依次銷燬;另外一種方式便是在生成該小部件時記住該小部件的 id, 在需要銷燬該小部件時,明確地調用 destroyRecursive API 來銷燬該小部件。

Dojo.create 與避免 DOM 插入順序內存泄漏

IE 中有一類典型的內存泄漏模式稱之爲 DOM 插入順序內存泄漏。當創建動態的 DOM 節點時,我們必須確保上層元素首先被附着,然後是底層的。如果把順序反過來,便有可能導致內存泄露。

很多人可能都習慣先創建一個很大的 DOM 樹,然後再把它附着到一個父節點上。而這在 IE 上卻會帶來問題。我們需要改變創建 DOM 樹的方式。Dojo 中的 dojo.create API 提供了動態創建 DOM 節點的功能,正確的使用該 API 能幫助我們避免 DOM 插入順序內存泄露問題。該 API 很簡單,它提供了一個參數用來指定創建的 DOM 節點要附着的父節點。所以,只要在使用 dojo.create API 時,我們始終都指定該參數,我們便能避免 IE 中的 DOM 插入順序內存泄露問題。


清單 7. 使用 dojo.create API 創建 DOM 節點
				 
 // btnNode 節點在創建時被附着在 this.containerNode 節點上
 var btnNode = dojo.create("span", null, this.containerNode); 

可能碰到的問題

當要創建的 DOM 樹很大時,這種從上而下的創建 DOM 節點的方式可能會造成瀏覽器視圖的閃爍。一個好的辦法是在 DOM 樹渲染期間通過樣式”display:none”把最頂層父節點隱藏起來,直到整個 DOM 樹都創建好之後,再把頂層父節點展現出來。參考清單 8 中的代碼。


清單 8. 避免 DOM 節點創建過程中的閃屏問題
				 
 var frameNode = dojo.create(“div”,{ 
"style“: { 
	display: "none" 
		 } 
	 },dojo.body()); 

    // 創建 frameNode 節點下的子節點樹
    … . 
    // 當子節點樹創建好之後,顯示整個 frameNode 節點樹
    dojo.attr(frameNode, "style", { 
            display: "block"
      }); 

UI 代碼設計與避免內存泄露

內存泄露問題似乎多半在代碼後期纔會得到關注,其實有時候內存泄露與 UI 代碼的前期設計緊密相連。越是在代碼開發早期考慮內存泄露的問題,越是能幫我們設計出更好的 UI 代碼結構。我們有個真實的案例是需要實現一個定製的 table 小部件。該 table 小部件由 table header 和 table cells 組成(如圖 4 所示)。當刷新表格數據時,組成表格單元的 DOM 節點以及關聯這些 DOM 節點與事件處理函數的“連接”都需要被及時銷燬。

在我們的早期設計中,我們把 table header 和 table cells 一起設計成一個整體的小部件。但是之後我們發現在表格數據刷新時,這樣的設計處理起來並不是很方便。我們無法像前面推薦的編程模式那樣方便地使用 dijit._Widget 中的 connect 方法來管理“連接”,而不得不添加額外的代碼來手工地處理“連接”。這種情況下該怎麼辦呢?經過研究,我們發現其實更好的設計應該是把 table cells 與 table header 劃分成兩個不同的子小部件,而這兩個子小部件組成了 table 小部件(如圖 5 所示)。這樣,在表格被刷新時,“連接”的處理就能由 table cells 子小部件自己來決定了。我們也就不再需要額外的代碼。很明顯,後一種設計更加的簡潔、優雅!


圖 4.table 小部件原始設計方案
圖 4.table 小部件原始設計方案 

圖 5.table 小部件改進的設計方案
圖 5.table 小部件改進的設計方案 

由此可見,越是能在開發早期把瀏覽器端的內存泄露問題考慮進來,越是能節省我們後期爲提高我們的 AJAX 應用性能所需要的努力。

小結

本文簡要介紹了造成瀏覽器內存泄露的本質原因。並詳細介紹了在使用 Dojo 開發 AJAX 應用時避免瀏覽器內存泄露的編程模式。首先介紹了 Dojo 中的事件機制以及如何使用它來避免循環引用帶來的問題,介紹瞭如何使用 Dojo 提供的 API 來銷燬 DOM 節點。之後着重介紹了 Dojo 小部件的析構機制以及如何正確使用其 API 來避免內存泄漏。還介紹了 DOM 插入順序內存泄漏模式,以及避免該類內存泄漏模式的最佳實踐。最後探討了小部件設計與內存泄漏的關係,並結合項目中的具體實例來講解什麼樣的設計是最好的,以及其如何來避免內存泄漏。


參考資料

學習



from http://www.ibm.com/developerworks/cn/web/1205_hukuang_dojomemleak/index.html


發佈日期: 2012 年 5 月 28 日 
級別: 高級 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章