- JavaScript設計模式與開發實踐
- 曾探
- 2333字
- 2020-01-10 15:38:14
3.1 閉包
對于JavaScript程序員來說,閉包(closure)是一個難懂又必須征服的概念。閉包的形成與變量的作用域以及變量的生存周期密切相關。下面我們先簡單了解這兩個知識點。
3.1.1 變量的作用域
變量的作用域,就是指變量的有效范圍。我們最常談到的是在函數中聲明的變量作用域。
當在函數中聲明一個變量的時候,如果該變量前面沒有帶上關鍵字var,這個變量就會成為全局變量,這當然是一種容易造成命名沖突的做法。
另外一種情況是用var關鍵字在函數中聲明變量,這時候的變量即是局部變量,只有在該函數內部才能訪問到這個變量,在函數外面是訪問不到的。代碼如下:
var func = function(){ var a = 1; alert ( a ); // 輸出: 1 }; func(); alert ( a ); // 輸出:Uncaught ReferenceError: a is not defined
在JavaScript中,函數可以用來創造函數作用域。此時的函數像一層半透明的玻璃,在函數里面可以看到外面的變量,而在函數外面則無法看到函數里面的變量。這是因為當在函數中搜索一個變量的時候,如果該函數內并沒有聲明這個變量,那么此次搜索的過程會隨著代碼執行環境創建的作用域鏈往外層逐層搜索,一直搜索到全局對象為止。變量的搜索是從內到外而非從外到內的。
下面這段包含了嵌套函數的代碼,也許能幫助我們加深對變量搜索過程的理解:
var a = 1; var func1 = function(){ var b = 2; var func2 = function(){ var c = 3; alert ( b ); // 輸出:2 alert ( a ); // 輸出:1 } func2(); alert ( c ); // 輸出:Uncaught ReferenceError: c is not defined }; func1();
3.1.2 變量的生存周期
除了變量的作用域之外,另外一個跟閉包有關的概念是變量的生存周期。
對于全局變量來說,全局變量的生存周期當然是永久的,除非我們主動銷毀這個全局變量。
而對于在函數內用var關鍵字聲明的局部變量來說,當退出函數時,這些局部變量即失去了它們的價值,它們都會隨著函數調用的結束而被銷毀:
var func = function(){ var a = 1; // 退出函數后局部變量a將被銷毀 alert ( a ); }; func();
現在來看看下面這段代碼:
var func = function(){ var a = 1; return function(){ a++; alert ( a ); } }; var f = func(); f(); // 輸出:2 f(); // 輸出:3 f(); // 輸出:4 f(); // 輸出:5
跟我們之前的推論相反,當退出函數后,局部變量a并沒有消失,而是似乎一直在某個地方存活著。這是因為當執行var f = func();時,f返回了一個匿名函數的引用,它可以訪問到func()被調用時產生的環境,而局部變量a一直處在這個環境里。既然局部變量所在的環境還能被外界訪問,這個局部變量就有了不被銷毀的理由。在這里產生了一個閉包結構,局部變量的生命看起來被延續了。
利用閉包我們可以完成許多奇妙的工作,下面介紹一個閉包的經典應用。假設頁面上有5個div節點,我們通過循環來給每個div綁定onclick事件,按照索引順序,點擊第1個div時彈出0,點擊第2個div時彈出1,以此類推。代碼如下:
<html> <body> <div>1</div> <div>2</div> <div>3</div> <div>4</div> <div>5</div> <script> var nodes = document.getElementsByTagName( 'div' ); for ( var i = 0, len = nodes.length; i < len; i++ ){ nodes[ i ].onclick = function(){ alert ( i ); } }; </script> </body> </html>
測試這段代碼就會發現,無論點擊哪個div,最后彈出的結果都是5。這是因為div節點的onclick事件是被異步觸發的,當事件被觸發的時候,for循環早已結束,此時變量i的值已經是5,所以在div的onclick事件函數中順著作用域鏈從內到外查找變量i時,查找到的值總是5。
解決方法是在閉包的幫助下,把每次循環的i值都封閉起來。當在事件函數中順著作用域鏈中從內到外查找變量i時,會先找到被封閉在閉包環境中的i,如果有5個div,這里的i就分別是0,1,2,3,4:
for ( var i = 0, len = nodes.length; i < len; i++ ){ (function( i ){ nodes[ i ].onclick = function(){ console.log(i); } })( i ) };
根據同樣的道理,我們還可以編寫如下一段代碼:
var Type = {}; for ( var i = 0, type; type = [ 'String', 'Array', 'Number' ][ i++ ]; ){ (function( type ){ Type[ 'is' + type ] = function( obj ){ return Object.prototype.toString.call( obj ) === '[object '+ type +']'; } })( type ) }; Type.isArray( [] ); // 輸出:true Type.isString( "str" ); // 輸出:true
3.1.3 閉包的更多作用
這一小節我們將通過幾個例子,進一步講解閉包的作用。因為篇幅所限,這里僅例舉少量示例。在實際開發中,閉包的運用非常廣泛。
1.封裝變量
閉包可以幫助把一些不需要暴露在全局的變量封裝成“私有變量”。假設有一個計算乘積的簡單函數:
var mult = function(){ var a = 1; for ( var i = 0, l = arguments.length; i < l; i++ ){ a = a * arguments[i]; } return a; };
mult函數接受一些number類型的參數,并返回這些參數的乘積。現在我們覺得對于那些相同的參數來說,每次都進行計算是一種浪費,我們可以加入緩存機制來提高這個函數的性能:
var cache = {}; var mult = function(){ var args = Array.prototype.join.call( arguments, ', ' ); if ( cache[ args ] ){ return cache[ args ]; } var a = 1; for ( var i = 0, l = arguments.length; i < l; i++ ){ a = a * arguments[i]; } return cache[ args ] = a; }; alert ( mult( 1,2,3 ) ); // 輸出:6 alert ( mult( 1,2,3 ) ); // 輸出:6
我們看到cache這個變量僅僅在mult函數中被使用,與其讓cache變量跟mult函數一起平行地暴露在全局作用域下,不如把它封閉在mult函數內部,這樣可以減少頁面中的全局變量,以避免這個變量在其他地方被不小心修改而引發錯誤。代碼如下:
var mult = (function(){ var cache = {}; return function(){ var args = Array.prototype.join.call( arguments, ', ' ); if ( args in cache ){ return cache[ args ]; } var a = 1; for ( var i = 0, l = arguments.length; i < l; i++ ){ a = a * arguments[i]; } return cache[ args ] = a; } })();
提煉函數是代碼重構中的一種常見技巧。如果在一個大函數中有一些代碼塊能夠獨立出來,我們常常把這些代碼塊封裝在獨立的小函數里面。獨立出來的小函數有助于代碼復用,如果這些小函數有一個良好的命名,它們本身也起到了注釋的作用。如果這些小函數不需要在程序的其他地方使用,最好是把它們用閉包封閉起來。代碼如下:
var mult = (function(){ var cache = {}; var calculate = function(){ // 封閉calculate函數 var a = 1; for ( var i = 0, l = arguments.length; i < l; i++ ){ a = a * arguments[i]; } return a; }; return function(){ var args = Array.prototype.join.call( arguments, ', ' ); if ( args in cache ){ return cache[ args ]; } return cache[ args ] = calculate.apply( null, arguments ); } })();
2.延續局部變量的壽命
img對象經常用于進行數據上報,如下所示:
var report = function( src ){ var img = new Image(); img.src = src; }; report( 'http://xxx.com/getUserInfo' );
但是通過查詢后臺的記錄我們得知,因為一些低版本瀏覽器的實現存在bug,在這些瀏覽器下使用report函數進行數據上報會丟失30%左右的數據,也就是說,report函數并不是每一次都成功發起了HTTP請求。丟失數據的原因是img是report函數中的局部變量,當report函數的調用結束后,img局部變量隨即被銷毀,而此時或許還沒來得及發出HTTP請求,所以此次請求就會丟失掉。
現在我們把img變量用閉包封閉起來,便能解決請求丟失的問題:
var report = (function(){ var imgs = []; return function( src ){ var img = new Image(); imgs.push( img ); img.src = src; } })();
3.1.4 閉包和面向對象設計
過程與數據的結合是形容面向對象中的“對象”時經常使用的表達。對象以方法的形式包含了過程,而閉包則是在過程中以環境的形式包含了數據。通常用面向對象思想能實現的功能,用閉包也能實現。反之亦然。在JavaScript語言的祖先Scheme語言中,甚至都沒有提供面向對象的原生設計,但可以使用閉包來實現一個完整的面向對象系統。
下面來看看這段跟閉包相關的代碼:
var extent = function(){ var value = 0; return { call: function(){ value++; console.log( value ); } } }; var extent = extent(); extent.call(); // 輸出:1 extent.call(); // 輸出:2 extent.call(); // 輸出:3
如果換成面向對象的寫法,就是:
var extent = { value: 0, call: function(){ this.value++; console.log( this.value ); } }; extent.call(); // 輸出:1 extent.call(); // 輸出:2 extent.call(); // 輸出:3
或者:
var Extent = function(){ this.value = 0; }; Extent.prototype.call = function(){ this.value++; }; console.log( this.value ); var extent = new Extent(); extent.call(); extent.call(); extent.call();
3.1.5 用閉包實現命令模式
在JavaScript版本的各種設計模式實現中,閉包的運用非常廣泛,在后續的學習過程中,我們將體會到這一點。
在完成閉包實現的命令模式之前,我們先用面向對象的方式來編寫一段命令模式的代碼。雖然還沒有進入設計模式的學習,但這個作為演示作用的命令模式結構非常簡單,不會對我們的理解造成困難,代碼如下:
<html> <body> <button id="execute">點擊我執行命令</button> <button id="undo">點擊我執行命令</button> <script> var Tv = { open: function(){ console.log( '打開電視機' ); }, close: function(){ console.log( '關上電視機' ); } }; var OpenTvCommand = function( receiver ){ this.receiver = receiver; }; OpenTvCommand.prototype.execute = function(){ this.receiver.open(); // 執行命令,打開電視機 }; OpenTvCommand.prototype.undo = function(){ this.receiver.close(); // 撤銷命令,關閉電視機 }; var setCommand = function( command ){ document.getElementById( 'execute' ).onclick = function(){ command.execute(); // 輸出:打開電視機 } document.getElementById( 'undo' ).onclick = function(){ command.undo(); // 輸出:關閉電視機 } }; setCommand( new OpenTvCommand( Tv ) ); </script> </body> </html>
命令模式的意圖是把請求封裝為對象,從而分離請求的發起者和請求的接收者(執行者)之間的耦合關系。在命令被執行之前,可以預先往命令對象中植入命令的接收者。
但在JavaScript中,函數作為一等對象,本身就可以四處傳遞,用函數對象而不是普通對象來封裝請求顯得更加簡單和自然。如果需要往函數對象中預先植入命令的接收者,那么閉包可以完成這個工作。在面向對象版本的命令模式中,預先植入的命令接收者被當成對象的屬性保存起來;而在閉包版本的命令模式中,命令接收者會被封閉在閉包形成的環境中,代碼如下:
var Tv = { open: function(){ console.log( ’打開電視機’ ); }, close: function(){ console.log( ’關上電視機’ ); } }; var createCommand = function( receiver ){ var execute = function(){ return receiver.open(); // 執行命令,打開電視機 } var undo = function(){ return receiver.close(); // 執行命令,關閉電視機 } return { execute: execute, undo: undo } }; var setCommand = function( command ){ document.getElementById( 'execute' ).onclick = function(){ command.execute(); // 輸出:打開電視機 } document.getElementById( 'undo' ).onclick = function(){ command.undo(); // 輸出:關閉電視機 } }; setCommand( createCommand( Tv ) );
3.1.6 閉包與內存管理
閉包是一個非常強大的特性,但人們對其也有諸多誤解。一種聳人聽聞的說法是閉包會造成內存泄露,所以要盡量減少閉包的使用。
局部變量本來應該在函數退出的時候被解除引用,但如果局部變量被封閉在閉包形成的環境中,那么這個局部變量就能一直生存下去。從這個意義上看,閉包的確會使一些數據無法被及時銷毀。使用閉包的一部分原因是我們選擇主動把一些變量封閉在閉包中,因為可能在以后還需要使用這些變量,把這些變量放在閉包中和放在全局作用域,對內存方面的影響是一致的,這里并不能說成是內存泄露。如果在將來需要回收這些變量,我們可以手動把這些變量設為null。
跟閉包和內存泄露有關系的地方是,使用閉包的同時比較容易形成循環引用,如果閉包的作用域鏈中保存著一些DOM節點,這時候就有可能造成內存泄露。但這本身并非閉包的問題,也并非JavaScript的問題。在IE瀏覽器中,由于BOM和DOM中的對象是使用C++以COM對象的方式實現的,而COM對象的垃圾收集機制采用的是引用計數策略。在基于引用計數策略的垃圾回收機制中,如果兩個對象之間形成了循環引用,那么這兩個對象都無法被回收,但循環引用造成的內存泄露在本質上也不是閉包造成的。
同樣,如果要解決循環引用帶來的內存泄露問題,我們只需要把循環引用中的變量設為null即可。將變量設置為null意味著切斷變量與它此前引用的值之間的連接。當垃圾收集器下次運行時,就會刪除這些值并回收它們占用的內存。
- DB2 V9權威指南
- Dynamics 365 for Finance and Operations Development Cookbook(Fourth Edition)
- Getting Started with CreateJS
- DevOps入門與實踐
- C#程序設計教程
- Julia機器學習核心編程:人人可用的高性能科學計算
- Learn WebAssembly
- Hands-On GPU:Accelerated Computer Vision with OpenCV and CUDA
- Spring MVC+MyBatis開發從入門到項目實踐(超值版)
- Getting Started with Polymer
- 邊玩邊學Scratch3.0少兒趣味編程
- Learning ECMAScript 6
- Hands-On Data Visualization with Bokeh
- 威脅建模:設計和交付更安全的軟件
- PHP面試一戰到底