做用域、執行上下文、閉包


title: JavaScript中的做用域、執行上下文與閉包 date: 2019-03-18 08:34:44 tags:javascript


JavaScript 代碼的整個執行過程,分爲兩個階段,代碼編譯階段與代碼執行階段。編譯階段由編譯器完成,將代碼翻譯成可執行代碼,這個階段做用域規則會肯定。執行階段由引擎完成,主要任務是執行可執行代碼,執行上下文在這個階段建立。前端

做用域 (Scope)

什麼是做用域

做用域是定義變量的區域。java

它規定了執行代碼時查找變量的範圍,也就是變量的做用範圍。git

JavaScript 採用詞法做用域(lexical scoping),也就是靜態做用域。github

由於 JavaScript 採用的是詞法做用域,函數的做用域在函數定義的時候就決定了面試

var a = 1
function foo1() {
    console.log(a)
}
function foo2() {
    var a = 2
    foo1()
}

foo2()  // 1
複製代碼

在 JavaScript 中無塊級做用域(一對兒 { } 包含的區域 ),只有全局做用域函數做用域segmentfault

if (true) {
    var name = 'abc'
}
console.log(name)  // 'abc'
複製代碼

同一個頁面中全部的 <script> 標籤中的沒在函數體內的變量,都在同一個全局做用域中。瀏覽器

執行上下文 (Execution context)

什麼是執行上下文

執行上下文能夠抽象成一個對象,當一個函數執行時就會建立一個執行上下文bash

每一個執行上下文,都有三個屬性:閉包

  • 變量對象(Variable object, VO)
  • 做用域鏈(Scope chain)
  • this

既然每執行一個函數就會建立一個執行上下文,咱們寫的函數可不止一個,那麼如何管理衆多的上下文呢?

JavaScript 引擎建立了執行上下文棧(Execution context stack, ECS) 來管理執行上下文

JavaScript 引擎在執行代碼時,最早遇到的是全局代碼,會建立一個全局上下文(globalContext)並將之壓入執行上下文棧(ECS)中,當執行一個函數時,建立一個函數上下文並壓入 ECS 中。函數執行完畢時,這個函數上下文會出棧並被清理。當整個程序執行完畢時,globalContext 也會出棧並被清理。

看一個例子:

function fun1() {
    fun2()
}

function fun2() {
    console.log('fun2')
}

fun1()
複製代碼

當 JavaScript 引擎執行代碼時,先建立一個全局上下文(globalContext) 壓入執行上下文棧(ECS)中。

當執行到 fun1() 時,建立一個函數上下文(EC_fun1)並壓入棧中。

而後執行 fun1 函數體裏的 fun2(),建立 EC_fun2並壓入棧中。

執行 fun2 函數裏的 console.log() 函數,建立 EC_consolelog 壓入棧中。

而後 console.log 執行完畢,EC_consolelog 出棧並銷燬。

而後 fun2 執行完畢,EC_fun2 出棧並銷燬。

fun1 執行完畢,EC_fun1 出棧並銷燬。

變量對象 (Variable object, VO)

變量對象是與執行上下文相關的數據做用域,它存儲了在該上下文定義的變量和函數聲明。變量對象是在建立函數上下文時建立的,它經過函數的 arguments 屬性初識化。

變量對象包括:

  1. 函數的全部形參(若是是函數上下文)
    • 由名稱和對應值組成一個變量對象(VO)的屬性
  2. 函數聲明
    • 由名稱和對應值(函數對象)組成一個變量對象(VO)的屬性
    • 若是 VO 已存在相同的名稱屬性,則替換這個屬性
  3. 變量聲明
    • 由名稱和 undefined 組成一個 VO 的屬性
    • 若是名稱和已經聲明的形參或函數相同,變量聲明不會干擾已存在的這類屬性

舉個例子:

function foo(a) {
    var b = 2
    var a = 10
    function c() {}
    var d = function() {}
}

foo(11)
複製代碼

在執行 foo(11)時,建立一個執行上下文,此時執行上下文的變量對象時:

VO = {
    arguments: {
        0: 11,
        length: 1
    },
    a: 11,
    b: undefined,
    c: reference to function c() {},
    d: undefined
}
複製代碼

全局對象

  • 全局上下文的變量對象就是全局對象。
  • 全局上下文的 this 指向的是全局對象。
  • 在瀏覽器環境下全局對象是 window ,Node.js 環境下全局對象是 global

活動對象 (Activation object,AO)

活動對象與變量對象實際上是同一個東西,只有當進入一個執行上下文中(這個執行上下文處在執行上下文棧的棧頂),這個執行上下文的變量對象(VO)纔會被激活,此時這個變量對象叫作活動對象(AO)。只有活動對象上的各類屬性才能被訪問。

做用域鏈 (Scope chain)

當查找變量時,會在當前執行上下文的變量對象(也是活動對象)中查找,若是沒找到,就會從父級(詞法層面上的父級)執行上下文的變量對象中查找,一直找到全局上下文的變量對象(也是全局對象)。這樣由多個執行上下文的變量對象構成的鏈表就叫作做用域鏈

函數定義時,有一個內部屬性 [[scope]] 保存了全部父變量對象。當函數執行時會建立一個做用域鏈,這個做用域鏈包含了函數的 [[scope]] 屬性和執行上下文的活動對象(AO)。

var a = 1
var b = 1
function foo() {
    var b = 2
    return a + b
}
foo()  // 3
複製代碼

執行過程以下:

  1. foo 函數被建立,foo 會將父變量對象(實際上是當前活動對象也是全局對象)保存在 [[scope]] 屬性中。
foo.[[scope]] = [globalContext.VO]
複製代碼
  1. 執行 foo 函數,建立 foo 函數執行上下文並壓入執行上下文棧(ECS)中。
ECS = [ fooContext,
        globalContext]
複製代碼
  1. foo 函數並不馬上執行,須要一些準備工做,第一步:複製函數的 [[scope]] 屬性到建立的做用域鏈(Scope)中
fooContext = {
    Scope: foo.[[scope]]
}
複製代碼
  1. 第二步:建立活動對象並用函數的 arguments 屬性初始化
fooContext = {
    AO: {
        arguments: {
            length: 0
        },
        b: undefined
    },
    Scope: [
            foo.[[scope]]
                         ]
}
複製代碼
  1. 第三步:將活動對象壓入 做用域鏈頂端
fooContext = {
    AO: {
        arguments: {
            length: 0
        },
        b: undefined
    },
    Scope: [
            AO,
            foo.[[scope]]
                         ]
}
複製代碼
  1. 準備工做作完,開始執行 foo 函數裏的代碼,更新 AO 的屬性值
AO:{
    arguments: {
        length: 0
    },
    b: 2
}
複製代碼
  1. foo 執行完畢,foo 函數上下文出棧並銷燬
ECS = [
       globalContext
                     ]
複製代碼

閉包

什麼是閉包

MDN 對閉包的定義爲:

閉包是指那些可以訪問自由變量的函數

那什麼是自由變量呢?

自由變量是指在函數中使用的變量,但既不是函數參數也不是函數局部變量。

自由變量實際上是指函數執行上下文的做用域鏈中非活動對象的那部分屬性(也就是外層做用域的變量 函數.[[scope]])

因此在《JavaScript高級程序設計中》是這樣描述的:

閉包是指有權訪問另外一個函數做用域中的變量的函數。

var a = 1
function foo() {
    console.log(a)
}
foo()
複製代碼

foo 函數能夠訪問變量a,但 a 既不是函數參數也不是函數的局部變量,a 就是自由變量。
那麼,foo 函數就是一個閉包。

到這裏,你或許會有疑問

這怎麼和咱們平時知道的閉包不是同一個,不是什麼函數中嵌套一個函數,裏面的函數纔是一個閉包,這裏怎麼沒有嵌套函數了?

實際上是站的角度不一樣:

  1. 從理論角度:全部函數都是閉包。由於他們都在建立時就將上層執行上下文的數據保存起來了(函數的 [[scope]] 屬性)。因此在函數中能夠做用域鏈訪問到外層做用域的變量,也就是能夠訪問自由變量,它就是一個閉包。
  2. 從實際角度:如下才是閉包:
    • 即便建立它的上下文已經銷燬,但它依然存在(好比,內部函數從父函數中返回)
    • 在代碼中引用了自由變量

舉個例子:

function foo() {
    var a = 0
    return function add() {
        console.log(a++)
    }
}
var add1 = foo()
var add2 = foo()

add1()  // 0
add1()  // 1
add1()  // 2
add2()  // 0
add2()  // 1
複製代碼

foo() 函數執行完畢,foo函數執行上下文已經被銷燬,那麼上下文的變量對象中的 a 變量應該也被銷燬了啊,問啥還能訪問到?

由於 add 函數在定義時就存在一個 [[scope]] 屬性,它保存了 foo 函數執行上下文的變量對象,在執行 add 函數時,會建立一個執行上下文鏈並將 add.[[scope]] 複製到該執行上下文的做用域鏈中,因此在 add 函數中能夠經過做用域鏈訪問到 a 屬性。

你可能還有一個問題,爲何 add1()add2() 訪問的不是同一個 a
由於每執行一次函數就會建立一個函數執行上下文,因此執行 add1 = foo()add2 = foo() 產生的是不一樣的執行上下文(對象),他們的 a 屬性固然不一樣了。

閉包的面試題

經典面試題,使用閉包解決 for 循環中 var 異步打印 i 值的問題

for (var i = 0; i < 5 ; i++) {
    setTimeout(function foo() {
        console.log(i)
    },1000 * i)
}
複製代碼

以上代碼執行結果是:5 5 5 5 5

這裏面涉及到事件循環機制,這裏就很少贅述,簡單的說就是等 for 循環結束後,纔開始依次執行這幾個 setTimeout() 裏面的 foo 函數。

JS 沒有塊級做用域,此時的 i 值爲 5 ,console.log(i) 訪問的就是全局變量 i ,因此打印 5。

咱們要作就是使用閉包的特性,讓 console.log(i) 訪問的不是全局變量 i

for (var i = 0; i < 5 ; i++) {
    ;(function(i) {
        setTimeout(function foo() {
        console.log(i)
        },1000 * i)
    })(i)
}
複製代碼

或者這樣:

for (var i = 0; i < 5 ; i++) {
    setTimeout((function foo(i) {
        return function() {
             console.log(i)
        } 
    })(i),1000 * i)
}
複製代碼

也可使用 ES6 中的 let 換掉 var,使得 for 循環中 i 成爲一個塊級做用域的本地變量。

for (let i = 0; i < 5 ; i++) {
    setTimeout(function foo() {
        console.log(i)
    },1000 * i)
}
複製代碼

閉包的優缺點

閉包能夠建立私有屬性和方法。

var singel = (function () {
    // 私有屬性,外部訪問不到
    var age = 20
    function foo() {
        console.log('foo')
    }
    return {
        // 公有屬性
        name: 'Tom',
        getAge: function() {
            return age
        },
        setAge: function(n) {
            age = n
        }
    }
})()

console.log(singel.age)  // undefined
singel.foo()  // Uncaught TypeError: singel.foo is not a function
console.log(singel.getAge())  // 20
singel.setAge(10) 
console.log(singel.getAge())  // 10
複製代碼

單例:指的是隻有一個實例的對象。JavaScript 通常以字面量的方式來建立單例。

匿名函數最大的用途就是建立閉包。而且還能夠構建命名空間,減小全局變量的污染。

經過匿名函數實現一個閉包計數器:

var numberCounter = (function() {
    var num = 0
    return function() {
        return ++num
    }
})()
複製代碼

閉包的缺陷:

  • 閉包所訪問的自由變量會常駐內存增大內存的使用量,所以閉包濫用會形成網頁性能問題。在老版本瀏覽器中因爲垃圾回收有問題致使內存泄漏。正常使用閉包不會致使內存泄漏。

總結

  • 做用域是定義變量的區域,規定了變量的訪問範圍,它在函數定義時肯定。
  • 執行上下文是一個對象,在函數執行時建立,它有變量對象、做用域鏈、this 三個屬性。
  • 函數執行時,變量對象經過函數的 arguments 屬性初始化,它包含函數的參數、函數體裏聲明的變量。
  • 函數執行時,做用域鏈是由函數的 [[scope]] 屬性中的變量 + 活動對象中的變量組成的。
  • 閉包是訪問外部做用域的變量的函數。
  • 閉包能夠建立私有屬性和方法。
  • 閉包濫用會影響頁面性能。

閱讀原文

參考資料:

github.com/mqyqingfeng…

segmentfault.com/a/119000000…

www.kancloud.cn/kancloud/ja…

blog.csdn.net/qq_27626333…

相關文章
相關標籤/搜索