JavaScript執行環境 + 變量對象 + 作用域鏈 + 閉包


閉包真的是一個談爛掉的內容。說到閉包,自然就涉及到執行環境、變量對象以及作用域鏈。湯姆大叔翻譯的《深入理解JavaScript系列》很好,幫我解決了一直以來似懂非懂的很多問題,包括閉包。下面就給自己總結一下。包括參考大叔的譯文以及《JavaScript高級程序設計(第3版)》,一些例子引用自它們。

附上大叔的鏈接:《深入理解JavaScript系列》

一、執行環境(或“執行上下文”,意義一樣)

首先說下ECMAScript可執行代碼的類型包括:全局代碼、函數代碼、eval()代碼。

每當執行流轉到可執行代碼時,即會進入一個執行環境。活動的執行環境構成一個棧:棧的底部始終是全局環境,頂部是當前活動的執行環境。

❶全局執行環境是最外圍的一個執行環境。在瀏覽器中,全局環境就是window對象,因此所有全局變量和函數都是作為window對象的屬性和方法創建的。

❷每個函數都有自己的執行環境。當執行流進入一個函數時,函數的環境被推入棧中。而在函數執行之后,棧將其環境彈出,把控制權返回給之前的執行環境。某個執行環境中的代碼執行完后,該環境銷毀,保存在其中的所有變量和函數定義也隨之銷毀。而全局執行環境直到應用程序退出才會被銷毀。

❸eval的執行環境與調用環境的執行環境相同。

二、變量對象

我們知道變量和執行環境有着密切的關系:

var a = 10; // 全局上下文中的變量
 
(function () {
  var b = 20; // function上下文中的局部變量
})();
 
alert(a); // 10
alert(b); // 全局變量 "b" 沒有聲明

而且我們也知道在JS里沒有塊級作用域這一說法,ES規范指出獨立作用域只能通過函數(function)代碼類型的執行環境創建。也就是說,像for循環並不能創建一個局部環境:

for (var k in {a: 1, b: 2}) {
  alert(k);
}
 
alert(k); // 盡管循環已經結束但變量k依然在當前作用域

既然變量與執行環境相關,那變量自己應該知道它的數據存放在哪里,並知道如何訪問。這就引出了“變量對象”這個概念。

每個執行環境都有一個與之關聯的變量對象,這個對象存儲着在環境中定義的以下內容:

1. 函數的形參
2. var聲明的變量
3. 函數聲明(不包括函數表達式)

舉例來說,用一個普通對象來表示變量對象,它是執行環境的一個屬性:

執行環境 = {
   變量對象:{
       //環境中的數據
    }
};        

例如:

var a = 10;

function test(x) {
  var b = 20;
};

test(30);

對應的變量對象為:

// 全局執行環境的變量對象
全局環境的變量對象= {
  a: 10,
  test: 指向test()函數
};
 
// test函數執行環境的變量對象
test函數環境的變量對象 = {
  x: 30,
  b: 20
};

 那么,不同執行環境中的變量對象的初始化是怎樣的呢?下面詳細看一下:

❶全局環境中的變量對象

先看下全局對象的明確定義:

全局對象 是在進入任何執行環境之前就已經創建了的對象。
這個對象只存在一份,它的屬性在程序中的任何地方都可以訪問,全局對象的生命周期終止於程序退出那一刻。

全局對象初始創建階段,將Math、String等作為自身屬性,初始化如下:

globla = {
   Math:
   String:
   ...
   ...
   window:globla   //引用自身
};

在這里,變量對象就是全局對象自己。

❷函數環境中的變量對象

在函數執行環境中,“活動對象” 扮演着變量對象這個角色。活動對象是在進入函數執行環境時創建的,它通過函數的arguments屬性初始化:

活動對象 = {
   arguments:  //是個對象,包括callee、length等屬性 
}; 

理解了變量對象的初始化之后,下面就是關於變量對象的核心了。

環境中的代碼,被分為兩個階段來處理:進入執行環境 、執行代碼。變量對象的修改變化與這兩個階段緊密相關。

這2個階段的處理是一般行為,和環境的類型無關(即,在全局環境和函數環境中的表現是一樣的)。

①進入環境

當進入執行環境時(代碼執行之前),變量對象已包含下列屬性(上面有提到):

