閉包和作用域鏈是JavaScript中比較重要的概念,這兩天翻閱了一些資料,把相關知識點給大家總結了以下。
JavaScript 采用詞法作用域(lexical scoping),函數執行依賴的變量作用域是由函數定義的時候決定,而不是函數執行的時候決定。以下面的代碼片段舉例說明,通常來說(基於棧的實現,如 C 語言) foo 被調用之後函數內的本地變量 scope 會被釋放,但是從詞法上看 foo 的內嵌匿名函數中 scope 應該指的是 foo 的本地變量 scope ,並且實際上代碼的運行結果跟詞法上的表達式一致的,f 被調用之後返回的是local scope。函數對象 f 在其主體函數 foo 調用結束之後,依然保持著 foo 函數體作用域變量的引用,這就是所謂的閉包 。
var scope = 'global scope'; function foo() { var scope = 'local scope'; return function () { return scope; } } var f = foo(); f(); // 返回 "local scope"
那麼閉包到底是如何工作的呢?了解閉包首先需要了解變量作用域和作用域鏈,另外一個重要的概念是執行上下文環境。
變量作用域
JavaScript 中全局變量擁有全局的作用域,函數體內申明的變量的作用域是整個函數體內,是局部的,當然也包括函數體內定義的嵌套函數。函數體內局部變量的優先級高於全局變量,如果局部變量與全局變量重名,全局變量會被局部變量掩蓋;同樣嵌套函數內定義的局部變量的優先級高於嵌套函數所在函數的局部變量。這簡直是顯而易見的,幾乎所有人都了解。
接下來談談可能大家比較陌生的。
函數聲明提升
用一句話來說明函數申明提升,指的是函數體內部申明的變量再整個函數內有效。也就是說,就是在函數體最底部申明的變量,也會被提升到最頂部。舉個例子:
var scope = 'global scope'; function foo() { console.log(scope); // 這裡不會打印出 "global scope",而是 "undefined" var scope = 'local scope'; console.log(scope); // 很顯然,打印出 "local scope" } foo();
第一個console.log(scope)會打印出undefined而不是global scope,是因為局部變量的申明被提升了,只是還未賦值。
作為屬性的變量
在 JavaScript 中,有三種定義全局變量的方式,如下示例代碼中的 globalVal1 、globalVal2 和 globalValue3 。一個有趣的現象是,實際上全局變量僅僅只是全局對象 window/global (在浏覽器中是 window,在 node.js 中是 global)的屬性而已。為了更加符合通常意義的變量定義, JavaScript 把用 var 定義的全局變量,設計成了不可刪除的全局對象屬性。 通過Object.getOwnPropertyDescriptor(this, 'globalVal1')可以得到,其 configurable 屬性為 false 。
var globalVal1 = 1; // 不可刪除的全局變量 globalVal2 = 2; // 可刪除的全局變量 this.globalValue3 = 3; // 同 globalValue2 delete globalVal1; // => false 變量沒有被刪除 delete globalVal2; // => true 變量被刪除 delete this.globalValue3; //=> true 變量被刪除
那麼問題來了,函數體內定義的局部變量是不是也作為某個對象的屬性呢?答案是肯定的。這個對象是跟函數調用相關的,在 ECMAScript 3中稱為“call object”、ECMAScript 5中稱為“declaravite environment record”的對象。這個特殊的對象對我們來說是一種不可見的內部實現。
作用域鏈
從上一節我們知道,函數局部變量可與看做是某個不可見的對象的屬性。那麼 JavaScript 的詞法作用域的實現可以這樣描述:每一段 JavaScript 代碼(全局或函數)都有一個跟它關聯的作用域鏈,它可以是數組或鏈表結構;作用域鏈中的每一個元素定義了一組作用域內的變量;當我們要查找變量 x 的值,那麼從作用域鏈的第一個元素中找這個變量,如果沒有找到者找鏈表中的下一個元素中查找,直到找到或抵達鏈尾。了解作用域鏈的概念對理解閉包至關重要。
執行上下文
每段 JavaScript 代碼的執行都與執行上下文綁定,運行的代碼通過執行上下文獲可用的變量、函數、數據等信息。全局的執行上下文是唯一的,與全局代碼綁定,每執行一個函數都會創建一個執行上下文與其綁定。JavaScript 通過棧的數據結構維護執行上下文,全局執行上下文位於棧底,當執行一個函數的時候,新創建的函數執行上下文將會壓入棧中,執行上下文指針指向棧頂,運行的代碼即可獲得當前執行的函數綁定的執行上下文。如果函數體執行嵌套的函數,也會創建執行上下文並壓入棧,指針指向棧頂,當嵌套函數運行結束後,與它綁定的執行上下文被推出棧,指針重新指向函數綁定的執行上下文。同樣,函數執行結束,指針會指向全局執行上下文。
執行上下文可以描述成式一個包含變量對象(對應全局)/活動對象(對應函數)、作用域鏈和 this 的數據結構。當一個函數執行時,活動對象被創建並綁定到執行上下文。活動對象包括函數體內申明的變量、函數、arguments 等。作用域鏈在上一節以及提到,是按詞法作用域構建的。需要注意的是 this 不屬於活動對象,在函數執行的那一刻就以及確定。
執行上下文的創建是有特定的次序和階段的,不同階段有不同的狀態,具體的細節可以看一下參考資料,在結尾部分會列出。
閉包
了解了作用域鏈和執行上下文,回過頭看篇首的那段代碼,基本上就可以解釋閉包式如何工作了。函數調用的時候創建的執行上下文以及詞法作用域鏈保持函數調用所需要的信息, f 函數調用之後才可以返回local scope。
需要注意的是,函數內定義的多個函數使用的是同一個作用域鏈,在使用 for 循環賦值匿名函數對象的場景比較容易引起錯誤,舉例如下:
var arr = []; for (var i = 0; i < 10; i++) { arr[i] = { func: function() { return i; } }; } arr[0].func(); // 返回 10,而不是 0
arr[0].func()返回的是 10 而不是 0,跟感官上的語義有偏差。在 ECMAScript 6 引入 let 之前, 變量作用域范圍是在整個函數體內而不是在代碼區塊之內,所以上面的例子中所有定義的 func 函數引用了同一個作用域鏈在 for 循環之後, i 的值已經變為 10 。
正確的做法是這樣:
var arr = []; for (var i = 0; i < 10; i++) { arr[i] = { func: getFunc(i) }; } function getFunc(i) { return function() { return i; } } arr[0].func(); // 返回 0
以上內容給大家介紹了JavaScript作用域鏈、執行上下文與閉包的相關知識,希望對大家有所幫助。