變數物件與作用域鏈

林晨熙發表於2019-03-31

通常,我們在函式內部使用函式外部的變數時會很自然,並沒有想過為什麼能夠直接使用函式外部的變數而在函式外部卻不能直接使用函式內部的變數,一切都顯得理所當然。佛曰,凡事必有因,這個因就是作用域鏈,在瞭解作用域鏈如何起作用前我們應該知道與其息息相關的執行環境和變數物件。

執行環境的生命週期

執行環境在JS 執行上下文一文中有討論。

執行環境的生命週期大概分為兩個階段,即建立階段和執行階段:

1. 建立階段

  • 建立作用域鏈(變數物件+父級執行環境的變數物件)
  • 建立變數物件(包括區域性變數、函式以及函式引數)
  • 確定 this 的指向

由此,一個執行環境可以由包含作用域鏈、變數物件和 this 指標的物件組成:

executionContextObj = {
  scopeChain: {},
  variableObject: {},
  this: {}
}
複製程式碼

2. 程式碼執行階段

  • 指定變數的值和函式的引用
  • 解釋並執行程式碼

變數物件(Variable Object, VO)

由前述,我們知道作用域鏈是由變數物件組成的,因此要理解作用域鏈就需要知道變數物件是如何建立的。其過程大致如下:

  • 建立 arguments 物件,檢查當前環境的引數,初始化屬性和屬性值。
  • 檢查函式宣告,當前環境中每發現一個函式就在 VO 中用函式名建立一個屬性,以此來引用函式。如果函式名存在,就覆蓋這 個屬性。
  • 檢查變數,當前環境中每發現一個變數就在 VO 中用變數名建立一個屬性,並初始化其值為 undefined。如果變數名存在, 則不進行任何處理(注意這是在建立階段,執行階段會被賦值),繼續檢查。

來看下面的例子:

function calcArea(r) {
  var width = 20;
  var squareArea = function squareArea() {
    return width * width;
  };

  function circleArea() {
    return 3.14 * r * r;
  };

  return circleArea() + squareArea();
}

calcArea(10);
複製程式碼

當呼叫 calcArea(10)時建立階段的快照如下:

calcAreaExecutionContext = {
  scopeChain: { ... },
  variableObject: {
    arguments: {
      0: 10,
      length: 1
    },
    r: 10,
    width: undefined,
    squareArea: undefined,
    circleArea: pointer to function circleArea()
  },
  this: { ... }
}
複製程式碼

可以看到在建立階段,只處理定義變數的名字,不為變數賦值,一旦建立完成進入執行階段就會為變數賦值,其快照如下:

calcAreaExecutionContext = {
  scopeChain: { ... },
  variableObject: {
    arguments: {
      0: 10,
      length: 1
    },
    r: 10,
    width: 20,
    squareArea: pointer to function squareArea(),
    circleArea: pointer to function circleArea()
  },
  this: { ... }
}
複製程式碼

由此變數提升就比較容易理解了,來看如下例子

console.log(hello); // [Function: hello]
function hello() { console.log('how are u') }
var hello = 10;
複製程式碼

可以看到列印輸出的值為[Function: hello],為什麼能在變數宣告前使用呢?我們來看上述程式碼的執行流程

  • 首先進入全域性環境建立階段,檢查函式宣告,將函式 hello 放入變數物件。
  • 檢查變數宣告,發現變數 hello 已經存在,則跳過。
  • 進入執行階段,變數物件就變成了活動物件 AO(Active Object,變成活動物件前,其內部屬性不能被訪問),執行程式碼 console.log(hello)時會先到當前環境活動物件中尋找 hello,找到了函式 hello。

執行階段執行環境快照如下:

globalExecutionContext = {
  scopeChain: { ... },
  AO: {
    hello: pointer to function hello(),
  },
  this: window
}
複製程式碼

作用域鏈

當程式碼在一個環境中執行時,會建立變數物件的一個作用域鏈,它是由當前環境與上層環境的一系列變數物件組成的,保證了當前執行環境對符合訪問許可權的變數和函式的有序訪問。

來看下面的例子:

var firstName = 'Michael';
function getName() {
  var middleName = 'Jeffrey';
  function fullName() {
    var lastName = 'Jordan';
    return firstName + middleName + lastName;
  }
  return fullName();
}

getName();
複製程式碼

上面的程式碼會建立三個執行環境,全域性環境、函式 getName 區域性環境以及函式 fullName 區域性環境,它們的變數物件分別為 VO(global)、VO(getName)以及 VO(fullName)。 最裡層的函式 fullName 的執行環境如下:

fullNameEC = {
  VO: {...},
  scopeChain: [VO(fullName), VO(getName), VO(global)], // 作用域鏈
}
複製程式碼

可以看到作用域鏈由一個陣列構成,陣列的第一個元素即鏈條的最前端為當前執行環境的變數物件,陣列的最後一個元素即鏈條的最末端為全域性執行環境的變數物件。當前執行環境在執行階段訪問變數會先從作用域鏈的最前端開始查詢變數,如果沒有則在包含環境中查詢,如果包含環境中沒有則繼續向上查詢,如此,直到全域性環境中的活動物件,返過來並不成立,也就是說在全域性作用域並不能訪問函式內部的變數。

執行棧示意圖

延長作用域鏈

在 js 中,某些語句可以在作用域鏈前端臨時新增一個變數物件,該變數物件會在程式碼執行完畢後移除。具體來說當執行流進入到下列兩種語句時,作用域就會得到加長:

  • try-catch 語句的 catch 塊

    在執行 catch 語句塊時,建立一個包含丟擲錯誤物件宣告的變數物件,將其加入作用域鏈前端。 如下,在 catch 塊中,錯誤物件 e 被新增到了其作用域鏈前端,這使得在 catch 塊內部能夠訪問到錯誤物件。執行完後,catch 塊內部的變數物件被銷燬,因此在 catch 塊外部就不能訪問到錯誤物件 e 了(ie8 可以訪問到,ie9 修復了這個問題)。

    var test = () => {
      try {
        throw Error("出錯誤了");
      } catch(e) {
        console.log(e);  //Error: 出錯誤了
      }
      console.log(e);  //Uncaught ReferenceError: e is not defined
    }
    test();
    複製程式碼
  • with(obj)語句

    將 obj 物件加入到作用域鏈前端。 如下,語句with(persion)將物件 persion 新增到了函式 getName 作用域鏈的前端,語句var myName = name在查詢變數 name 時 會首先在其作用域鏈前端,即 person 物件中查詢,查詢到 name 屬性為 snow。又因為 with 語句的變數物件是隻讀的,在本層定義的變數,不能儲存到本層,而是儲存到它的上一層作用域。這樣在函式 getName 的作用域內就能訪問到變數 myName 了。

    var persion = { name: 'snow' };
    var name = 'summer';
    var getName = () => {
      with(persion) {
        var myName = name;
      }
      return myName;
    }
    console.log(getName())
    => snow
    複製程式碼

結論

  • 全域性環境沒有 arguments 物件
  • 我們編寫程式碼時並不能訪問變數物件,但直譯器在處理資料使其成為活動物件時就可以使用它。
  • 作用域鏈的搜尋始終是從作用域鏈的前端開始,然後逐級的向後回溯,直到全域性環境,不能反向搜尋。
  • 各個環境間的聯絡是線性的,有次序的。

相關文章