解密JavaScript執行上下文

玩弄心裡的鬼發表於2019-05-22

 執行上下文棧

  首先我們先了解一下什麼是執行上下文棧(Execution context stack)。

  上面這張圖來自於mdn,分別展示了棧、堆和佇列,其中棧就是我們所說的執行上下文棧;堆是用於儲存物件這種複雜型別,我們複製物件的地址引用就是這個堆記憶體的地址;佇列就是非同步佇列,用於event loop的執行。

  JS程式碼在引擎中是以“一段一段”的方式來分析執行的,而並非一行一行來分析執行。而這“一段一段”的可執行程式碼無非為三種:Global code、Function Code、Eval code。這些可執行程式碼在執行的時候又會建立一個一個的執行上下文(Execution context)。例如,當執行到一個函式的時候,JS引擎會做一些“準備工作”,而這個“準備工作”,我們稱其為執行上下文。

  那麼隨著我們的執行上下文數量的增加,JS引擎又如何去管理這些執行上下文呢?這時便有了執行上下文棧。

  這裡我用一段貫穿全文的例子來講解執行上下文棧的執行過程:

var scope = 'global scope';
function checkscope(s) {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

  當JS引擎去解析程式碼的時候,最先碰到的就是Global code,所以一開始初始化的時候便會將全域性上下文推入執行上下文棧,並且只有在整個應用程式執行完畢的時候,全域性上下文才會推出執行上下文棧。

  這裡我們用ECS來模擬執行上下文棧,用globalContext來表示全域性上下文:

ESC = [
   globalContext, // 一開始只有全域性上下文
]

  然後當程式碼執行checkscope函式的時候,會建立checkscope函式的執行上下文,並將其壓入執行上下文棧:

ESC = [
  checkscopeContext, // checkscopeContext入棧
  globalContext,
]

  接著程式碼執行到return f()的時候,f函式的執行上下文被建立:

ESC = [
  fContext, // fContext入棧
  checkscopeContext,
  globalContext,
]

  f函式執行完畢後,f函式的執行上下文出棧,隨後checkscope函式執行完畢,checkscope函式的執行上下文出棧:

// fContext出棧
ESC = [
  // fContext出棧
  checkscopeContext,
  globalContext,
]
// checkscopeContext出棧
ESC = [
  // checkscopeContext出棧
  globalContext,
]

 變數物件

  每一個執行上下文都有三個重要的屬性:

  • 變數物件
  • 作用域鏈
  • this

  這一節我們先來說一下變數物件(Variable object,這裡簡稱VO)。

  變數物件是與執行上下文相關的資料作用域,儲存了在上下文中定義的變數和函式宣告。並且不同的執行上下文也有著不同的變數物件,這裡分為全域性上下文中的變數物件和函式執行上下文中的變數物件。

  全域性上下文中的變數物件

  全域性上下文中的變數物件其實就是全域性物件。我們可以通過this來訪問全域性物件,並且在瀏覽器環境中,this === window;在node環境中,this === global。

  函式上下文中的變數物件

  在函式上下文中的變數物件,我們用活動物件來表示(activation object,這裡簡稱AO),為什麼稱其為活動物件呢,因為只有到當進入一個執行上下文中,這個執行上下文的變數物件才會被啟用,並且只有被啟用的變數物件,其屬性才能被訪問。

  在函式執行之前,會為當前函式建立執行上下文,並且在此時,會建立變數物件:

  • 根據函式arguments屬性初始化arguments物件;
  • 根據函式宣告生成對應的屬性,其值為一個指向記憶體中函式的引用指標。如果函式名稱已存在,則覆蓋;
  • 根據變數宣告生成對應的屬性,此時初始值為undefined。如果變數名已宣告,則忽略該變數宣告;

  還是以剛才的程式碼為例:

var scope = 'global scope';
function checkscope(s) {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

  在執行checkscope函式之前,會為其建立執行上下文,並初始化變數物件,此時的變數物件為:

VO = {
  arguments: {
    0: 'scope',
    length: 1,
  },
  s: 'scope', // 傳入的引數
  f: pointer to function f(),
  scope: undefined, // 此時宣告的變數為undefined
}

  隨著checkscope函式的執行,變數物件被啟用,變相物件內的屬性隨著程式碼的執行而改變:

VO = {
  arguments: {
    0: 'scope',
    length: 1,
  },
  s: 'scope', // 傳入的引數
  f: pointer to function f(),
  scope: 'local scope', // 變數賦值
}

  其實也可以用另一個概念“函式提升”和“變數提升”來解釋:

function checkscope(s) {
  function f() { // 函式提升
    return scope;
  }
  var scope; // 變數宣告提升	
  scope = 'local scope' // 變數物件的啟用也相當於此時的變數賦值
  return f();
}

 作用域鏈

  每一個執行上下文都有三個重要的屬性:

  • 變數物件
  • 作用域鏈
  • this

  這一節我們說一下作用域鏈。

  什麼是作用域鏈

  當查詢變數的時候,會先從當前上下文的變數物件中查詢,如果沒有找到,就會從父級執行上下文的變數物件中查詢,一直找到全域性上下文的變數物件。這樣由多個執行上下文的變數物件構成的連結串列就叫做作用域鏈。

  下面還是用我們的例子來講解作用域鏈:

var scope = 'global scope';	
function checkscope(s) {
  var scope = 'local scope';	
  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

  首先在checkscope函式宣告的時候,內部會繫結一個[[scope]]的內部屬性:

checkscope.[[scope]] = [
  globalContext.VO
];

  接著在checkscope函式執行之前,建立執行上下文checkscopeContext,並推入執行上下文棧:

  • 複製函式的[[scope]]屬性初始化作用域鏈;
  • 建立變數物件;
  • 將變數物件壓入作用域鏈的最頂端;
     
// -> 初始化作用域鏈;
checkscopeContext = {
  scope: checkscope.[[scope]],
}

// -> 建立變數物件
checkscopeContext = {
  scope: checkscope.[[scope]],
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 傳入的引數
    f: pointer to function f(),
    scope: undefined, // 此時宣告的變數為undefined
  },
}

// -> 將變數物件壓入作用域鏈的最頂端
checkscopeContext = {
  scope: [VO, checkscope.[[scope]]],
  VO = {
    arguments: {
    0: 'scope',
    length: 1,
  },
  s: 'scope', // 傳入的引數
  f: pointer to function f(),
  scope: undefined, // 此時宣告的變數為undefined
  },
}

  接著,隨著函式的執行,修改變數物件:

checkscopeContext = {
  scope: [VO, checkscope.[[scope]]],
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 傳入的引數 
    f: pointer to function f(),
    scope: 'local scope', // 變數賦值
  }
}

  與此同時遇到f函式宣告,f函式繫結[[scope]]屬性:

checkscope.[[scope]] = [
  checkscopeContext.VO, // f函式的作用域還包括checkscope的變數物件
  globalContext.VO
];

  之後f函式的步驟同checkscope函式。

  再來一個經典的例子:

var data = [];	
for (var i = 0; i < 6; i++) {
  data[i] = function () {
    console.log(i); 
  }; 
}
data[0]();
// ...

  很簡單,不管訪問data幾,最終console列印出來的都是6,因為在ES6之前,JS都沒有塊級作用域的概念,for迴圈內的程式碼都在全域性作用域下。

  在data函式執行之前,此時全域性上下文的變數物件為:

globalContext.VO = { 
  data: [pointer to function ()], 
  i: 6, // 注意:此時的i值為6 
}

  每一個data匿名函式的執行上下文鏈大致都如下:

data[n]Context = {
  scope: [VO, globalContext.VO],
  VO: {
    arguments: {
      length: 0,
    }
  }
}

  那麼在函式執行的時候,會先去自己匿名函式的變數物件上找i的值,發現沒有後會沿著作用域鏈查詢,找到了全域性執行上下文的變數物件,而此時全域性執行上下文的變數物件中的i為6,所以每一次都列印的是6了。

  詞法作用域 & 動態作用域

  JavaScript這門語言是基於詞法作用域來建立作用域的,也就是說一個函式的作用域在函式宣告的時候就已經確定了,而不是函式執行的時候。

  改一下之前的例子:

var scope = 'global scope';
function f() {
  console.log(scope)
}
function checkscope() {
  var scope = 'local scope';
  f();
}
checkscope();

  因為JavaScript是基於詞法作用域建立作用域的,所以列印的結果是global scope而不是local scope。我們結合上面的作用域鏈來分析一下:

  首先遇到了f函式的宣告,此時為其繫結[[scope]]屬性:

// 這裡就是我們所說的“一個函式的作用域在函式宣告的時候就已經確定了”
f.[[scope]] = [
  globalContext.VO, // 此時的全域性上下文的變數物件中儲存著scope = 'global scope';
];

  然後我們直接跳過checkscope的執行上下文的建立和執行的過程,直接來到f函式的執行上。此時在函式執行之前初始化f函式的執行上下文:

// 這裡就是為什麼會列印global scope
fContext = {
  scope: [VO, globalContext.VO], // 複製f.[[scope]],f.[[scope]]只有全域性執行上下文的變數物件
  VO = {
    arguments: {
      length: 0,
    },
  },
}

  然後到了f函式執行的過程,console.log(scope),會沿著f函式的作用域鏈查詢scope變數,先是去自己執行上下文的變數物件中查詢,沒有找到,然後去global執行上下文的變數物件上查詢,此時scope的值為global scope。

 this

  在這裡this繫結也可以分為全域性執行上下文和函式執行上下文:

  • 在全域性執行上下文中,this的指向全域性物件。(在瀏覽器中,this引用 Window 物件)。
  • 在函式執行上下文中,this 的值取決於該函式是如何被呼叫的。如果它被一個引用物件呼叫,那麼this會被設定成那個物件,否則this的值被設定為全域性物件或者undefined(在嚴格模式下)

  總結起來就是,誰呼叫了,this就指向誰。

 執行上下文

  這裡,根據之前的例子來完整的走一遍執行上下文的流程:

var scope = 'global scope';
function checkscope(s) {
  var scope = 'local scope';
  function f() {
    return scope;
  }
  return f();
}
checkscope('scope');

  首先,執行全域性程式碼,建立全域性執行上下文,並且全域性執行上下文進入執行上下文棧:

globalContext = {
  scope: [globalContext.VO],
  VO: global,
  this: globalContext.VO
}
ESC = [
  globalContext,
]

  然後隨著程式碼的執行,走到了checkscope函式宣告的階段,此時繫結[[scope]]屬性:

checkscope.[[scope]] = [
  globalContext.VO,
]

  在checkscope函式執行之前,建立checkscope函式的執行上下文,並且checkscope執行上下文入棧:

// 建立執行上下文
checkscopeContext = {
  scope: [VO, globalContext.VO], // 複製[[scope]]屬性,然後VO推入作用域鏈頂端
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 傳入的引數
    f: pointer to function f(),
    scope: undefined,
  },
  this: globalContext.VO,
}

