前言:
JavaScript是一種應用非常廣泛的語言,其也有一些自身特點和優勢,本文重在講述其作用域機制以及閉包,會從一些實例來探討其機理。
作用域在JavaScript程序員日常使用中有不同的含義,如下所示:
- this綁定的值;
- this綁定的值定義的執行上下文;
- 一個變量的“生命周期”;
- 變量的值解析方案,或詞法綁定。
下面將講訴JavaScript作用域概念,由此引出變量值解析方案的一般想法,最后再探討JavaScript里閉包這一重要知識點。
1.全局作用域
所有瀏覽器都支持 window 對象,它表示瀏覽器窗口,JavaScript 全局對象、函數以及變量均自動成為 window 對象的成員。所以,全局變量是 window 對象的屬性,全局函數是 window 對象的方法,甚至 HTML DOM 的 document 也是 window 對象的屬性之一。
全局變量是JavaScript里生命周期(一個變量多長時間內保持一定的值)最長的變量,其將跨越整個程序,可以被程序中的任何函數方法訪問。
在全局下聲明的變量都會在window對象下,都在全局作用域中,我們可以通過window對象訪問,也可以直接訪問。
1 var name = "jeri"; 2 console.log(window.name); // 輸出:jeri 3 console.log(name); // 輸出:jeri
在JS中任何位置,沒有使用var關鍵字聲明的變量也都是全局變量。
1 function fun() { 2 name = "jeri"; 3 alert(name); 4 } 5 6 console.log(name); // 輸出:jeri
全局變量存在於整個函數的生命周期中,然而其在全局范圍內很容易被篡改,我們在使用全局變量時一定要小心,盡量不要使用全局變量。在函數內部聲明變量沒有使用var也會產生全局變量,會為我們造成一些混亂,比如變量覆蓋等。所以,我們在聲明變量的任何時候最好都要帶上var。
全局變量存在於程序的整個生命周期,但並不是通過其引用我們一定可以訪問到全局變量。
2.詞法作用域
詞法作用域:函數在定義它們的作用域里運行,而不是在執行它們的作用域里運行。也就是說詞法作用域取決於源碼,通過靜態分析就能確定,因此詞法作用域也叫做靜態作用域。with和eval除外,所以只能說JS的作用域機制非常接近詞法作用域(Lexical scope)。詞法作用域也可以理解為一個變量的可見性,及其文本表述的模擬值。
1 var name = "global"; 2 3 function fun() { 4 var name = "jeri"; 5 return name; 6 } 7 8 console.log(fun()); // 輸出:jeri 9 console.log(name); // 輸出:global
在通常情況下,變量的查詢從最近接的綁定上下文開始,向外部逐漸擴展,直到查詢到第一個綁定,一旦完成查找就結束搜索。就像上例,先查找離它最近的name="jeri",查詢完成后就結束了,將第一個獲取的值作為變量的值。
3.動態作用域
在編程實踐中,最容易低估和過度濫用的概念就是動態作用域,因為很少有語言支持這種方式為綁定解析方案。
動態作用域與詞法作用域相對而言的,不同於詞法作用域在定義時確定,動態作用域在執行時確定,其生存周期到代碼片段執行為止。動態變量存在於動態作用域中,任何給定的綁定的值,在確定調用其函數之前,都是不可知的。
在代碼執行時,對應的作用域鏈常常是保持靜態的。然而當遇到with語句、call方法、apply方法和try-catch中的catch時,會改變作用域鏈的。以with為例,在遇到with語句時,會將傳入的對象屬性作為局部變量來顯示,使其便於訪問,也就是說把一個新的對象添加到了作用域鏈的頂端,這樣必然影響對局部標志符的解析。當with語句執行完畢后,會把作用域鏈恢復到原始狀態。實例如下:
1 var name = "global"; 2 3 // 使用with之前 4 console.log(name); // 輸出:global 5 6 with({name:"jeri"}){ 7 console.log(name); // 輸出:jeri 8 } 9 10 // 使用with之后,作用域鏈恢復 11 console.log(name); // 輸出:global
在作用域鏈中有動態作用域時,this引用也會變得更加復雜,不再指向第一次創建時的上下文,而是由調用者確定。比如在使用apply或call方法時,傳入它們的第一個參數就是被引用的對象。實例如下:
1 function globalThis() { 2 console.log(this); 3 } 4 5 globalThis(); // 輸出:Window {document: document,external: Object…} 6 globalThis.call({name:"jeri"}); // 輸出:Object {name: "jeri"} 7 globalThis.apply({name:"jeri"},[]); // 輸出:Object {name: "jeri"}
因為this引用是動態作用域,所以在編程過程中一定要注意this引用的變化,及時跟蹤this的變動。
4.函數作用域
函數作用域,顧名思義就是在定義函數時候產生的作用域,這個作用域也可以稱為局部作用域。和全局作用域相反,函數作用域一般只在函數的代碼片段內可訪問到,外部不能進行變量訪問。在函數內部定義的變量存在於函數作用域中,其生命周期隨着函數的執行結束而結束。實例如下:
1 var name = "global"; 2 3 function fun() { 4 var name = "jeri"; 5 console.log(name); // 輸出:jeri 6 7 with ({name:"with"}) { 8 console.log(name); // 輸出:with 9 } 10 console.log(name); // 輸出:jeri 11 } 12 13 fun(); 14 15 // 不能訪問函數作用域 16 console.log(name); // 輸出:global
5.沒有塊級作用域
不同於其他編程語言,在JavaScript里並沒有塊級作用域,也就是說在for、if、while等語句內部的聲明的變量與在外部聲明是一樣的,在這些語句外部也可以訪問和修改這些變量的值。實例如下:
1 function fun() { 2 3 if(0 < 2) { 4 var name = "jeri"; 5 } 6 console.log(name); // 輸出:jeri 7 name = "change"; 8 console.log(name); // 輸出:change 9 } 10 11 fun();
6.作用域鏈
JavaScript里一切皆為對象,包括函數。函數對象和其它對象一樣,擁有可以通過代碼訪問的屬性和一系列僅供JavaScript引擎訪問的內部屬性。其中一個內部屬性是作用域,包含了函數被創建的作用域中對象的集合,稱為函數的作用域鏈,它用來保證對執行環境有權訪問的變量和函數的有序訪問。
當一個函數創建后,它的作用域鏈會被創建此函數的作用域中可訪問的數據對象填充。在全局作用域中創建的函數,其作用域鏈會自動成為全局作用域中的一員。而當函數執行時,其活動對象就會成為作用域鏈中的第一個對象(活動對象:對象包含了函數的所有局部變量、命名參數、參數集合以及this)。在程序執行時,Javascript引擎會通過搜索上下文的作用域鏈來解析諸如變量和函數名這樣的標識符。其會從作用域鏈的最里面開始檢索,按照由內到外的順序,直到完成查找,一旦完成查找就結束搜索。如果沒有查詢到標識符聲明,則報錯。當函數執行結束,運行期上下文被銷毀,活動對象也隨之銷毀。實例如下:
1 var name = 'global'; 2 3 function fun() { 4 console.log(name); // output:global 5 name = "change"; 6 // 函數內部可以修改全局變量 7 console.log(name); // output:change 8 // 先查詢活動對象 9 var age = "18"; 10 console.log(age); // output:18 11 } 12 13 fun(); 14 15 // 函數執行完畢,執行環境銷毀 16 console.log(age); // output:Uncaught ReferenceError: age is not defined
7.閉包
閉包是JavaScript的一大謎團,關於這個問題有很多文章進行講述,然而依然有相當數量的程序員對這個概念理解不透徹。閉包的官方定義為:一個擁有許多變量和綁定了這些變量的環境的表達式(通常是一個函數),因而這些變量也是該表達式的一部分。
一句話概括就是:閉包就是一個函數,捕獲作用域內的外部綁定。這些綁定是為之后使用而被綁定,即使作用域已經銷毀。
自由變量
自由變量與閉包的關系是,自由變量閉合於閉包的創建。閉包背后的邏輯是,如果一個函數內部有其他函數,那么這些內部函數可以訪問在這個外部函數中聲明的變量(這些變量就稱之為自由變量)。然而,這些變量可以被內部函數捕獲,從高階函數(返回另一個函數的函數稱為高階函數)中return語句實現“越獄”,以供以后使用。內部函數在沒有任何局部聲明之前(既不是被傳入,也不是局部聲明)使用的變量就是被捕獲的變量。實例如下:
1 function makeAdder(captured) { 2 return function(free) { 3 var ret = free + captured; 4 console.log(ret); 5 } 6 } 7 8 var add10 = makeAdder(10); 9 10 add10(2); // 輸出:12
從上例可知,外部函數中的變量captured被執行加法的返回函數捕獲,內部函數從未聲明過captured變量,卻可以引用它。
如果我們再創建一個加法器將捕獲到同名變量captured,但有不同的值,因為這個加法器是在調用makeAdder之后被創建:
1 var add16 = makeAdder(16); 2 3 add16(18); // 輸出:34 4 5 add10(10); // 輸出:20
如上述代碼所示,每一個新的加法器函數都保留了自己創建時捕獲的captured實例。
變量遮蔽
在JavaScript中,當變量在一定作用域內聲明,然后在另一個同名變量在一個較低的作用域聲明,會發生變量的遮蔽。實例如下:
1 var name = "jeri"; 2 var name = "tom"; 3 4 function glbShadow() { 5 var name = "fun"; 6 7 console.log(name); // 輸出:fun 8 } 9 10 glbShadow(); 11 12 console.log(name); // 輸出:tom
當在一個變量同一作用域內聲明了多次時,最后一次聲明會生效,會遮蔽以前的聲明。
變量聲明的遮蔽很好理解,然而函數參數的遮蔽就略顯復雜。例如:
1 var shadowed = 0; 2 3 function argShadow(shadowed) { 4 var str = ["Value is",shadowed].join(" "); 5 console.log(str); 6 } 7 8 argShadow(108); // output:Value is 108 9 10 argShadow(); // output:Value is
函數argShadow的參數shadowed覆蓋了全局作用域內的同名變量。即使沒有傳遞任何參數,仍然綁定的是shadowed,並沒有訪問到全局變量shadowed = 0。
任何情況下,離得最近的變量綁定優先級最高。實例如下:
1 var shadowed = 0; 2 3 function varShadow(shadowed) { 4 var shadowed = 123; 5 var str = ["Value is",shadowed].join(" "); 6 console.log(str); 7 } 8 9 varShadow(108); // output:Value is 123 10 11 varShadow(); // output:Value is 123
varShadow(108)打印出來的並不是108而是123,即使沒有參數傳入也是打印的123,先訪問離得最近的變量綁定。
遮蔽變量同樣發生在閉包內部,實例如下:
1 function captureShadow(shadowed) { 2 3 console.log(shadowed); // output:108 4 5 return function(shadowed) { 6 7 console.log(shadowed); // output:2 8 var ret = shadowed + 1; 9 console.log(ret); // output:3 10 } 11 } 12 13 var closureShadow = captureShadow(108); 14 15 closureShadow(2);
在編寫JavaScript代碼時,因為變量遮蔽會使很多變量綁定超出我們的控制,我們應盡量避免變量遮蔽,一定要注意變量命名。
典型誤區
下面是一個非常典型的問題,曾經困擾了很多人,下面也來探討下。
1 var test = function() { 2 var ret = []; 3 4 for(var i = 0; i < 5; i++) { 5 ret[i] = function() { 6 return i; 7 } 8 } 9 10 return ret; 11 }; 12 var test0 = test()[0](); 13 console.log(test0); // 輸出:5 14 15 var test1 = test()[1](); 16 console.log(test1); //輸出:5
從上面的例子可知,test這個函數執行之后返回一個函數數組,表面上看數組內的每個函數都應該返回自己的索引值,然而並不是如此。當外部函數執行完畢后,外部函數雖然其執行環境已經銷毀,但閉包依然保留着對其中變量綁定的引用,仍然駐留在內存之中。當外部函數執行完畢之后,才會執行內部函數,而這時內部函數捕獲的變量綁定已經是外部函數執行之后的最終變量值了,所以這些函數都引用的是同一個變量i=5。
下面有個更優雅的例子來表述這個問題:
1 for(var i = 0; i < 5; i++) { 2 3 setTimeout(function() { 4 console.log(i); 5 }, 1000); 6 } 7 8 // 每隔1秒輸出一個5
按照我們的推斷,上例應該輸出1,2,3,4,5。然而,事實上輸出的是連續5個5。為什么出現這種詭異的狀況呢?其本質上還是由閉包特性造成的,閉包可以捕獲外部作用域的變量綁定。
上面這個函數片段在執行時,其內部函數和外部函數並不是同步執行的,因為當調用setTimeout時會有一個延時事件排入隊列,等所有同步代碼執行完畢后,再依次執行隊列中的延時事件,而這個時候 i 已經 是5了。
那怎么解決這個問題呢?我們是不是可以在每個循環執行時,給內部函數傳進一個變量的拷貝,使其在每次創建閉包時,都捕獲一個變量綁定。因為我們每次傳參不同,那么每次捕獲的變量綁定也是不同的,也就避免了最后輸出5個5的狀況。實例如下:
1 for(var i = 0; i < 5; i++) { 2 3 (function(j) { 4 5 setTimeout(function() { 6 console.log(j); 7 }, 1000); 8 })(i); 9 } 10 11 // 輸出:0,1,2,3,4
閉包具有非常強大的功能,函數內部可以引用外部的參數和變量,但其
參數和變量不會被垃圾回收機制回,常駐內存,會增大內存使用量,使用不當很容易造成內存泄露。但,閉包也是
javascript
語言的一大特點,主要應用閉包場合為:設計私有的方法和變量。
模擬私有變量
從上文的敘述我們知道,變量的捕獲發生在創建閉包的時候,那么我們可以把閉包捕獲到的變量作為私有變量。實例如下:
1 var closureDemo = (function() { 2 var PRIVATE = 0; 3 4 return { 5 inc:function(n) { 6 return PRIVATE += n; 7 }, 8 dec:function(n) { 9 return PRIVATE -= n; 10 } 11 }; 12 })(); 13 14 var testInc = closureDemo.inc(10); 15 //console.log(testInc); 16 // 輸出:10 17 18 var testDec = closureDemo.dec(7); 19 //console.log(testDec); 20 // 輸出:3 21 22 closureDemo.div = function(n) { 23 return PRIVATE / n; 24 }; 25 26 var testDiv = closureDemo.div(3); 27 console.log(testDiv); 28 //輸出:Uncaught ReferenceError: PRIVATE is not defined
自執行函數closureDemo執行完畢之后,自執行函數作用域和PRIVATE隨之銷毀,但PRIVATE仍滯留在內存中,也就是加入到closureDemo.inc和closureDemo.dec的作用域鏈中,閉包也就完成了變量的捕獲。但之后新加入的closureDemo.div並不能在作用域中繼續尋找到PRIVATE了。因為,函數只有被調用時才會執行函數里面的代碼,變量的捕獲也只發生在創建閉包時,所以之后新加入的div方法並不能捕獲PRIVATE。
創建特權方法
通過閉包我們可以創建私有作用域,那么也就可以創建私有變量和私有函數。創建私有函數的方式和聲明私有變量方法一致,只要在函數內部聲明函數就可以了。當然,既然可以模擬私有變量和私有函數,我們也可以利用閉包這個特性,創建特權方法。實例如下:
1 (function() { 2 3 // 私有變量和私有函數 4 var privateVar = 10; 5 6 function privateFun() { 7 return false; 8 }; 9 10 // 構造函數 11 MyObj = function() { 12 13 }; 14 15 // 公有/特權方法 16 MyObj.prototype.publicMethod = function() { 17 privateVar ++; 18 return privateFun(); 19 } 20 })();
上面這個實例創建了一個私有作用域,並封裝了一個構造函數和對應的方法。需要注意的是在上面的實例中,在聲明MyObj這個函數時,使用的是不帶var的函數表達式,我們希望產生的是一個全局函數而不是局部的,不然我們依然在外部無法訪問。所以,MyObj就成為了一個全局變量,能夠在外部進行訪問,我們在原型上定義的方法publicMethod也就可以使用,通過這個方法我們也就可以訪問私有函數和私有變量了。
總的來說,因為閉包奇特的特性,可以通過它實現一些強大的功能。但,我們在日常編程中,也要正確的使用閉包,要時刻注意回收不用的變量,避免內存泄露。
本文通過對作用域的系統講述,一步步引出對閉包概念,詳細探討了閉包的生成機理和一些常見錯誤理解,最后對閉包進行了應用舉例。但限於個人水平有限,但有疏漏之處還望見諒。歡迎交流!