深入學習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()的每一次循環迭代中(也就是每一次循環用的都是同一個i)

我們通過另一個代碼來看看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:創建塊作用域內的常量,常量不可修改,在塊作用域內無效。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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