①函數的所有形參(如果是在函數執行環境中。因為全局環境沒有形參。)
————由 形參名稱 和 對應值 組成,作為變量對象的屬性。如果沒有傳遞對應的參數,將undefined作為對應值。

②所有函數聲明(注意是聲明,函數表達式不算。)
————由 函數名 和 對應值(函數對象)組成,作為變量對象的屬性。如果變量對象已經存在同名的屬性,則覆蓋這個屬性。

③所有變量聲明(由var聲明的變量)
————由 變量名 和 對應值(undefined) 組成,作為變量對象的屬性。如果變量名與已經聲明的形參或函數相同,則變量聲明不會干擾已經存在的這類屬性。
————注意:此時的對應值是undefined。

讓我們來看一個例子:

function test(a, b) {
  alert(c);    //undefined
  alert(d);   //function d() {}
  alert(e);   //undefined
  alert(x);   //出錯
  var c = 10;
  function d() {}
  var e = function _e() {};
  (function x() {});
}
 
test(10); //

 我們考慮當進入帶有參數10的test函數環境時(代碼執行之前),活動對象表現如下:

活動對象(test) = {
  a: 10,
  b: undefined,  
  c: undefined,
  d:指向函數d,
  e: undefined
};

注意,活動對象里不包含函數x。這是因為x是一個函數表達式而不是函數聲明,函數表達式不會影響變量對象(在這里是活動對象)。函數_e同樣是函數表達式,但是我們注意到它分配給了變量e,所以可以通過名稱e來訪問。

在這之后,將進入處理代碼的第二個階段:執行代碼。

②執行代碼

這個階段內,變量/活動對象已經擁有了屬性(不過,並不是所有屬性都有值,就像上面那個例子,大部分屬性的值還是系統默認的undefined)。

繼續上面那個例子,活動對象在“執行代碼”這個階段被修改如下():

AO(test) = {
  a: 10,
  b: undefined,  //沒有相應該參數傳入,undefined
  c: 10,    //之前是undefined
  d: 指向函數d,
  e: 指向函數表達式_e   //之前是undefined
};

注意此時,函數表達式_e保存到了已聲明的變量e上,但函數表達式"x"本身不存在於活動對象中,也就是說,如果嘗試調用函數"x",無論在函數定義之前或之后,都會出現        “x is not defined”的錯誤。

理解了以上內容之后,再來看一個例子:

alert(x); // function
 
var x = 10;
alert(x); // 10
 
x = 20;
 
function x() {};

alert(x); // 20

為什么第一個alert(x)的值是function,而且它還是在x聲明之前訪問的x?為什么不是10或20呢?

現在我們知道,函數聲明是在進入環境時填入活動對象的,同一時間,還有一個變量聲明'x',但是正如前面所說,變量聲明在順序上跟在函數聲明和形參聲明之后。即,在進入環境階段,變量聲明不會干擾變量對象中已經存在的同名函數或形參聲明。所以,就這個例子來說,在進入環境時,變量對象的結構如下:

變量對象 = {
    x:指向函數x
    //如果function x沒有已經聲明的話,這時的x應該是undefined
};

緊接着,在代碼執行階段,變量對象作如下修改:

變量對象['x'] = 10;
變量對象['x'] = 20;
//可以在第二、三個alert看到這個結果

再看一個例子:

if (true) {
   var a = 1;   
} else {
   var b = 2;
}
//變量是在進入環境階段放入變量對象的,雖然else部分永遠不會執行,
//但是不管怎樣,變量b仍然存在於變量對象中。
alert(a); //1
alert(b); //undefined,不是b未聲明,而是b的值是undefined

另外,關於var聲明變量和不用var聲明:

大叔的譯文中指出:任何時候,變量只能通過var關鍵字才能聲明。

像a = 10;這僅僅是給全局對象創建了一個新屬性(但它不是變量)。它之所以能成為全局對象的屬性,完全是因為全局對象===全局變量對象。看例子:

alert(a); // undefined
alert(b); // "b" 沒有聲明,出錯
 
b = 10;
var a = 20;

進入環境階段:

變量對象 = {
  a: undefined
};

可以看到,因為b不是一個變量,所以在這個階段根本就沒有b,b將只在代碼執行階段才會出現,但在這里,還未執行到那就出錯了。

還有一個要注意的:var聲明的變量,相對於屬性(如a = 10;或window.a = 10;),變量的[[Configurable]]特性值為false,即不能通過delete刪除,而屬性則可以。

三、作用域鏈

現在我們已經知道,一個執行環境的數據(變量、函數聲明和函數形參)作為屬性存儲在變量對象中。

同時也知道,變量對象在每次進入環境時創建,並填入初始值,值的更新出現在代碼執行階段。

下面的內容討論作用域鏈。

如果要簡要地描述並展示其重點,那么作用域鏈大多數與內部函數相關。

我們可以創建內部函數,甚至能從父函數中返回這些函數。

var x = 10;
 
function foo() { 
  var y = 20; 
  function bar() {
    alert(x + y);
  } 
  return bar; 
}
 
foo()(); // 30

很明顯每個環境擁有自己的變量對象:對於全局環境,它是全局對象自身;對於函數,它是活動對象。

作用域鏈正是內部環境所有變量對象(包括父變量對象)的列表。此鏈用來在標識符解析中變量查找。

作用域鏈本質上,是一個指向變量對象的指針列表,它只引用但不實際包含變量對象。

對於上面這個例子,bar執行環境中的作用域鏈包括:bar變量對象、foo變量對象、全局變量對象。

函數執行環境中的作用域鏈在函數調用時創建,包含這個函數的活動對象和函數的[[scope]]屬性。示例如下:

活動的執行環境 = {
    變量對象: {...}, // or 活動對象
    this: thisValue,
    Scope: [ // 作用域鏈
      // 它是所有變量對象的列表。
    ]
};

其中的Scope定義為:Scope = 被調用函數的活動對象 + [[scope]]。

這種標識符的解析過程,與函數的生命周期相關,下面詳細討論。

(1)函數的生命周期

函數的生命周期分為創建和激活(調用時)兩個階段。

❶函數創建

讓我們先看看在全局環境中的變量和函數聲明(這里的變量對象就是全局對象自身,我們懂的。)

var x = 10;
 
function foo() {
  var y = 20;
  alert(x + y);
}
 
foo(); // 30

函數激活時,得到了正確的也是預期中的結果。但我們注意到,變量y在函數foo中定義(意味着它在foo的活動對象中),但是x並未在foo環境中定義,相應地,它不會添加到foo的活動對象中。那么,foo是如何訪問到變量x的?其實我們大都知道函數能訪問更高一層的環境中的變量對象,事實也是如此,而這種機制正是通過函數內部的[[scope]]屬性實現的。

[[scope]]是所有父變量對象的層級鏈,處於當前函數環境,在函數創建時存在於其中。

注意重要的一點:[[scope]]屬性在函數創建時被存儲,永遠不變,直到函數銷毀。函數可以不被調用,但這個屬性一直存在。
且,與作用域鏈相比,作用域鏈是執行環境的一個屬性,而[[scope]]是函數的屬性。

上面的例子,函數foo的[[scope]]如下:

foo.[[Scope]] = [
  全局執行環境.變量對象 // === Global
];

繼續,我們知道在函數調用時進入執行環境,這時活動對象被創建,this、作用域鏈被確定。下面詳細考慮這個時刻。

❷函數激活

正如上面提到的,進入環境創建變量/活動對象之后,環境的Scope屬性(即作用域鏈)定義為:Scope = 變量/活動對象 + [[scope]]。

這個定義意思是:活動對象是被添加到[[scope]]前端,在作用域鏈中處理第一位。這很重要,對於標識符的查找,是從自身變量對象開始的,逐漸往父變量對象查找。

(2)通過構造函數創建的函數的[[scope]]

在上面的例子中,我們看到,在函數創建時,函數獲得[[scope]]屬性,該屬性存儲着所有父環境的變量/活動對象。但有一個例外,那就是通過構造函數創建的函數。

var x = 10;
 
function foo() {
 
  var y = 20;
 
  function barFD() { // 函數聲明
    alert(x);
    alert(y);
  }
 
  var barFE = function () { // 函數表達式
    alert(x);
    alert(y);
  };
 
  var barFn = Function('alert(x); alert(y);');
 
  barFD(); // 10, 20
  barFE(); // 10, 20
  barFn(); // 10, "y" is not defined
 
}
 
foo();

從以上例子中,我們看出問題所在:通過構造函數創建的函數,它的[[scope]]僅包含全局對象。

另外關於eval,實踐中很少用到eval,但有一點提示,eval代碼的環境與當前的調用環境擁有相同的作用域鏈。

