JavaScript 作用域 與 作用域鏈

YXi發表於2019-07-27

作用域

任何程式設計語言都有作用域的概念,簡單的說,作用域就是變數與函式的可訪問範圍,即作用域控制著變數與函式的可見性和生命週期。在JavaScript中,變數的作用域有全域性作用域和區域性作用域兩種

JS中的作用域是基於上下文,以函式劃分的,而不是由塊(block)劃分的

  • 全域性作用域

    • 最外層函式和在最外層函式外面定義的變數擁有全域性作用域

    如下:

    var a = 111;   // 全域性變數 作用域:函式內外都能訪問
    function f(){
      	  var b = 666;  //區域性變數
    }
    複製程式碼
    • 所有未定義直接賦值的變數自動宣告為擁有全域性作用域

    如下:

    var a = 111;   // 全域性變數 作用域:函式內外都能訪問
    function f(){
          var b = 666;	//區域性變數
          c = 777;	//全域性變數
    }
    複製程式碼
    • 所有window物件的屬性擁有全域性作用域

      一般情況下,window物件的內建屬性都都擁有全域性作用域,例如window.name、window.location、window.top等等

  • 區域性作用域

和全域性作用域相反,區域性作用域一般只在固定的程式碼片段內可訪問到,最常見的例如函式內部,所以在一些地方也會看到有人把這種作用域稱為函式作用域 (如上述程式碼中,函式 f 內部就被稱為區域性作用域)

(如果想了解更多,請參考我的JavaScript函式變數的使用)

作用域鏈

瞭解完作用域後,接下來就要說我們的作用域鏈了

  • 什麼是作用域鏈?
    大概就是根據在內部函式可以訪問外部函式變數的這種機制,用鏈式查詢決定哪些資料能被內部函式訪問。

如果想要知道JS怎麼鏈式查詢,就必須先要了解JS的執行環境

執行環境(execution context)

每個函式執行時都會產生一個執行環境,js為每一個執行環境關聯了一個變數物件。環境中定義的所有變數和函式都儲存在這個物件中。

全域性執行環境是最外圍的執行環境,全域性執行環境被認為是window物件,因此所有的全域性變數和函式都作為window物件的屬性和方法建立的。

js的執行順序是根據函式的呼叫來決定的,當一個函式被呼叫時,該函式環境的變數物件就被壓入一個環境棧中。而在函式執行之後,棧將該函式的變數物件彈出,把控制權交給之前的執行環境變數物件。

例如:

<script>
      var scope = "global"; 
      function fn1(){
         return scope; 
      }
      function fn2(){
         return scope;
      }
      fn1();
      fn2();
</script>
複製程式碼

上面程式碼執行情況如下圖所示:

圖片載入失敗!

瞭解了環境變數,就下來再詳細講講作用域鏈。

當某個函式第一次被呼叫時,就會建立一個執行環境(execution context)以及相應的作用域鏈,並把作用域鏈賦值給一個特殊的內部屬性([scope])。然後使用this,arguments(arguments在全域性環境中不存在)和其他命名引數的值來初始化函式的活動物件(activation object)。當前執行環境的變數物件始終在作用域鏈的第0位。

以上面的程式碼為例,當第一次呼叫fn1()時的作用域鏈如下圖所示(因為fn2()還沒有被呼叫,所以沒有fn2的執行環境):

圖片載入失敗!

可以看到fn1活動物件裡並沒有scope變數,於是沿著作用域鏈(scope chain)向後尋找,結果在全域性變數物件裡找到了scope,所以就返回全域性變數物件裡的scope值。

識別符號解析是沿著作用域鏈一級一級地搜尋識別符號地過程。搜尋過程始終從作用域鏈地前端開始,然後逐級向後回溯,直到找到識別符號為止(如果找不到識別符號,通常會導致錯誤發生)

作用域鏈地作用不僅僅只是為了搜尋識別符號

再來看一段程式碼:

<script>
  function outer(){
	 var scope = "outer";
	 function inner(){
		return scope;
	 }
	 return inner;
  }
  var fn = outer();
  fn();
</script>
複製程式碼

outer()內部返回了一個inner函式,當呼叫outer時,inner函式的作用域鏈就已經被初始化了(複製父函式的作用域鏈,再在前端插入自己的活動物件),具體如下圖:

圖片載入失敗!

一般來說,當某個環境中的所有程式碼執行完畢後,該環境被銷燬(彈出環境棧),儲存在其中的所有變數和函式也隨之銷燬(全域性執行環境變數直到應用程式退出,如網頁關閉才會被銷燬)

但是像上面那種有內部函式的又有所不同,當outer()函式執行結束,執行環境被銷燬,但是其關聯的活動物件並沒有隨之銷燬,而是一直存在於記憶體中,因為該活動物件被其內部函式的作用域鏈所引用。

具體如下圖:

outer執行結束,內部函式開始被呼叫
outer執行環境等待被回收,outer的作用域鏈對全域性變數物件和outer的活動物件引用都斷了

圖片載入失敗!

像上面這種內部函式的作用域鏈仍然保持著對父函式活動物件的引用,就是閉包(closure)


閉包

閉包有兩個作用:

  1. 可以讀取自身函式外部的變數(沿著作用域鏈尋找)
  2. 讓這些外部變數始終儲存在記憶體中

但是它也存在缺陷,可能產生記憶體洩漏(但是現在一般瀏覽器可以解決這種問題)

關於第二點,舉個例子說明:

<script>
      function outer(){
         var result = new Array();
         for(var i = 0; i < 2; i++){//注:i是outer()的區域性變數
            result[i] = function(){
               return i;
            }
         }
         return result;//返回一個函式物件陣列
         //這個時候會初始化result.length個關於內部函式的作用域鏈
      }
      var fn = outer();
      console.log(fn[0]());//result:2
      console.log(fn[1]());//result:2
</script>
複製程式碼

返回結果很出乎意料吧,你肯定以為依次返回0,1,但事實並非如此

來看一下呼叫fn0的作用域鏈圖:

圖片載入失敗!

可以看到result[0]函式的活動物件裡並沒有定義i這個變數,於是沿著作用域鏈去找i變數,結果在父函式outer的活動物件裡找到變數i(值為2),而這個變數i是父函式執行結束後將最終值儲存在記憶體裡的結果。

由此也可以得出,js函式內的變數值不是在編譯的時候就確定的,而是等在執行時期再去尋找的。

那怎麼才能讓result陣列函式返回我們所期望的值呢?
看一下result的活動物件裡有一個arguments,arguments物件是一個引數的集合,是用來儲存物件的。 那麼我們就可以把i當成引數傳進去,這樣一呼叫函式生成的活動物件內的arguments就有當前i的副本。

改進之後:

<script>
      function outer(){
         var result = new Array();
         for(var i = 0; i < 2; i++){
            //定義一個帶參函式
            function arg(num){
               return num;
            }
            //把i當成引數傳進去
            result[i] = arg(i);
         }
         return result;
      }
      var fn = outer();
      console.log(fn[0]);//result:0
      console.log(fn[1]);//result:1
</script>
複製程式碼

雖然的到了期望的結果,但是又有人問這算閉包嗎?呼叫內部函式的時候,父函式的環境變數還沒被銷燬呢,而且result返回的是一個整型陣列,而不是一個函式陣列!
確實如此,那就讓arg(num)函式內部再定義一個內部函式就好了:
這樣result返回的其實是innerarg()函式

<script>
      function outer(){
         var result = new Array();
         for(var i = 0; i < 2; i++){
            //定義一個帶參函式
            function arg(num){
               function innerarg(){
                  return num;
               }
               return innerarg;
            }
            //把i當成引數傳進去
            result[i] = arg(i);
         }
         return result;
      }
      var fn = outer();
      console.log(fn[0]());
      console.log(fn[1]());
</script>
複製程式碼

當呼叫outer,for迴圈內i=0時的作用域鏈圖如下:

圖片載入失敗!

由上圖可知,當呼叫innerarg()時,它會沿作用域鏈找到父函式arg()活動物件裡的arguments引數num=0.
上面程式碼中,函式arg在outer函式內預先被呼叫執行了,對於這種方法,js有一種簡潔的寫法

 function outer(){
         var result = new Array();
         for(var i = 0; i < 2; i++){
            //定義一個帶參函式
            result[i] = function(num){
               function innerarg(){
                  return num;
               }
               return innerarg;
            }(i);//預先執行函式寫法
            //把i當成引數傳進去
         }
         return result;
}
複製程式碼

^_<

相關文章