JavaScript-編譯與閉包

語言: CN / TW / HK

編譯原理

儘管 JavaScript 經常被歸類為“動態”或“解釋執行”的語言,但實際上它是一門編譯語言。JavaScript 引擎進行的編譯步驟和傳統編譯語言非常相似,但有些地方可能比預想的要複雜。

傳統編譯流程:

  • 分詞/此法分析(Tokenizing/Lexing)

    這個過程會將有字符組成的字符串分解成(對編程語言來説)有意義的代碼塊,這些代碼塊被稱為詞法單元(token)。例如: var a = 2 ;這段程序通常會被分解成詞法單元: vara=2 ;空格是否會被當成詞法單元,取決於空格在這門語言種是否具有意義。

  • 解析/語法分析(Parsing)

    這個過程是將詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的代表了程序語法的樹。這個樹被稱為“抽象語法樹”(Abstract Syntax Tree,AST)。

    var a = 2 的 AST 為:

    VariableDeclaration
    --Identifier = a
    --AssignmentExpression
    ----NumericLiteral = 2
  • 代碼生成

    將 AST 轉換為可執行代碼的過程被稱為代碼生成。這個過程與語言、目標平台等息息相關。簡單來説就是將 AST 轉換為一組機器指令,用來創建一個叫做 a 的變量(包括分配內存等),並將值 2 存儲在 a 中。

JavaScript 的編譯

JavaScript 的編譯由 JavaScript 引擎來負責(包括執行)。編譯通常由三個部分組成:

  • 引擎:從頭到尾負責整個 JavaScript 的編譯以及執行;
  • 編譯器:負責語法分析以及代碼生成;
  • 作用域:負責收集並維護由所有聲明的標識符(變量)組成的一系列查詢,並實施一套非常嚴格的規則,確定當前執行的代碼對這些標識符的訪問權限。

在我們看來 var a = 2 ;這是一個普通的變量聲明。而在 JavaScript 引擎看來這裏有兩個完全不同的聲明:

var a
a = 2

由於編譯的第一步操作會尋找所有的 var 關鍵詞聲明,無論它在代碼的什麼位置,都會聲明好。在代碼真正運行時,所有聲明都已經聲明好了,哪怕它是在其他操作的下面,都可以直接進行。這就是 var 關鍵詞的聲明提升。

a = 2;
console.log(a);
var a;

LHS 和 RHS

編譯器在編譯過程的第二步生成了代碼,引擎執行它時,就會查找變量 a 來判斷它是否已經聲明過。但引擎如何進行查找,影響最終查找的結果。

LHS 和 RHS 分別對應的是左側查找與右側查找。左右兩側分別代表 一個賦值操作的左側和右側 。也就説,當變量出現在賦值操作的左側時進行 LHS 查詢,出現在右側時進行 RHS 查詢。

例如: a = 2 ,這裏進行的就是 LHS 查詢。這裏不關心 a 的當前值,只想找到 a 併為其賦一個值。

而: console.log(a) ,這裏進行的是 RHS 查詢。因為這裏需要取到 a 的值,而不是為其賦值。

“賦值操作的左側和右側”並不一定代表就是 = 的左右兩側,賦值操作還有其他多種形式。因此,可以在概念上理解為“查詢被賦值的目標(LHS)”以及”查詢目標的值(RHS)“。

小測驗:

尋找 LHS 查詢(3處)以及 RHS 查詢(4處)。

function foo(a) {
  var b = a;
  return a + b;
}
var c = foo(2);

LHS:

var c = foo(...)
foo(2)
var b = a

RHS:

  • var c = foo(...) :查詢 foo()
  • var b = a :(為變量 b 賦值時)取得 a 的值
  • return a + b :取得 a 與 b(兩次)

異常

通過詳細的瞭解異常可以準確的確定發生的問題所在。

在 LHS 查詢時,如果到作用域頂部還沒有查詢到聲明,則作用域會熱心的幫我們(隱式)創建一個全局變量(非嚴格模式下)。

而在 RHS 查詢時,如果在作用域頂部還沒有查詢到聲明,就會拋出一個 ReferenceError 異常。

在嚴格模式下,LHS 如果沒有找到聲明,引擎會拋出一個和 RHS 類似的 ReferenceError 異常。

無論是 LHS 還是 RHS 都是查詢一個引用,而沒有查詢到對應的引用時,就會得到(引用)ReferenceError 異常。

接下來,如果 RHS 查詢到了一個變量,但是我們嘗試對這個變量的值進行不合理的操作。例如對一個非函數進行函數調用,或者對對象中不存在的屬性進行引用。那麼引擎會拋出另外一個異常,叫做 TypeError。

閉包

閉包是基於詞法作用域書寫代碼時所產生的自然結果。閉包的主要定義:

當函數可以記住並訪問 所在的 詞法作用域時,就產生了閉包,即使函數是在當前詞法作用域之外執行。

JavaScript 使用的是詞法作用域模型,另一種作用域模型是動態作用域。

仔細來看,閉包的主要定義有:

  • 函數記住並可以訪問所在的詞法作用域
  • 在當前詞法作用域之外執行也能繼續訪問所在的詞法作用域

來看一個例子:

function foo() {
  const a = 123;

  function bar() {
    console.log(a);
  }

  bar();
}
foo();

這段代碼看起來好像符合閉包的一部分定義,雖然 bar() 函數並沒有脱離當前的詞法作用域執行。但是它依然記住了 foo() 的詞法作用域,並能訪問。

它確實滿足閉包定義的一部分(很重要的一部分),從技術上講,也許是,但並不能完全斷定這就是閉包。通常我們所見到的與認為閉包的情況就是滿足所有定義的時候:

function foo() {
  const a = 321;

  function bar() {
    console.log(a);
  }

  return bar;
}
// 同理
// foo()();
const baz = foo()
baz();

因為垃圾收集機制,當一個函數執行結束後,通常它的整個內部作用域會被銷燬。當我們的 foo() 函數執行結束後,看上去它的內容不會再被使用,所以很自然的考慮會被回收。

但閉包的神奇之處就在這裏,它會阻止這一切的發生。當 barreturn 出去之後,在其詞法作用域的外部依然能夠訪問 foo() 的內部作用域。 bar 依然持有對該作用域的引用,這個引用就叫作閉包。

這也是經常見到説閉包會影響性能的主要原因。某些情況下,它確實會影響到性能,例如過度多的返回本不需要的函數,甚至是嵌套。這會導致本不需要的作用域沒有被回收。

常見的閉包

上述將一個函數 return 出來的案例是最常見的閉包案例。但在我們的代碼中,也有些其他非常常見的閉包。不過平時可能沒有太過去注意它。

先來回顧一下定義:

無論通過何種手段將內部函數傳遞到詞法作用域之外,它都會保留對改內部詞法作用域的引用,無論在何處執行這個函數都會使其閉包。

function waitAMinute(msg: string) {
  setTimeout(() => {
    console.log(msg);
  }, 1000);
}
waitAMinute('嚶嚶嚶');
function btnClick(selector: string, msg: string) {
  $(selector).click(() => {
    alert(msg);
  });
}
btnClick('#btn_1', 'hah');
btnClick('#btn_2', 'got you');