(3)延長作用域鏈

有兩個能延長作用域鏈的方法:with聲明和catch語句。它們添加到作用域鏈的最前端(比被調用函數的活動對象還要靠前)。

如果發生其中一個,作用域鏈作如下修改:

Scope = withObject|catchObject +活動/變量對象 + [[Scope]]

看個例子:

var x = 10, y = 10;
 
with ({x: 20}) {
 
  var x = 30, y = 30;
 
  alert(x); // 30
  alert(y); // 30
}
 
alert(x); // 10
alert(y); // 30
//1. x = 10,y = 10;
//2. 進入環境,對象{x:20}添加到作用域鏈的前端。
//3. 執行代碼,x為20,變為30,y為10,變為30。
//4.with聲明完成后,對象被移除,那個因with對象而改變的x=30也被移除。
//最后兩個alert,x保持最初不變,y在with里已發生改變。

四、閉包

到了這里,其實如果對前面的[[scope]]和作用域鏈完全理解的話,閉包也就懂了。

大叔的譯文對閉包給出的2個定義是:

❶從理論角度:所有函數都是閉包。因為它們在創建的時候就將所有父環境的數據保存起來了。哪怕是簡單的全局變量也是如此,因為在函數中訪問全局變量就相當於在訪問自由變量(指不在參數聲明,也不在局部聲明的變量),這個時候使用最外層的作用域。

❷從實踐角度:以下函數才算是閉包:

   ①即使創建它的環境銷毀,它仍然存在(比如,內部函數從父函數返回);②在代碼中引用了自由變量。

閉包的性能問題總被提及,現在我們知道原因了:創建閉包的父環境即使被銷毀了,但閉包仍然引用着父環境的變量對象,也就是說需要繼續維護着這個變量對象的內存。

下面我們再來具體看一下。

var x = 10;

function foo() {
  alert(x);
}

(function (funArg) {

  var x = 20;

  // 變量"x"在foo中靜態保存的,在該函數創建的時候就保存了
  funArg(); // 10, 而不是20

})(foo);

我們已經知道,創建foo函數的父級環境(在這里是全局環境)的數據是保存在foo函數的內部屬性[[scope]]中的。

這里還要注意的是:同一個父環境創建的閉包是共用一個[[scope]]屬性的。也就是說,某個閉包對其中[[scope]]的變量的修改會影響到其他閉包對其變量的讀取。

var firstClosure;
var secondClosure;

function foo() {

  var x = 1;

  firstClosure = function () { return ++x; };
  secondClosure = function () { return --x; };

  x = 2; // 影響"x", 在2個閉包公有的[[Scope]]中

  alert(firstClosure()); // 3, 通過第一個閉包的[[Scope]]
}

foo();

alert(firstClosure()); // 4
alert(secondClosure()); // 3

關於這個問題,大叔的譯文和《JS高級》里都有一個例子:

var data = [];

for (var k = 0; k < 3; k++) {
  data[k] = function () {
    alert(k);
  };
}

data[0](); // 3, 而不是0
data[1](); // 3, 而不是1
data[2](); // 3, 而不是2

這就是閉包共用一個[[scope]]的問題。可以按下面的方法解決:

var data = [];

for (var k = 0; k < 3; k++) {
  data[k] = (function _helper(x) {
    return function () {
      alert(x);
    };
  })(k); // 傳入"k"值
}

// 現在結果是正確的了
data[0](); // 0
data[1](); // 1
data[2](); // 2

在上例中,每次_helper都會創建一個新的變量對象,其中含有參數x,其值就是傳遞進來的k值。此時,返回的函數的[[scope]]如下:

data[0].[[Scope]] === [
  ... // 其它變量對象
  父級環境中的活動對象: {data: [...], k: 3},
  _helper環境中的活動對象: {x: 0}
];

data[1].[[Scope]] === [
  ... // 其它變量對象
  父級環境中的活動對象: {data: [...], k: 3},
  _helper環境中的活動對象: {x: 1}
];

data[2].[[Scope]] === [
  ... // 其它變量對象
  父級環境中的活動對象: {data: [...], k: 3},
  _helper環境中的活動對象: {x: 2}
];

要注意的是,如果在返回的函數中,要獲取k值,那么該值還會是3。

五、小結

總結得好長啊好長。因為我覺得一口氣將這幾個點連在一起梳理一下比較好。

嗯,就這樣吧。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM