javascript執行上下文詳解

簡介

執行上下文可以說是js代碼執行的一個環境,存放瞭代碼執行所需的變量,變量查找的作用域鏈規則以及this指向等。同時,它也是js很底層的東西,很多的問題如變量提升、作用域鏈和閉包等都可以在執行上下文中找到答案,所以這也是我們學習執行上下文的原因

執行上下文分為三種:

  • 全局執行上下文:當進入全局代碼時會進行編譯,在編譯中創建全局執行上下文,並生成可執行代碼
  • 函數執行上下文:執行代碼的過程中,如果遇到函數調用,會編譯函數內的代碼和創建函數執行上下文,並創建可執行代碼
  • eval執行上下文:當使用eval函數的時候,eval的代碼也會被編譯,並創建執行上下文

因為執行上下文是在編譯階段創建的,所以接下來先看一下js代碼的執行過程吧

javascript代碼的執行過程

一段js代碼的執行過程中,先是會進行編譯階段,js引擎會將代碼進行編譯,再進入執行階段

也就是說,js代碼是按照“段”來執行的,具體就是全局代碼就是一段代碼,函數執行也算一段代碼,編譯也是按照“段”來編譯的,也就是一整個js代碼會出現多個編譯階段

編譯階段

編譯階段是一個很復雜的過程,這裡隻是簡單的介紹:

1、編譯階段完成兩件事情:創建執行上下文和生成可執行代碼

2、執行上下文就包括變量環境和詞法環境和this指向等,創建執行上下文的過程:

  • 如果是普通變量的話,js引擎會將該變量添加到變量環境中並初始化為undefined
  • 如果是函數聲明的話,js引擎會將函數定義添加到變量環境中,然後將函數名執行該函數的位置(內存)

3、接著,js引擎就會把其他的代碼編譯為字節碼,生成可執行代碼

編譯階段完成後,js引擎開始執行可執行代碼,按照順序一行一行執行,當遇到函數或者變量時,會在變量環境中尋找,找不到的話就會報錯

如果遇到賦值語句時,就會將值賦值給變量

var變量提升與let和const

變量提升是指在js代碼執行過程中,js引擎把變量的聲明部分和函數聲明部分提升到代碼開頭的“行為”。變量被提升後,會給變量設置默認值undefined

變量提升的實現並不是物理地移動代碼的位置,而是在編譯階段被js引擎放入內存中。

1、普通變量提升會賦值為undefined,函數變量名會將整個函數提升

console.log(fn);  // [Function: fn]
console.log(a);  // undefined
function fn() {
    console.log(111);
}
var a = 1

2、函數表達式隻會將變量提升,不會將函數題提升

3、當有多個相同類型的聲明(同樣是函數聲明或者同樣是普通變量聲明),最後一個聲明會覆蓋之前的聲明

4、當函數聲明與變量聲明同時出現時,函數聲明優先級更高

當然,變量提升有很多的缺陷,所以從es6開始引入瞭let和const關鍵字,通過let和const聲明的變量不具有變量提升特性,同時也支持塊級作用域,先看一下作用域吧

作用域

作用域其實就是一套定義函數調用和變量使用的規則,其中,就有三種作用域:

  • 全局作用域:其中對象在代碼的任何地方都能訪問,其生命周期伴隨著頁面的生命周期
  • 函數作用域:在函數內部定義的變量和函數,隻能在內部訪問,外部不能訪問到,函數執行結束後,函數內部定義的變量會被銷毀(函數不會嗎???)
  • 塊級作用域:由代碼塊包含的代碼會形成一個塊級作用域(es6之前沒有),跟函數作用域類似

通過var聲明的變量沒有塊級作用域,通過const let聲明的變量有塊級作用域

那js是如何var的變量提升和支持塊級作用域的呢?這就得從執行上下文的角度說起

編譯階段生成執行上下文:

假設js需要執行一個函數

  • 首先,編譯創建該函數的執行上下文,創建可執行代碼
  • 在編譯階段,所有通過var聲明的變量(包括代碼塊裡面的變量)都會被創建並存放在變量環境中,並初始化為undefined
  • 通過let或者const聲明的變量(不包括代碼塊碼裡面的變量)都會被創建並存放在詞法環境中,設置為未初始化
  • 至此,編譯階段結束瞭,開始執行代碼
  • 執行代碼過程中遇到代碼塊時,會先將裡面通過let或者const聲明的變量存放在詞法環境中並設置為初始化,其實,在詞法環境內部,維護瞭一個小型的棧結構,棧底是函數最外層的變量,每遇到一個代碼塊,就將所包含的變量壓入詞法環境的棧結構,代碼塊執行結束後,就將包含的變量彈出

接下來看一段代碼:

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()

當執行到代碼塊時,對應的執行上下文如下:

foo函數執行上
變量環境
a = 1, c = 3
詞法環境
{b = 3, d = 5}
{b = 2}

當代碼塊的代碼執行完畢後,對應的詞法環境裡的變量就會被彈出棧

foo函數執行上下文
變量環境
a = 1, c = 3
詞法環境
{b = 2}

原因

通過上面的分析,我們可以總結變量提升和塊級作用域的實現:

  • 通過編譯階段,通過var聲明的變量已經存在變量環境中並賦值為undefined,所以在執行代碼的任何位置都能狗訪問得到,而不需要在聲明之後才能訪問
  • 而通過let聲明的變量,會被存放在詞法環境中但並未初始化(不包含代碼塊的let或const聲明的變量),所以並不能訪問,而是等到遇到let聲明語句的時候才初始化並賦值
  • 在遇到代碼塊時,會先將let和const聲明的變量存放在詞法環境中並設置為初始化,如果此時在代碼塊中在let聲明變量之前使用該變量,並不會去外部作用域找該變量,因為此時詞法作用域已經存在改變量瞭,但未初始化,所以此時會報錯誤,這也是let暫時性死區的原因

單個執行上下文中變量的查找規則

沿著詞法環境的棧頂向下查詢,如果在詞法環境的某個快中查找到瞭,就直接返回給js引擎,如果沒有找到,就繼續在變量環境中查找

在這裡插入圖片描述

調用棧

調用棧是用來管理函數調用關系的一種棧結構

在函數調用之前,會創建對應的執行上下文,並生成對應的可執行代碼

  • js維護瞭一個棧結構,每當遇到一個函數調用的時候,就創建一個執行上下文,並壓入該棧中,
  • 這個棧叫做執行上下文棧,也叫做調用棧
  • 當函數執行完畢之後,會將對應的執行上下文彈出棧結構
  • 棧的容量是有限的,當棧容量不夠的時候就有可能發生棧溢出

作用域鏈

  • 在函數中如果在當前作用域中找不到所需要的變量,就得沿著作用域鏈往下去查找,直到找到為止
  • 我們都知道,當一段代碼在執行的時候,會有對應的執行上下文,那變量沿著作用域鏈查看的規則也是在執行上下文中設置的
  • 在每個執行上下文中,在變量環境中,都有一個外部引用,用來執行外部的執行上下文,我們把這個外部引用稱為outer
  • 上文已經說到,變量的查找首先會從執行上下文的詞法環境中查找,找不到就在變量環境中查找,再找不到的話就會沿著outer去外部的執行上下文中查找
  • outer具體引用哪一個執行上下文(作用域),是由詞法作用域決定的

詞法作用域

詞法作用域指的是作用域有代碼中函數的聲明位置決定的,也叫做靜態作用域

也就是說,當創建一個執行上下文的時候,其內部的outer就會根據詞法作用域去執行對應的外部執行上下文

在外部的執行上下文中查找時,也是先從詞法環境中開始

function fn() {
    console.log(a);
}
function fn1() {
    let a = 1
    fn()
}
let a = 3  // let聲明的變量是在詞法環境中的
fn1()  // 3

閉包

在js中,根據詞法作用域的規則,內部函數總是可以訪問外部函數中聲明的變量,當通過調用一個外部函數返回一個內部函數後,即使該外部函數已經執行結束瞭,但是內部函數引用外部函數的變量依然保存在內存中,我們就把這些變量的集合稱為閉包

function foo() {
    var myName = " 極客時間 "
    let test1 = 1
    const test2 = 2
    var innerBar = {
        getName:function(){
            console.log(test1)
            return myName
        },
        setName:function(newName){
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 極客邦 ")
bar.getName()
console.log(bar.getName())

上面代碼中,由於存在閉包現象,foo函數執行結束後,內部的變量還會被保存,調用棧如下圖:

在這裡插入圖片描述

當執行到 bar.setName 方法中的myName = "極客邦"這句代碼時,JavaScript 引擎會沿著“當前執行上下文–>foo 函數閉包–> 全局執行上下文”的順序來查找 myName 變量,你可以參考下面的調用棧狀態圖:

在這裡插入圖片描述

閉包的回收

  • 如果閉包使用不正確,會很容易造成內存泄漏的,關註閉包是如何回收的能讓你正確地使用閉包。
  • 通常,如果引用閉包的函數是一個全局變量,那麼閉包會一直存在直到頁面關閉;但如果這個閉包以後不再使用的話,就會造成內存泄漏。
  • 如果引用閉包的函數是個局部變量,等函數銷毀後,在下次 JavaScript 引擎執行垃圾回收時,判斷閉包這塊內容如果已經不再被使用瞭,那麼 JavaScript 引擎的垃圾回收器就會回收這塊內存。
  • 所以在使用閉包的時候,你要盡量註意一個原則:如果該閉包會一直使用,那麼它可以作為全局變量而存在;但如果使用頻率不高,而且占用內存又比較大的話,那就盡量讓它成為一個局部變量。
var bar = {
    myName:"time.geekbang.com",
    printName: function () {
        console.log(myName)
    }    
}
function foo() {
    let myName = " 極客時間 "
    return bar.printName
}
let myName = " 極客邦 "
let _printName = foo()
_printName()
bar.printName()

從上下文角度講this

執行上下文分為三種,對應的this也隻有三種:全局上下文的this,函數中的this,eval中的this

  • 箭頭函數沒有自己的執行上下文
  • 全局上下文的this指向全局對象
  • 函數上下文的this根據四種綁定規則判斷this指向
  • 執行上下文包含this指向

參考:https://time.geekbang.org/column/intro/100033601

推薦閱讀: