JavaScript中的函數(shù)作用域

正如上篇文章介紹的那樣,作用域包含了一些列的”氣泡“,每一個都可以作為容器,其中包含了標識符(變量、函數(shù))的定義,這些氣泡互相嵌套,并且整齊地排列成蜂窩形,排列的結構是在寫代碼的時候定義的。

但是,究竟是什么生成了一個新的氣泡?只有函數(shù)會生成新的氣泡嗎?js中的其他結構能生成作用域氣泡嗎?

函數(shù)中的作用域

對于上面的問題,最常見的答案是,js中具有基于函數(shù)的,意味著每聲明一個函數(shù)都會為其自身創(chuàng)建一個氣泡,而其他結構都不會創(chuàng)建作用域氣泡,但是事實上并不完全正確,下面我們看一下:

首先需要研究一下函數(shù)作用域及其背后的一些內容。

考慮以下代碼:

function foo(a){
  var b = 2;
  // 一些代碼
  function bar(){
    // ...

    //  更多的代碼
  }
  var c = 3;
}

在這個代碼中,foo(...)的作用域氣泡包含了標識符 a,b,c 和bar 無論標識符聲明出現(xiàn)在作用域何處,這個標識符所代表的變量或者函數(shù)都將附屬于所屬作用域的氣泡

bar(...)擁有自己的作用域氣泡,全局作用域也有自己的作用域氣泡它只包含了一個標識符:foo。

由于標識符a,b,c 和bar 都附屬于 foo(...)的作用域氣泡,因此無法從 foo(..)的外部對它進行訪問。也就是說,這些標識符全部都無法從全局的作用域中進行訪問,因此下面的代碼會導致 ReferenceError的錯誤:

bar(); // 失敗

console.log(a,b,c); // 三個全部失敗。

但是,這些標識符在 foo(..)的內部是可以被訪問的,同樣在bar(..)內部也可以被訪問,假設bar(..)內部沒有同名的標識符聲明)。

函數(shù)作用域的含義是指:屬于這個函數(shù)的全部變量都可以在整個函數(shù)的范圍內使用及復用(事實上在嵌套的作用域中也可以使用)

這種設計方案是非常有用的,能充分利用js變量可以根據(jù)需要改變值的類型的”動態(tài)“特性。

但與此同時,如果不細心處理那些可以再整個作用域內被訪問的變量,可能會帶來意想不到的問題。

隱藏內部實現(xiàn)

對函數(shù)的傳統(tǒng)認知就是先聲明一個函數(shù),然后再向內部里面添加代碼。但是反過來想也是可以的從所寫的代碼中挑選一個任意的片段,然后用函數(shù)聲明對它進行包裝,實際上就是把這些代碼隱藏起來了。

實際的結果就是在這個代碼片段的周圍創(chuàng)建了一個作用域氣泡,也就是說這段代碼中的任何聲明(變量或者函數(shù))都將綁定在這個新創(chuàng)建的包裝函數(shù)的作用域中,換句話說,可以將函數(shù)和變量包裹在一個函數(shù)的作用域中,然后用這個作用域來“隱藏”它們;

為什么隱藏作用域和函數(shù)是一個有用的技術?

有很多的原因促成了這種基于作用域的隱藏方法,他們大都是從最小特權原則中引申出來的,也叫最小授權或者最小暴露原則,這個原則是指在軟件設計中,應該最小限度的暴露必要的內容,而將其他的內容都隱藏起來,比如某一個模塊或者對象的API設計。

這個原則可以延伸到如何選擇作用域來包含變量和函數(shù),如果所有變量和函數(shù)都在全局 作用域中,當然可以在所有的內部嵌套作用域中訪問到它們。但是這樣會破壞前面提到的最小特權原則,因為很可能暴露過多的變量或者函數(shù),而這些變量或者函數(shù)本應該是私有的,正確的代碼應該是在可以阻止對這些變量或者函數(shù)進行訪問的。

function doSomething(a) {
   b = a + doSomethingElse(a * 2);
   console.log(b * 3);
}

function doSomethingElse(a) {
   return a - 1;
}
var b;
doSomething(2);// 15

在這個代碼片段中,變量 b 和函數(shù) doSomethingElse(..) 應該是 doSomething(..) 內部具體 實現(xiàn)的“私有”內容。給予外部作用域對 b 和 doSomethingElse(..)的“訪問權限”不僅 沒有必要,而且可能是“危險”的,因為它們可能被有意或無意地以非預期的方式使用, 從而導致超出了 doSomething(..) 的適用條件。更“合理”的設計會將這些私有的具體內 容隱藏在 doSomething(..) 內部,例如:

    function doSomething(a) {
      function doSomethingElse(a) {
        return a - 1;
      }
      var b;
      b = a + doSomethingElse(a * 2);
      console.log(b * 3);
    }
    doSomething(2); // 15

現(xiàn)在,b 和 doSomethingElse(..) 都無法從外部被訪問,而只能被 doSomething(..) 所控制。 功能性和最終效果都沒有受影響,但是設計上將具體內容私有化了,設計良好的軟件都會 依此進行實現(xiàn)。

規(guī)避沖突

“隱藏”作用域中的變量和函數(shù)所帶來的另一個好處,是可以避免同名標識符之間的沖突,兩個標識符可能具有相同的名字但是用于卻不一樣,無意間可能造成命名沖突沖突會導致變量的值被意外覆蓋。

例如:

function foo(){
  function bar(a){
    i = 3; 
     console.log(a+i);
}

  for(var i = 0;i<10;i++){
    bar(i+2);
  }
}
foo();

bar(..)內部的賦值表達式 i = 3 意外覆蓋了聲明在 foo(..)內部 for循環(huán)中的 i 在這個例子中將導致無限循環(huán),因為 i 被固定設置為3 永遠滿足教育10這個條件。

bar(..)內部賦值操作需要聲明一個本地變量來使用,采用任何名字都可以,var i = 3 就可以滿足這個需求另一種是采用完全不同的標識符名稱,比如 var j = 3; 但是軟件設計在某種情況下是可能自然而然的要求使用同樣的標識符名稱,因此在這種情況下使用作用域“隱藏”內部聲明式唯一的最佳選擇。

1、全局命名空間

變量沖突的一個典型例子存在于全局作用域中,當程序中加載了多個第三方庫時候,如果它們沒有妥善地將內部私有的函數(shù)或變量隱藏起來,就會很容易引起沖突。

這些庫通常會在全局作用域中聲明一個名字足夠獨特的變量,通常是一個對象,這個對象被用作庫的命名空間,所有需要暴露給外部的功能都成為這個對象的屬性,而不是將自己的標識符暴露在頂級的詞法作用域中。

例如:

var MyReallyCoolLibrary = {
  awesome: "stuff",
  doSomething: function () {
   // ... 
 },
  doAnotherThing: function () {
  // ...
 }
};
2、模塊管理

另外一種避免沖突的辦法和現(xiàn)代的模塊機制很相似,就是從眾多模塊管理器中挑選一個來使用使用這些工具任何庫都無需將標識符假如到全局作用域中,而是通過依賴管理器的機制將庫的標識符顯式地導入一個特定的作用域中。

函數(shù)作用域

我們已經(jīng)知道,在任意代碼片段外部添加包裝函數(shù),可以將內部的變量和函數(shù)隱藏起來,外部作用域無法訪問包裝函數(shù)內部的任何內容。

例如:

    var a = 2;
    function foo() {
      var a = 3;
      console.log(a); // 3
    }

    foo(); //

    console.log(a);// 2

雖然這種技術可以解決一些問題,但是它并不理想,因為會導致一些額外的問題,首先,必須聲明一個具名函數(shù)foo() 意味著foo這個名稱本身“污染”了所在的作用域(在這個例子中是全局作用域)其次,必須顯式地通過函數(shù)名調用這個函數(shù)才能運行其中的代碼。

如果函數(shù)不需要函數(shù)名(或者函數(shù)名可以不污染所在的作用域),并且能夠自動運行,這將會更加理想。

幸好 js提供了能夠同時解決這兩個問題的方案。

var a = 2;

(function foo() {
  var a = 3;
  console.log(a); // 3
})()
console.log(a); // 2

接下來我們來分別介紹這里發(fā)生的事情,

首先包裝函數(shù)聲明以 (function...)而不是function...開始盡管看上去這不是一個顯眼的細節(jié)但是這是一個非常重要的區(qū)別,函數(shù)會被當做函數(shù)表達式而不是一個標準的函數(shù)聲明來處理。

區(qū)分函數(shù)聲明和表達式最簡單的方法是看function關鍵字出現(xiàn)在聲明中的位置(不僅僅是一行代碼)而是整個聲明中的位置 如果function是聲明中的第一個詞,那么就是一個函數(shù)聲明,否則就是一個函數(shù)表達式

函數(shù)聲明和函數(shù)表達式之間最重要的區(qū)別是它們的名稱標識符將會綁定在何處。

比較一下前面兩個代碼片段,第一個片段中foo被綁定在所在作用域中,可以直接通過 foo()來調用它。第二個片段中foo 被綁定在函數(shù)表達式自身的函數(shù)中而不是所在的作用域中。

換句話說,(function foo() {... }) 作為函數(shù)表達式意味著foo只能在..所代表的位置中被訪問,外部作用域則不行。 foo變量名被隱藏在自身意味著不會非必要的污染外部變量作用域。

