深入學習JavaScript之函式作用域與塊作用域
我們將作用域比作氣泡,一層巢狀一層,每一個氣泡裡面都可以放置識別符號(函式,變數)的定義,這些氣泡在書寫階段就已經確定了。
但是,究竟是什麼生成了一個新的氣泡,只有函式能夠生成氣泡嗎?JavaScipt中的其他結構能生成作用域氣泡嗎?
1.1 函式中的作用域
對於前面的問題,最常見的答案是JavaScript具有基於函式的作用域,意味著每宣告一個函式都會為自身建立一個氣泡,其他結構都不會生成氣泡,但是這也不完全正確。
首先需要研究一個例子及其背後的一些內容
function foo(a){ var b=3; function bar(c){ console.log(a,b,c); }; };
按照我們之前的"氣泡理論",我們可以這麼理解,這段程式碼有三個氣泡
- 全域性氣泡------包含了foo函式以及所有的識別符號
- foo()氣泡------foo()函式內部所有內容
- bar()氣泡------bar()函式內部所有內容
bar、a、b、c都在foo()氣泡中,這也就意味著,你在foo()外部是無法引用它們的,因為無論是RHS查詢還是LHS查詢都會從本層氣泡出發,一層一層向外查詢而不會向內。
如:
bar();
console.log(a,b,c);
會發生ReferenceError錯誤;因為bar、a、b、c都在foo()函式內,在外部無法呼叫。
前面說過作用域就是一套用於引擎在當前作用域和其子作用域下查詢識別符號的規則。由此可得出函式作用域的規則。
函式作用域是指在這個函式內部定義的識別符號(變數和函式)能在整個函式的範圍內使用及複用(事實上在巢狀的子作用域也可以使用)
由此可得出:
函式作用域規則:外部函式定義的識別符號,內部函式可用,內部函式定義的識別符號,外部函式不可用
1.2 隱藏內部實現
在我們定義函式時,我們是怎麼做的?先建立函式再往裡面新增程式碼,那麼我們如果反過來呢,將程式碼的一部分拿出來,為它建立一個函式------實際上就是把它隱藏起來了。
實際上就是為這段程式碼建立了一個作用域氣泡,這段程式碼裡面的函式和變數都不在之前的作用域中了,而是存在於新建立的作用域氣泡中。然後根據之前的函式作用域規則。定義的內部函式把“內容程式碼”隱藏起來了。
這有什麼用呢?
1.2.1 最小特權原則
在解釋用途之前,我們先了解一個概念。最小特權原則(又名最小授權或最小暴露原則):在軟體設計中,應該最小限度的暴露必要內容,而將其他部分“隱藏”起來,例如某個功能模組或者API設計。
我們隱藏某段程式碼就是基於這個最小特權原則。如果所有的變數和函式都在全域性作用域中,可以在內部作用域中訪問到它們,但是!!!這樣會破壞之前的最小特權原則,會暴露過多的變數和函式,而這些函式和變數應該是私有的,正確的程式碼是可以阻止這些變數和函式的訪問的。
舉個例子
function doSomething(a){
b=a+doSomethingElse(a*2);
console.log(b*3);
}
function doSomethingElse(a){
return a-1;
}
var b;
doSomething(2);
我們注意到,doSomethingElse函式以及b是在全域性作用域中的,這不僅沒用,而且增添了許多的危險(它們可能會被有意無意的以非預期的方式使用)從而導致超出了doSomething的使用範圍。
我們以最小特權原則來實現對這段程式碼的隱藏。
function doSomething(a){
var b;
function doSomethingElse(a){
return a-1;
}
b=a+doSomethingElse(a*2);
console.log(b*3);
}
doSomething(2);
現在b和doSomethingElse()都被私有化了無法從外部訪問,只能被doSomething()控制。最終執行效果也沒有受到影響,但是具體內容被私有化了。
1.2.2 規避衝突
隱藏技術帶來的另一個好處是規避同名的識別符號之間的衝突,兩個識別符號可能名字相同但是用途不同,無意間可能會造成命名衝突,這會導致變數的值意外被覆蓋。
舉個例子:
function foo(){
function bar(a){
i=3; //修改for中的i值
console.log(a+i);
}
for(var i=0;i<10;i++){
bar(i*2); //每次傳入的i都是3,陷入無限迴圈
}
}
foo();
bar(...)內部的賦值語句 i=3,意外的覆蓋了宣告在foo(...)內部迴圈中的i。在這個例子中將會導致無限迴圈。因為i一直等於3,小於迴圈結束條件10。
bar(...)內部賦值操作需要宣告本地的變數來使用。採用什麼名字都可以,即使是i。var i=3。因為是在bar(...)的氣泡中,與foo(...)氣泡中的i不起衝突,從而避免了值被覆蓋,(這時命名一個不是i的變數比如j也是可以的,但是有時就會出現必須要兩個同名的識別符號的情況)--------這其實也相當於遮蔽效應(詞法分析域裡有講過)。
1.全域性名稱空間
變數的一個典型衝突是在全域性名稱空間上。當程式載入多了個第三方庫時。如果沒有妥善隱藏內部的函式以及變數,那麼很容易發生衝突。
這些庫通常會在全域性作用域中建立一個物件,所有暴露在外面的功能都將成為這個物件的屬性。這個物件就代表了庫的名稱空間,它並不會將識別符號暴露在最頂級的詞法作用域中。
舉個例子
var MyReallyCoolLibrary={
awe:"stuff";
doSomething: function(){.....}
doAnothing: funciton {.....}
}
2.模組管理
另一中規避衝突的辦法與現代機制中的模組機制非常的接近,就是從眾多模組管理器中挑選一個來使用,使用這些工具,任何庫無需將識別符號新增到全域性作用域中,而是依賴管理器的機制,將庫的識別符號顯式的匯入另一個特定的作用域中。
顯而易見,這些工具並沒有違反詞法作用域的規則,它們利用了作用域的規則強制所有識別符號都不能注入到共享的作用域中,而是儲存在內部,自私,無衝突的作用域中,這樣可以規避掉所有的意外衝突。
總結:隱藏機制源於最小特權原則,不讓重要的程式碼(識別符號)暴露在外部作用域中,利用函式作用域的訪問規則隱藏程式碼。
隱藏機制的作用還可以規避衝突,與之有相同功能的是全域性名稱空間以及模組管理工具。
1.3 函式作用域
在上面我們瞭解了,在任意程式碼片段中新增包裝函式,可以將內部的函式和變數隱藏起來,外部作用域無法訪問包裝函式的內部內容。
例如:
var a=2;
function foo(){ //汙染作用域
var a=3;
console.log(a); //a=3
}
foo(); //顯式呼叫foo()
console.log(a); //a=2
雖然這種方法可以隱藏程式碼,提高安全性,但是它並不理想,會導致一些其他的問題,①我們將程式碼存放在foo()中,它會汙染所在的作用域(在這裡面是全域性作用域)。②必須要對foo()進行顯式呼叫。
這時候我們就會思考,如果函式不需要建立函式名(至少不會汙染所在的作用域)並且能夠自動執行!!!這簡直就是太棒了!!!
正巧JavaScript中就存在一種機制,實現了這個功能。
例:
var a=2;
(function foo(){
var a=3;
console.log(a);
})();
console.log(a);
這與之前的差別就在,function foo(){...}被一個括號包了起來,以(funciotn......開始而不是funciton.....。這有什麼區別呢?、
區別就在於,當以(funtion....開頭時,函式將會被當做函式表示式而不是函式宣告。
區分函式宣告與函式表示式的方法只需要看function的位置
以(function開頭-------函式表示式
以funciton開頭--------函式宣告
函式宣告與函式表示式最重要的區別在於它們的名稱識別符號繫結的地方不同。
讓我們來看看之前的那兩個例子,
第一個例子中的foo()將會被繫結在所在的作用域中,可以直接通過foo()來呼叫它。
第二個例子中foo()被繫結在函式表示式自身的函式內而不是所在的作用域中,換句話說,便是(funciton foo(){....})中的foo只能被{....}進行訪問,外部作用域則不行,foo變數名被隱藏在自身中意味著不會汙染所在的作用域。
1.3.1 具名和匿名
對於函式表示式,我們最熟悉的就是回撥函數了
setTimeOut(function(){
console.log("hello word");
},1000);
細心點你會發現在這裡面沒有函式名----這就是匿名函式表示式,在function().....中沒有函式名(名稱識別符號)。
在JavaScript中;
函式表示式可以沒有函式名
函式宣告必須要具有函式名
匿名函式表示式雖好,在一些工具或者庫中也推廣中簡便易於編寫的程式碼。但是它的缺點也不容忽視!!!
①匿名錶達式在棧追蹤中不會顯示出有意義的函式名,使得除錯困難
②沒有函式名,當函式在呼叫自身時只能使用已經過期的arguments.callee引用(不建議使用arguments.callee,這在ES5中早已過時),比如在遞迴中,另一個函式需要呼叫自身的例子,是在事件觸發後事件監聽器需要解綁自身。
③匿名函式省略了許多對程式碼理解具有重要意義的函式名。
那麼這麼解決上述的問題呢???答案是,為行內函式表示式指定一個函式名-----這就叫做行內表示式,之前的或許叫行內匿名錶達式
setTimeOut(funtion foo(){ //新增foo()作為行內函式表示式的函式名稱
console.log("my name is foo");
},1000);
1.3.2 立即執行函式表示式
var a=3;
(funciton foo(){ //以(function開始為函式表示式
var a=2;
console.log(a);
})(); //添加了括號,代表立即執行
console.log(a);
由於函式被一堆括號包著----所以成了函式表示式,在函式表示式後新增()------立即執行函式表示式(IIFE)。這種模式很常見,人們還給它起了個名字IIFE。
在解決函式宣告帶給我們的問題時,光靠一個函式表示式還是不夠的,還要讓它成為立即執行的函式表示式。
當然了在IIFE中,函式名不是必須的,IIFE最常見的使用是匿名錶達式。
IIFE函式名形式:
var a=3;
(funciton IIFE(){ //以(function開始為函式表示式
var a=2;
console.log(a);
})(); //添加了括號,代表立即執行
console.log(a);
相較於傳統的IIFE模式,更多人喜歡另一個改進模式:(function foo(){...}())。第一種形式中函式表示式被包括在()中,然後在後面用另一個()括號來呼叫。第二種用來呼叫的括號移進了封裝的括號中。這兩種功能一致,用哪個看個人喜好
IIFE還有一個進階的用法,把它們當做函式呼叫並傳遞實參進去。
什麼意思呢?我們通過一個例子來說明。
var a=2;
(function IIFE(global){
var a=3;
console.log(a); //3
console.log(global.a) //2
})(window);
console.log(a); //2
我們通過引入一個全域性物件(window)前面提到過,全域性變數會自動成為全域性物件的屬性。我們在IIFE中傳入一個全域性物件(window),全域性變數a為這個物件的屬性,這個物件傳入了IIFE中命名為"global",我們試著引用全域性變數"a",通過global.a。成功了,這是一種內部函式訪問全域性變數的方式
IIFE還有一種變化的用途是倒置程式碼的執行順序,將需要執行的函式放在第二位,在IIFE函式執行後當做引數傳遞進去。這種模式在UMD專案中被廣泛使用。
var a=3;
(function IIFE(def){
def(window);
})(function def(global){ //需要執行的函式放置在(funtion IIEF(){...})()中的後面括號裡。
var a=2;
console.log(a); //2
console.log(global); //3
})
函式表示式def定義在片段的第二段程式碼中,然後當做引數被傳遞到IIFE函式中。最後def被呼叫傳入全域性變數window。
總結:隱藏函式能夠儘量使程式碼中重要部分不暴露出來,但是問題也很大,解決的方式是通過立即執行的函式表示式(IIFE)。
funciton foo(){.....}在一個括號中------------------函式表示式
函式表示式後面新增一個括號-----------------------立即執行
兩者相加----------------立即執行的函式表示式
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
函式宣告-----------------function foo(){....}不在()中,繫結的作用域在函式當前所在的作用域。
函式表示式---------------function foo(){...}在()中,繫結的作用域在函式表示式內自身函式作用域
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
函式表示式分為具名(函式有名稱)和匿名(函式無名稱),一般用在行內的叫行內表示式,具名函式表示式要比匿名函式表示式優點多。
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
立即執行函式表示式(IIFE)有兩種格式:
第二部分在括號外的:(function foo(){...})();
第二部分在括號內的:(function foo(){...}());
立即執行函式表示式的作用:
①解決隱藏內部函式的缺點
②傳遞外部物件作為引數獲得外部變數,外部物件在第二個()中
③倒置程式碼的執行順序,將需要執行的函式放置在第二部分,作為引數傳遞給IIFE
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1.4 塊作用域
我們學習了詞法作用域,函式作用域。在其他比較理想化的程式語言中,還存在這一個塊作用域機制。但是在JavaScript中我們就沒有關於塊作用機制,但是在JavaScript中存在著類似於塊作用域的方法。
for(var i=0;i<10;i++)
{.....}
我們在for的頭部直接定義了i,從程式碼執行效果,我們會認為i只在for()中有效,但這是不對的,在for(...)中定義的i是在for(...)的外部函式中有效的
(function IIFE(){
for(var i=0;i<10;i++);
console.log(i); //10
})()
我們建立了一個for讓i的值迴圈成10,然後輸出i,在for()中定義的i變成了外部變數!!!我們通常只是想在for()中使用i,而不想在外部也使用,這就是塊作用域解決的問題了--------------塊作用域能讓變數只在區域性有用,
塊作用域是之前最小授權原則的擴充套件工具------將程式碼在函式中的隱藏資訊細化成函式塊程式碼中的隱藏資訊。
1.4.1 with
with主要用於簡寫物件的屬性引用,我們之前在深入理解JavaScript詞法作用域中提到過,with(...)與eval(...)一樣能夠欺騙詞法域。在with中宣告的變數會被繫結到with建立的作用域中。
1.4.2 try/catch
在ES3中定義的try/catch中也屬於建立塊作用域的一種語法,try/catch是JavaScript中錯誤的捕獲及處理方式,在tyr中捕獲到錯誤(存放可能發生錯誤的語句)由catch中的函式來處理它,不過遺憾的是,只有在catch(err){}中建立的err才屬於try/catch作用域中存在的引數。
try{
undefined()
}catch (err){
var a=1;
console.log(err); //err
console.log(a); //1
}
console.log(err); //ReferenceError: err is not foud
console.log(a); //1
正如上程式碼所示:在try/catch中定義的err,在外部函式中引用發生錯誤,try/catch中定義的a在外部函式中引用正確。
1.4.3 let
在JavaScript ES6中新增了一個宣告變數的關鍵字:let。let能夠將宣告的變數繫結到{....}塊作用域中。換句話說let隱式的將變數繫結到了所在的塊作用中,
if(true)
{
let bar=2;
console.log(bar); //2
}
console.log(bar); //ReferenceError
在上面我們在if這個塊中用let建立了一個變數bar=2;在if塊中輸出結果正確,在if外輸出錯誤。這是因為"let bar=2"將bar繫結在了If這一個塊內,在塊外對"bar"進行輸出,自然會報錯誤。
用let將變數新增到一個已經存在的塊作用域上的行為是隱式的。在開發和修改程式碼的過程中,如果沒有密切關注哪些塊作用域中有繫結的變數,並且習慣地移動這些塊或者將其包含在其他的塊中,就會導致程式碼變得混亂
比較好的解決方法是為塊作用域顯式地建立塊,使得變數的附屬關係變得清晰。通常來講,顯式的程式碼要優於隱式的程式碼或者一些不清晰的程式碼。顯示的塊作用域風格非常容易編寫,並且和其他語言的塊作用域原理一致
if(true){
{let bar=2; //新增{ }將需要顯示的塊構建出來,程式碼簡但明瞭,易於複用
console.log(bar); //2
}
console.log(bar); //ReferenceError
}
只要宣告正確,我們可以在宣告的任何部位用{...}來為let建立一個用於繫結的塊!!!
關於let還有一個很有趣的地方,let所宣告的變數在塊作用域內是沒有提升的(提升是指宣告被視為存在於其出現的作用域的整個範圍內。)在let的塊作用域內,在宣告的程式碼(let)被執行之前,宣告並不"存在"。
{
console.log(a); //ReferenceError
var a=2;
}
1.4.3.1垃圾收集
另一個塊作用域非常有用的原因和閉包及回收記憶體垃圾的回收機制有關。而有關於內部原理和閉包的問題,我們現在還暫時學不到就不給予探討了。
考慮以下程式碼:
function process(data){
..... //在這裡面對資料進行處理
}
var someReallyBigData={....}; //儲存資料物件
process(someReallyBigData); //處理資料
var btn=document.getElementByid("my_button"); //得到按鍵點選物件
//新增按鈕監聽器,對按鈕行為進行監控,並處理
btn.addEvenListener("click",function click(evt){
console.log("button clicked");
},/*capturingPhase=*/false);
click函式的點選回撥並不需要someReallyBigData變數。理論上這就意味著當process(...)執行了以後,,在記憶體中佔用大量空間的資料結構就可以被垃圾回收了。但是由於click函式形成了一個覆蓋整個作用域的閉包,JavaScript引擎極有可能保留這個資料結構(取決於具體實現)。
塊作用域就可以解決這個問題,實現佔據記憶體的資料結構的釋放,讓引擎清除這個資料結構。
怎麼實現呢??
function process(data){
..... //在這裡面對資料進行處理
}
{ //添加了這一行
let someReallyBigData={....}; //儲存資料物件
process(someReallyBigData); //處理資料
} //以及這一行
var btn=document.getElementByid("my_button"); //得到按鍵點選物件
//新增按鈕監聽器,對按鈕行為進行監控,並處理
btn.addEvenListener("click",function click(evt){
console.log("button clicked");
},/*capturingPhase=*/false);
將要實現的資料結構用let宣告、處理,並繫結在一個顯式的塊級作用域中。
1.4.3.2 let迴圈
繼續我們之前提到的例子,for迴圈內的變數如何繫結在for這一個塊內的?答案同樣是通過let關鍵字給變數宣告。
for(let i=0;i<3;i++)
{
console.log(i); //0、1、2
}
console.log(i); //ReferencEerror
for迴圈中的let不僅將i繫結在for()塊作用域中,還將i繫結到for()的每一次迴圈迭代中(也就是在每一次迴圈中都要宣告一次變數,每一次宣告的變數是上一次的值)
我們通過另一個程式碼來看看for()中的迭代繫結
{
let j;
for(j=0;j<3;j++)
{
let i=j;
console.log(i); //0、1、2
}
}
如上所示,我們在for中定義了一個let i只存在與for(...)中的變數 "i",來追蹤塊作用域中的 "j" 的變化,可以看到每次 "j" 的變化都在前一個 "j" 值的基礎上+1。證明每次迭代使用的都是同一個 "j"
1.4.3.3 使用let重構var
在我們對程式碼進行重構時,經常容易犯的一個錯誤是忽略了 "var" 構建的作用域是函式作用域或者全域性作用域,而 "let" 構建的作用域是當前新建立的作用域(不是當前函式作用域,也不是全域性作用域)
例如:
(function IIFE() {
var bar=10;
if(true){
var baz=3;
if(bar>baz){
console.log(bar); //10
}
}
})()
重構後:
(function IIFE() {
var bar=10;
if(true){
var baz=3;
}
if(bar>baz){
console.log(bar);
}
})()
使用 let 替換掉 var
(function IIFE() {
var bar=10;
if(true){
let baz=3;
}
if(bar>baz){
console.log(bar);
}
})()
此處發生錯誤!!!因為之前在第一個 if() 中宣告的是var i是全域性變數中的i,當我們用 "let" 在 "if(...)" 中聲明瞭一個塊作用域變數 "i"
1.4.4 const
除了let外,ES6中還引進了conts,同樣可以用來建立塊作用域,不過與let不同的是,它的值是固定的(常量)。之後任何對它進行更改的操作都會導致錯誤。
舉個例子
(function IIFE(){
const b=1; // b等於常量
console.log(b); //1
b=1; //TypeEroor
})();
console.log(b); //ReferenceError
如上我們可以看到在IIFE(...)我們建立了一個 conts 型別的常量 b=1;當嘗試對 b 進行修改時,提示TypeError錯誤
總結:函式是JavaScript中最常見的作用域單元,在函式作用域,只能從內部訪問外部的函式,外部函式不能訪問內部函式,我們根據最小授權原則將重要程式碼識別符號放進內部函式中,達到了隱藏程式碼的效果。
比內部函式作用域更為標準化的是塊作用域,它是存在於一個程式碼塊的作用域,範圍為:全域性作用域>外部函式作用域>內部函式作用域>塊作用域。
實現塊作用域的方法有:
①with(){...}:它將{...}內的變數建立在一個新的作用域中(不是函式作用域)
②try/catch:ES3開始在catch中的引數,也就是catch(...){...}中(...)的引數繫結在塊作用域中
③let:ES6開始 let 宣告變數(與var差不多),它可以將變數繫結到一個塊作用域中,
if(true){let a=2;.....}-------let將變數宣告並繫結在if(...)這一個塊中。
作用
-
建立塊作用域內可用的變數
-
垃圾收集
-
let迴圈
④conts:建立塊作用域內的常量,常量不可修改,在塊作用域內無效。