1. 程式人生 > >通過javascript 執行環境理解她

通過javascript 執行環境理解她

古往今來最難的學的武功(javascript)算其一。

欲練此功必先自宮,願少俠習的此功,笑傲江湖。

你將瞭解

  • 執行棧(Execution stack)
  • 執行上下文(Execution Context)
  • 作用域鏈(scope chains)
  • 變數提升(hoisting)
  • 閉包(closures)
  • this 繫結

執行棧

又叫呼叫棧,具有 LIFO(last in first out 後進先出)結構,用於儲存在程式碼執行期間建立的所有執行上下文。

當 JavaScript 引擎首次讀取你的指令碼時,它會建立一個全域性執行上下文並將其推入當前的執行棧。每當發生一個函式呼叫,引擎都會為該函式建立一個新的執行上下文並將其推到當前執行棧的頂端。

引擎會執行執行上下文在執行棧頂端的函式,當此函式執行完成後,其對應的執行上下文將會從執行棧中彈出,上下文控制權將移到當前執行棧的下一個執行上下文。

我們通過下面的示例來說明一下

function one() {
  console.log('one')
  two()
}
function two() {
  console.log('two')
}
one()

當程式(程式碼)開始執行時 javscript 引擎建立 GobalExecutionContext (全域性執行上下文)推入當前的執行棧,此時 GobalExecutionContext 處於棧頂會立刻執行全域性執行上下文 然後遇到 one()

引擎都會為該函式建立一個新的執行上下文 oneFunctionExecutionContext 並將其推到當前執行棧的頂端並執行,然後遇到two() twoFunctionExecutionContext 入棧並執行至出棧,回到 oneFunctionExecutionContext 繼續執行至出棧 ,最後剩餘一個 GobalExecutionContext 它會在程式關閉的時候出棧。

然後呼叫棧如下圖:

如果是這樣的程式碼

function foo() {
  foo()
}
foo()

如下

當一個遞迴沒有結束點的時候就會出現棧溢位

什麼是執行上下文

瞭解 JavaScript 的執行上下文,有助於你理解更高階的內容比如變數提升、作用域鏈和閉包。既然如此,那到底什麼是“執行上下文”呢?

執行上下文是當前 JavaScript 程式碼被解析和執行時所在環境的抽象概念。

Javascript 中程式碼的執行上下文分為以下三種:

  1. 全域性執行上下文(Global Execution Context)- 這個是預設的程式碼執行環境,一旦程式碼被載入,引擎最先進入的就是這個環境。
  2. 函式執行上下文(Function Execution Context) - 當執行一個函式時,執行函式體中的程式碼。
  3. Eval - 在 Eval 函式內執行的程式碼。

javascript 是一個單執行緒語言,這意味著在瀏覽器中同時只能做一件事情。當 javascript 直譯器初始執行程式碼,它首先預設進入全域性上下文。每次呼叫一個函式將會建立一個新的執行上下文。

javascript執行棧中不同執行上下文之間的詞法環境有一種關聯關係,從棧頂到棧底(從區域性直到全域性),這種關係被叫做作用域鏈

簡單的說,每次你試圖訪問函式執行上下文中的變數時,程序總是從自己上下文環境中開始查詢。如果在自己的上下文中沒發現要查詢的變數,繼續搜尋下一層上下文。它將檢查執行棧中每一個執行上下文環境,尋找和變數名稱匹配的值,直到找到為止,如果到全域性都沒有則丟擲錯誤。

執行上下文的建立過程

我們現在已經知道,每當呼叫一個函式時,一個新的執行上下文就會被創建出來。然而,在 javascript 引擎內部,這個上下文的建立過程具體分為兩個階段:

建立階段 > 執行階段

建立階段

執行上下文在建立階段建立。在建立階段發生以下事情:

  1. LexicalEnvironment 元件已建立。
  2. VariableEnvironment 元件已建立。

因此,執行上下文可以在概念上表示如下:

ExecutionContext = {
  LexicalEnvironment = <詞法環境>,
  VariableEnvironment = <變數環境>,
}

詞法環境(Lexical Environment)

官方 ES6 文件將詞法環境定義為:

詞法環境是一種規範型別,基於 ECMAScript 程式碼的詞法巢狀結構來定義識別符號與特定變數和函式的關聯關係。詞法環境由環境記錄(environment record)和可能為空引用(null)的外部詞法環境組成。

簡而言之,詞法環境是一個包含識別符號變數對映的結構。(這裡的識別符號表示變數/函式的名稱,變數是對實際物件【包括函式型別物件】或原始值的引用)

詞法環境有兩種型別

  • 全域性環境(在全域性執行上下文中)是一個沒有外部環境的詞法環境。全域性環境的外部環境引用為 null。它擁有一個全域性物件(window 物件)及其關聯的方法和屬性(例如陣列方法)以及任何使用者自定義的全域性變數,this 的值指向這個全域性物件。
  • 函式環境,使用者在函式中定義的變數被儲存在環境記錄中。對外部環境的引用可以是全域性環境,也可以是包含內部函式的外部函式環境。

每個詞彙環境都有三個組成部分:

1)環境記錄(environment record)

2)對外部環境的引用(outer)

3) 繫結 this

環境記錄 同樣有兩種型別(如下所示):

  • 宣告性環境記錄 儲存變數、函式和引數。一個函式環境包含宣告性環境記錄。
  • 物件環境記錄 用於定義在全域性執行上下文中出現的變數和函式的關聯。全域性環境包含物件環境記錄

抽象地說,詞法環境在虛擬碼中看起來像這樣:

詞法環境和環境記錄值是純粹的規範機制,ECMAScript 程式不能直接訪問或操縱這些值。

GlobalExectionContext = {
  // 詞法環境
  LexicalEnvironment:{
    // 功能環境記錄
    EnvironmentRecord:{
      Type:"Object",
      // Identifier bindings go here
     }
    outer:<null>,
    this:<global object>
  }
}
FunctionExectionContext = {
  LexicalEnvironment:{
    EnvironmentRecord:{
      Type:"Declarative",
      // Identifier bindings go here
     }
    outer:<Global or outer function environment reference>,
    this:<取決於函式的呼叫方式>
  }
}

變數環境:

它也是一個詞法環境,其 EnvironmentRecord 包含了由 VariableStatements 在此執行上下文建立的繫結。
如上所述,變數環境也是一個詞法環境,因此它具有上面定義的詞法環境的所有屬性。
在 ES6 中,LexicalEnvironment 元件和 VariableEnvironment 元件的區別在於前者用於儲存函式宣告和變數( let 和 const )繫結,而後者僅用於儲存變數( var )繫結。
讓我們結合一些程式碼示例來理解上述概念:

let a = 20
const b = 30
var c

function multiply(e, f) {
  var g = 20
  return e * f * g
}

c = multiply(20, 30)

執行上下文如下所示:

GlobalExectionContext = {
  LexicalEnvironment:{
    EnvironmentRecord:{
      Type:"Object",
      // Identifier bindings go here
      a:<uninitialized>,
      b:<uninitialized>,
      multiply:<func>
    },
    outer:<null>,
    ThisBinding:<Global Object>
  },
  VariableEnvironment:{
    EnvironmentRecord:{
      Type:"Object",
      // Identifier bindings go here
      c:undefined,
    }
    outer:<null>,
    ThisBinding:<Global Object>
  }
}

在執行階段,完成變數賦值。因此,在執行階段,全域性執行上下文將看起來像這樣。

// 執行
GlobalExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      a: 20,
      b: 30,
      multiply: < func >
    }
    outer: <null>,
    ThisBinding: <Global Object>
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Object",
      // Identifier bindings go here
      c: undefined,
    }
    outer: <null>,
    ThisBinding: <Global Object>
  }
}

當 multiply(20, 30)遇到函式呼叫時,會建立一個新的函式執行上下文來執行函式程式碼。因此,在建立階段,函式執行上下文將如下所示:

// multiply 建立
FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

在此之後,執行上下文將執行執行階段,這意味著完成了對函式內部變數的賦值。因此,在執行階段,函式執行上下文將如下所示:

// multiply 執行
FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      Arguments: {0: 20, 1: 30, length: 2},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      g: 20
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

函式完成後,返回的值賦值給c。因此,全域性詞法環境得到了更新。之後,全域性程式碼完成,程式結束。

注: 在執行階段,如果 Javascript 引擎在原始碼中宣告的實際位置找不到 let 變數的值,那麼將為其分配 undefined 值。

變數提升

在網上一直看到這樣的總結: 在函式中宣告的變數以及函式,其作用域提升到函式頂部,換句話說,就是一進入函式體,就可以訪問到其中宣告的變數以及函式。這是對的,但是知道其中的緣由嗎?相信你通過上述的解釋應該也有所明白了。不過在這邊再分析一下。

你可能已經注意到了在建立階段 letconst 定義的變數沒有任何與之關聯的值,但 var 定義的變數設定為 undefined
這是因為在建立階段,程式碼會被掃描並解析變數和函式宣告,其中函式宣告儲存在環境中,而變數會被設定為 undefined(在 var 宣告變數的情況下)或保持未初始化(在 letconst 宣告變數的情況下)。
這就是為什麼你可以在宣告之前訪問var 定義的變數(儘管是 undefined ),但如果在宣告之前訪問letconst 定義的變數就會提示引用錯誤的原因。
這就是我們所謂的變數提升

思考題:

console.log('step1:',a)
var a = 'artiely'
console.log('step2:',a)
function bar (a){
  console.log('step3:',a)
  a = 'TJ'
  console.log('step4:',a)
  function a(){
  }
}
bar(a)
console.log('step5:',a)

對外部環境的引用

上面程式碼如果我們改用呼叫方式如下:

let a = 20
const b = 30
var c

function multiply() {
  var g = 20
  return a * b * g
}

c = multiply()

其實你會發現結果是一樣的
但是 multiply 的執行上下文卻發生一些變化

// 建立
FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      Arguments: { length: 0},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      g: undefined
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}
// 執行
FunctionExectionContext = {
LexicalEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      Arguments: { length: 0},
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>,
  },
VariableEnvironment: {
    EnvironmentRecord: {
      Type: "Declarative",
      // Identifier bindings go here
      g: 20
    },
    outer: <GlobalLexicalEnvironment>,
    ThisBinding: <Global Object or undefined>
  }
}

multiply() 執行的時候會直接在 outer: <GlobalLexicalEnvironment>,中查詢a,b

對外部環境的引用意味著它可以訪問其外部詞法環境。這意味著如果在當前詞法環境中找不到它們,JavaScript 引擎可以在外部環境中查詢變數。這就是之前說的 作用域鏈

閉包

MDN 解釋 閉包是由函式以及建立該函式的詞法環境組合而成。這個環境包含了這個閉包建立時所能訪問的所有區域性變數

這是我認為對閉包最合理的解釋了,就看你怎麼理解閉包的機制了。
其實閉包與作用域鏈有著密切的關係。

首先我們來看看什麼樣的程式碼會產生閉包。

function foo() {
  var name = 'artiely'
  function bar() {
    console.log(`hello `)
  }
  bar()
}
foo()

以上程式碼是有閉包嗎?沒有~

function foo() {
  var name = 'artiely'
  function bar() {
    console.log(`hello ${name}`)
  }
  bar()
}
foo()

我們只做了微小的調整,現在就有閉包了,我們只是在bar中加入了name得引用
上面的程式碼還可以寫成這樣

// 或者
function foo() {
  var name = 'artiely'
  return function bar() {
    console.log(`hello ${name}`)
  }
}
foo()()

對於閉包的形成我進行了如下的幾點歸納

  1. A 函式內必須有 B 函式的宣告;
  2. B 函式必須引用 A 函式的變數;
  3. B 函式被呼叫(當然前提是 A 函式被呼叫)

以上 3 點缺一不可

我們來分析一下上面程式碼的執行上下文

// 建立
fooFunctionExectionContext = {
LexicalEnvironment: {
  EnvironmentRecord: {
    Type: "Declarative",
    Arguments: { length: 0},
    bar: < func >,
  },
  outer: <GlobalLexicalEnvironment>,
  ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
  EnvironmentRecord: {
    Type: "Declarative",
    name: undefined
  },
  outer: <GlobalLexicalEnvironment>,
  ThisBinding: <Global Object or undefined>
  }
}
// 執行 略
// 建立
barFunctionExectionContext = {
LexicalEnvironment: {
  EnvironmentRecord: {
    Type: "Declarative",
    Arguments: { length: 0},
  },
  outer: <fooLexicalEnvironment>,
  ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
  EnvironmentRecord: {
    Type: "Declarative",
  },
  outer: <fooLexicalEnvironment>,
  ThisBinding: <Global Object or undefined>
  }
}
// 執行 略

這裡因為bar的建立存在著對fooLexicalEnvironment裡變數的引用,雖然foo可能執行已結束但變數不會被回收。這種機制被叫做閉包

閉包是由函式以及建立該函式的詞法環境組合而成。這個環境包含了這個閉包建立時所能訪問的所有區域性變數

我們結合上面例子重新分解一下這句話

閉包是由函式bar以及建立該函式foo的詞法環境組合而成。這個環境包含了這個閉包建立時所能訪問的所有區域性變數name

但是從chrome的理解,閉包並沒有包含所能訪問的所有區域性變數,僅僅包含所被引用的變數。

this 繫結

在全域性執行上下文中,值是 this 指全域性物件。(在瀏覽器中,this 指的是 Window 物件)。

在函式執行上下文中,值 this 取決於函式的呼叫方式。如果它由物件引用呼叫,則將值 this 設定為該物件,否則,將值 this 設定為全域性物件或 undefined(在嚴格模式下)。例如:

let person = {
  name: 'peter',
  birthYear: 1994,
  calcAge: function() {
    console.log(2018 - this.birthYear)
  }
}

person.calcAge()
// 'this' 指向 'person', 因為 'calcAge' 是被 'person' 物件引用呼叫的。

let calculateAge = person.calcAge
calculateAge()
// 'this' 指向全域性 window 物件,因為沒有給出任何物件引用

注意所有的()()自呼叫的函式 this 都是指向Global Object的既瀏覽器中的window

最後

如果本文對你有幫助或覺得不錯請幫忙點贊,如有疑問請留言。

其他參考:
https://hackernoon.com/execution-context-in-javascript-319dd72e8e2c

https://tylermcginnis.com/ultimate-guide-to-execution-contexts-hoisting-scopes-and-closures-in-javascript/

https://hackernoon.com/javascript-execution-context-and-lexical-environment-explained-528351703