// 進入執行上下文棧
ESC = [
  checkscopeContext,
  globalContext,
]

  checkscope函式執行,更新變數物件:

// 建立執行上下文
checkscopeContext = {
  scope: [VO, globalContext.VO], // 複製[[scope]]屬性,然後VO推入作用域鏈頂端
  VO = {
    arguments: {
      0: 'scope',
      length: 1,
    },
    s: 'scope', // 傳入的引數
    f: pointer to function f(),
    scope: 'local scope', // 更新變數
  },
  this: globalContext.VO,
}

  f函式宣告,繫結[[scope]]屬性:

f.[[scope]] = [
  checkscopeContext.VO,
  globalContext.VO,
]

  f函式執行,建立執行上下文,推入執行上下文棧:

// 建立執行上下文
fContext = {
  scope: [VO, checkscopeContext.VO, globalContext.VO], // 複製[[scope]]屬性,然後VO推入作用域鏈頂端
  VO = {
    arguments: {
      length: 0,
    },
  },
  this: globalContext.VO,
}
	
// 入棧
ESC = [
  fContext,
  checkscopeContext,
  globalContext,
]

  f函式執行完成,f函式執行上下文出棧,checkscope函式執行完成,checkscope函式出棧:

ESC = [
  // fContext出棧
  checkscopeContext,
  globalContext,
]

ESC = [
  // checkscopeContext出棧,
  globalContext,
]

  到此,一個整體的執行上下文的流程就分析完了。

相關文章