JS學習系列 03 - 函數做用域和塊做用域

在 ES5 及以前版本,JavaScript 只擁有函數做用域,沒有塊做用域(with 和 try...catch 除外)。在 ES6 中,JS 引入了塊做用域,{ } 內是單獨的一個做用域。採用 let 或者 const 聲明的變量會挾持所在塊的做用域,也就是說,這聲明關鍵字會將變量綁定到所在的任意做用域中(一般是 {...} 內部)。javascript

今天,咱們就來深刻研究一下函數做用域塊做用域java

1. 函數中的做用域

函數做用域的含義是指,屬於這個函數的任何聲明(變量或函數)均可以在這個函數的範圍內使用及複用(包括這個函數嵌套內的做用域)。瀏覽器

舉個例子:bash

function foo (a) {
   var b = 2;

   // something else

   function bar () {
      // something else   
   }

   var c = 3;
}

bar();      // 報錯,ReferenceError: bar is not defined
console.log(a, b, c);        // 報錯,緣由同上
複製代碼

在這段代碼中,函數 foo 的做用域包含了標識符a、b、c 和 bar ,函數 bar 的做用域中又包含別的標識符。微信

因爲標識符 a、b、c 和 bar都屬於函數 foo 的做用域,因此在全局做用域中訪問會報錯,由於它們都沒有定義,可是在函數 foo 內部,這些標識符都是能夠訪問的,這就是函數做用域。數據結構

1.1 爲何要有這些做用域

當咱們用做用域把代碼包起來的時候,其實就是對它們進行了「隱藏」,讓咱們對其有控制權,想讓誰訪問就可讓誰訪問,想禁止訪問也很容易。閉包

想像一下,若是全部的變量和函數都在全局做用域中,固然咱們能夠在內部的嵌套做用域中訪問它們,可是由於暴露了太多的變量或函數,它們可能被有意或者無心的篡改,以非預期的方式使用,這就致使咱們的程序會出現各類各樣的問題,嚴重會致使數據泄露,形成沒法挽回的後果。函數

例如:ui

var obj = {
   a: 2,
   getA: function () {
      return this.a;
   }
};

obj.a = 4;
obj.getA();      // 4
複製代碼

這個例子中,咱們能夠任意修改對象 obj 內部的值,在某種狀況下這並非咱們所指望的,採用函數做用域就能夠解決這個問題,私有化變量 a 。this

var obj = (function () {
  var a = 2;
  return {
     getA: function () {
        return a;
     },
     setA: function (val) {
        a = val;
     }
  }
}());

obj.a = 4;
obj.getA();      // 2
obj.setA(8);
obj.getA();      // 8
複製代碼

這裏經過當即執行函數(IIFE)返回一個對象,只能經過對象內的方法對變量 a 進行操做,其實這裏有閉包的存在,這個咱們在之後會深刻討論。

「隱藏」做用域中的變量和函數所帶來的另外一個好處,是能夠避免同名標識符之間的衝突,衝突會致使變量的值被意外覆蓋。

例如:

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(...) 內部 for 循環中的 i ,在這個例子中由於 i 始終被設置爲 3 ,永遠知足小於 10 這個條件,致使無限循環。

bar(...) 內部的賦值操做須要聲明一個本地變量來使用,採用任何名字均可以,var i = 3; 就能夠知足這個要求。另一種方法是採用一個徹底不一樣的標識符名稱,好比 var j = 3; 。可是軟件設計在某種狀況下可能天然而然的要求使用一樣的標識符名稱,所以在這種狀況下使用做用域來「隱藏」內部聲明是惟一的最佳選擇。

總結來講,做用域能夠起到兩個做用:

  • 私有化變量或函數
  • 規避同名衝突
1.2 函數聲明和函數表達式

若是 function 是聲明中的第一個詞,那麼就是一個函數聲明,不然就是一個函數表達式。

函數聲明舉個例子:

function foo () {
   // something else
}
複製代碼

這就是一個函數聲明。

函數表達式分爲匿名函數表達式和具名函數表達式。

對於函數表達式來講,最熟悉的場景可能就是回調參數了,例如:

setTimeout(function () {
   console.log("I wait for one second.")
}, 1000);
複製代碼

這個叫做匿名函數表達式,由於 function ()... 沒有名稱標識符。函數表達式能夠是匿名的,可是函數聲明不能夠省略函數名,在 javascript 中這是非法的。

匿名函數表達式書寫簡便,可是它也有幾個缺點須要注意:

  1. 匿名函數在瀏覽器棧追蹤中不會顯示出有意義的函數名,這會加大調試難度。
  2. 若是沒有函數名,當函數須要引用自身的時候就只能使用已經不是標準的 arguments.callee 來引用,好比遞歸。在事件觸發後的事件監聽器中也有可能須要經過函數名來解綁自身。
  3. 匿名函數對代碼的可讀性和可理解性有必定的影響。一個有意義的函數名可讓代碼不言自明。

具名函數表達式又叫行內函數表達式,例如:

setTimeout(function timerHandler () {
   console.log("I wait for one second.")
}, 1000);
複製代碼

這樣,在函數內部須要引用自身的時候就能夠經過函數名來引用,固然要注意,這個函數名只能在這個函數內部使用,在函數外使用時未定義的。

1.3 當即執行函數表達式(IIFE)

IIFE 全寫是 Immediately Invoked Function Expression,當即執行函數。

var a = 2;

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

console.log(a);      // 2
複製代碼

因爲函數被包含在一對 ( ) 括號內部,所以成爲了一個函數表達式,經過在末尾加上另外一對 ( ) 括號能夠當即執行這個函數,好比 (function () {})() 。第一個 ( ) 將函數變成函數表達式,第二個 ( ) 執行了這個函數。

也有另一種當即執行函數的寫法,(function () {}()) 也能夠當即執行這個函數。

var a = 2;

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

console.log(a);      // 2
複製代碼

這兩種寫法功能是徹底同樣的,具體看你們使用。

IIFE 的另外一種廣泛的進階用法是把它們當作函數調用並傳遞參數進去。

var a = 2;

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

console.log(a);      // 2
複製代碼

咱們將 window 對象的引用傳遞進去,但將參數命名爲 global,所以在代碼風格上對全局對象的引用變得比引用一個沒有「全局」字樣的變量更加清晰。固然能夠從外部做用域傳遞你須要的任何東西,並將變量命名爲任何你以爲合適的文字。這對於改進代碼風格是很是有幫助的。

這個模式的另一個應用場景是解決 undefined 標識符的默認值被錯誤覆蓋的異常(這並不常見)。將一個參數命名爲 undefined ,可是並不傳入任何值,這樣就能夠保證在代碼塊中 undefined 的標識符的值就是 undefined 。

undefined = true;

(function IIFE (undefined) {
   var a;
   if (a === undefined) {
      console.log("Undefined is safe here.")
   }
}()); 
複製代碼

2. 塊做用域

ES5 及之前 JavaScript 中具備塊做用域的只有 with 和 try...catch 語句,在 ES6 及之後的版本添加了具備塊做用域的變量標識符 let 和 const 。

2.1 with
var obj = {
   a: 2,
   b: 3
};

with (obj) {
   console.log(a);      // 2
   console.log(b);      // 3
}

console.log(a);      // 報錯,a is not defined
console.log(b);      // 報錯,a is not defined
複製代碼

用 with 從對象中建立出的做用域僅在 with 聲明中而非外部做用域中有效。

2.2 try...catch
try {
  undefined();      // 非法操做
} catch (err) {
  console.log(err);      // 正常執行
}

console.log(err);      // 報錯,err is not defined
複製代碼

try/catch 中的 catch 分句會建立一個塊做用域,其中的變量聲明僅在 catch 內部有效。

2.3 let

let 關鍵字能夠將變量綁定到任意做用域中(一般是 {...} 內部)。換句話說,let 爲其聲明的變量隱式的劫持了所在的塊做用域。

var foo = true;

if (foo) {
   let a = 2;
   var b = 2;
   console.log(a);      // 2
   console.log(b);      // 2
}

console.log(b);      // 2
console.log(a);      // 報錯,a is not defined
複製代碼

用 let 將變量附加在一個已經存在的塊做用域上的行爲是隱式的。在開發和修改代碼的過程當中,若是沒有密切關注哪些代碼塊做用域中有綁定的變量,而且習慣性的移動這些塊或者將其包含到其餘塊中,就會致使代碼混亂。

爲塊做用域顯示的建立塊能夠部分解決這個問題,使變量的附屬關係變得更加清晰。

var foo = true;

if (foo) {
   {
      let a = 2;
      console.log(a);      // 2
   }
}
複製代碼

在代碼的任意位置均可以使用 {...} 括號來爲 let 建立一個用於綁定的塊。

還有一點要注意的是,在使用 var 進行變量聲明的時候會存在變量提高,提高是指聲明會被視爲存在於其所出現的做用域的整個範圍內。可是使用 let 進行的聲明不會存在做用域提高,聲明的變量在被運行以前,並不存在。

console.log(a);      // undefined
console.log(b);      // 報錯, b is not defined

// 在瀏覽器中運行這段代碼時,由於前面報錯了,因此不會看到接下來打印的結果,可是理論上就是這樣的結果
var a = 2;
console.log(a);      // 2 

let b = 4;
console.log(b);      // 4
複製代碼

2.3.1 垃圾收集 另外一個塊做用域很是有用的緣由和閉包及垃圾內存的回收機制有關。 舉個例子:

function processData (data) {
   // do something
}

var bigData = {...};

processData(bigData);

var btn = document.getElementById('my_button');

btn.addEventListener('click', function () {
   console.log('button clicked');
}, false);
複製代碼

這個按鈕點擊事件的回調函數中並不須要 bigData 這個很是佔內存的數據,理論上來講,當 processData 函數處理完以後,這個佔有大量空間的數據結構就能夠被垃圾回收了。可是,因爲這個事件回調函數造成了一個覆蓋當前做用域的閉包,JavaScript 引擎極有可能依然保存着這個數據結構(取決於具體實現)。

使用塊做用域能夠解決這個問題,可讓引擎清楚的知道沒有必要繼續保存這個 bigData 。

function processData (data) {
   // do something
}

{
   let bigData = {...};

   processData(bigData);
}

var btn = document.getElementById('my_button');

btn.addEventListener('click', function () {
   console.log('button clicked');
}, false);
複製代碼

2.3.2 let 循環 一個 let 能夠發揮優點的典型例子就是 for 循環。

var lists = document.getElementsByTagName('li');

for (let i = 0, length = lists.length; i < length; i++) {
   console.log(i);
   lists[i].onclick = function () {
     console.log(i);      // 點擊每一個 li 元素的時候,都是相對應的 i 值,而不像用 var 聲明 i 的時候,由於沒有塊做用域,因此在回調函數經過閉包查找 i 的時候找到的都是最後的 i 值
   };
};

console.log(i);      // 報錯,i is not defined
複製代碼

for 循環頭部的 let 不只將 i 綁定到 fir 循環的塊中,事實上它將其從新綁定到了循環的每個迭代中,確保上一個循環迭代結束時的值從新進行賦值。

固然,咱們在 for 循環中使用 var 時也能夠經過當即執行函數造成一個新的閉包來解決這個問題。

var lists = document.getElementsByTagName('li');

for (var i = 0, length = lists.length; i < length; i++) {
   lists[i].onclick = (function (j) {
        return function () {
           console.log(j);
        }
   }(i));
}
複製代碼

或者

var lists = document.getElementsByTagName('li');

for (var i = 0, length = lists.length; i < length; i++) {
   (function (i) {
      lists[i].onclick = function () {
         console.log(i);
      }
   }(i));
}
複製代碼

其實原理無非就是,爲每一個迭代建立新的閉包,當即執行函數執行完後原本應該銷燬變量,釋放內存,可是由於這裏有回調函數的存在,因此造成了閉包,而後經過形參進行同名變量覆蓋,因此找到的 i 值就是每一個迭代新閉包中的形參 i 。

2.4 const

除了 let 之外,ES6 還引入了 const ,一樣能夠用來建立做用域變量,但其值是固定的(常亮)。以後任何試圖修改值的操做都會引發錯誤。

var foo = true;

if (foo) {
   var a = 2;
   const b = 3;      // 包含在 if 中的塊做用域常亮

   a = 3;      // 正常
   b = 4;      // 報錯,TypeError: Assignment to constant variable
}

console.log(a);      // 3
console.log(b);      // 報錯, b is not defined
複製代碼

和 let 同樣,const 聲明的變量也不存在「變量提高」。

3. 總結

函數是 JavaScript 中最多見的做用域單元。塊做用域指的是變量和函數不只能夠屬於所處的函數做用域,也能夠屬於某個代碼塊。

本質上,聲明在一個函數內部的變量或函數會在所處的做用域中「隱藏」起來,這是有意爲之的良好軟件的設計原則。

有些人認爲塊做用域不該該徹底做爲函數做用域的替代方案。兩種功能應該同時存在,開發者能夠而且也應該根據須要選擇使用哪一種做用域,創造可讀、可維護的優良代碼。

歡迎關注個人公衆號

微信公衆號
相關文章
相關標籤/搜索