匿名和具名

對于函數(shù)表達式你最熟悉的場景可能就是回調函數(shù)了, 比如:

    setTimeout(function () {
      console.log("wait 1 second");
    }, 1000)

這叫做匿名函數(shù)表達式,因為function().. 沒有名稱標識符。函數(shù)表達式可以是匿名的。而函數(shù)聲明則不可以省略函數(shù)名——在js的語法中這是非法的。

匿名函數(shù)表達式書寫起來快捷,很多庫和工作也傾向鼓勵使用這種風格的代碼,但是它也有幾個缺點需要考慮。

1.匿名函數(shù)在棧追蹤中不會顯示出有意義的函數(shù)名,使得調試很困難。
2.如果沒有函數(shù)名,當函數(shù)需要引用自身時候只能使用已經(jīng)過期的arguments.callee 引用,比如在遞歸中另一個函數(shù)需要引用自身的例子,是在事件觸發(fā)后事件監(jiān)聽器需要解綁自身。
3.匿名函數(shù)省略了對于代碼可讀性 / 可理解性很重要的函數(shù)名,一個描述性的名稱可以讓代碼不言自明。

行內函數(shù)表達式非常強大且有用,——匿名和具名之間的區(qū)別并不會對這點有任何影響,給函數(shù)表達式指定一個函數(shù)名可以有效解決以上問題。始終給函數(shù)表達式命名是一個最佳實踐。

    setTimeout(function timeoutHandler() { // <-- 快看,我有名字了!
      console.log("I waited 1 second!");
    }, 1000);

立即執(zhí)行函數(shù)表達式

    var a = 2;
    (function foo() {
      var a = 3;
      console.log(a); // 3
    })();
    console.log(a); // 2

由于函數(shù)被包裹在一對( )括號內部,因此成為了一個表達式,而在末尾加上一個( )可以立即執(zhí)行這個函數(shù)比如(function foo() {...})() 第一個( ) 將函數(shù)變成表達式,第二個( )執(zhí)行了這個函數(shù)

這種模式很常見,幾年前 社區(qū)給它規(guī)定了一個術語 IIFE 代表立即執(zhí)行函數(shù)表達式。

函數(shù)名稱對于IIFE當然不是必須的,IIFE 最常見的用法是使用一個匿名函數(shù)表達式,雖然使用具名函數(shù)的IIFE并不常見,但是它具有上述匿名函數(shù)表達是的所有優(yōu)勢

    var a = 2; (function IIFE() {
      var a = 3; console.log(a); // 3
    })();
    console.log(a); // 2

相對于傳統(tǒng)的IIFE形式,很多人都更加喜歡 另一種改進的形式,(function(){}());仔細觀察一下其中的區(qū)別,第二種形式用來調用的() 寫在了最外層的( )的里面。

IIFE的另外一個非常普遍的進階用法是把它們當做函數(shù)調用并傳遞參數(shù)進去。

例如:

    var a = 2;
    (function IIFE(global){
      var a = 3;
      console.log(a); // 3
      console.log(global.a) // 2
    })(window)

    console.log(2);

我們將window對象的引用傳遞進去,但將參數(shù)命名為 global 因此在代碼 風格上面對全局對象的引用,變得比引用一個沒有“全局”字樣的變量更加清晰。當然可以從外部作用域傳入任何你需要的東西,并將變量命名為任何你覺得合適的名字。這對于改進代碼風格是 非常有幫助的。

這個模式的另外一種場景是解決undefined 標識符的默認值被錯誤覆蓋導致的異常,將一個參數(shù)命名為undefined,但是在對應的位置不傳入任何值,這樣就保證在代碼中undefined標識符的值是真的 undefined:

undefined = true; // 給其他代碼挖了一個大坑,千萬不要這樣做;

    (function IIFE(undefined){
      var a;
      if(a === undefined){
        console.log("undefined is safe here");
      }
    })

IIFE 還有一種變化的用途是倒置代碼的運行順序,將需要運行的函數(shù)放在第二位,在IIFE執(zhí)行后當做參數(shù)傳遞進去這種模式在UMD項目中被廣泛使用,盡管這種模式略顯冗長,但是有些人認為它更容易理解。

    var a = 2;

    (function IIFE(def){
      def(window);
    })(function def(global){
      var a = 3;
      console.log(a); // 3
      console.log(global.b); // 2
    })

函數(shù)表達式def 定義在片段的第二部分,然后當做參數(shù) (這個參數(shù)也叫做def) 被傳遞進IIFE函數(shù)定義的第一部分。最后,參數(shù)def(也就是傳遞進去的函數(shù))被調用,并將window傳入當做global參數(shù)的值。

參考《你不知道的javascript》上卷

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容