理解ES6中的暫時死區(TDZ)

Understanding TDZ in ES6

Wed, 25 Jan 2017

Temporal Dead Zone(TDZ)是 ES6(ES2015)中對作用域新的專用語。TDZ 名詞並沒有明確地寫在 ES6 的標準文件中,一開始是出現在ES Discussion討論區中,是對於某些遇到在區塊作用域綁定早於宣告語句時的狀況時,所使用的專用術語。

以英文名詞來說明,Temporal 是”時間的、暫時的”意義,Dead Zone 則是”死區”,意指”電波達不到的區域”。所以 TDZ 可以翻為”時間上暫時的無法達到的區域”,簡稱為”時間死區”或”暫時死區”。

let/const 與 var

在 ES6 的新特性中,最容易看到 TDZ 作用就是在 let/const 的使用上,let/const 與 var 的主要不同有兩個地方:

  • let/const 是使用區塊作用域;var 是使用函式作用域
  • 在 let/const 宣告之前就存取對應的變數與常數,會拋出ReferenceError錯誤;但在 var 宣告之前就存取對應的變數,則會得到undefined
console.log(aVar) // undefined
console.log(aLet) // causes ReferenceError: aLet is not defined
var aVar = 1
let aLet = 2

根據 ES6 標準中對於 let/const 宣告的章節13.3.1,有以下的文字說明:

The variables are created when their containing Lexical Environment
 is instantiated but may not be accessed in any way until the
 variable’s LexicalBinding is evaluated.

意思是說由 let/const 宣告的變數,當它們包含的詞法環境(Lexical Environment)被實體化時會被建立,但只有在變數的詞法綁定(LexicalBinding)已經被求值運算後,才能夠被存取。

註: 這裡指的”變數”是 let/const 兩者,const 在 ES6 定義中是 constant variable(固定的變數)的意思。

說得更明白些,當程式的控制流程在新的作用域(module, function 或 block 作用域)進行實體化時,在此作用域中的用 let/const 宣告的變數會先在作用域中被建立出來,但因此時還未進行詞法綁定,也就是對宣告語句進行求值運算,所以是不能被存取的,存取就會拋出錯誤。所以在這執行流程一進入作用域建立變數,到變數開始可被存取之間的一段時間,就稱之為 TDZ(暫時死區)。

以上面解說來看,以 let/const 宣告的變數,的確也是有提升(hoist)的作用。這個是很容易被誤解的地方,實際上以 let/const 宣告的變數也是會有提升(hoist)的作用。提升是 JS 語言中對於變數宣告的基本特性,只是因為 TDZ 的作用,並不會像使用 var 來宣告變數,只是會得到undefined而已,現在則是會直接拋出ReferenceError錯誤,而且很明顯的這是一個在執行期間才會出現的錯誤。

用一個簡單的範例來說明 let 宣告的變數會在作用域中被提升,就像下面這樣:

let x = 'outer value'

;(function() {
  // 這裡會產生 TDZ for x
  console.log(x) // TDZ期間存取,產生ReferenceError錯誤
  let x = 'inner value' // 對x的宣告語句,這裡結束 TDZ for x
})()

在範例中的 IIFE 裡的函式作用域,變數 x 在作用域中會先被提升到函式區域中的最上面,但這時會產生 TDZ,如果在程式流程還未執行到 x 的宣告語句時,算是在 TDZ 作用的期間,這時候存取 x 的值,就會拋出ReferenceError錯誤。

在 let 與 const 宣告的章節13.3.1接著的幾句,說明有關變數是如何進行初始化的:

A variable defined by a LexicalBinding with an Initializer is
assigned the value of its Initializer’s AssignmentExpression when
 the LexicalBinding is evaluated, not when the variable is created.
 If a LexicalBinding in a let declaration does not have an
 Initializer the variable is assigned the value undefined when the
 LexicalBinding is evaluated.

這幾句比較重點的部份是關於初始化的過程。以 let/const 宣告的變數或常數,必需是經過對宣告的指定值語句的求值後,才算初始化完成,建立時並不算初始化。如果以 let 宣告的變數沒有指定給初始值,那麼就指定值給它undefined值。也就是經過初始化的完成,才代表著 TDZ 期間的真正結束,這些在作用域中的被宣告的變數才能夠正常地被存取。

下面這個範例是一個未初始化完成的結果,它一樣是在 TDZ 中,也是會拋出ReferenceError錯誤:

let x = x

因為右值(要被指定的值),它在此時是一個還未被初始化完成的變數,實際上我們就在這一個同一表達式中要初始化它。

註: TDZ 最一開始是為了 const 所設計的,但後來的對 let 的設計也是一致的,範例中都用 let 來說明會比較容易。

註: 在 ES6 標準中,對於 const 所宣告的識別子仍然也經常包含在 variable(變數),被稱為 constant variable(固定的變數)。以 const 宣告所建立出來的常數,在 JS 中只是不能再被指定(can’t re-assignment),並不是不可被改變(immutable)的,這兩種概念仍然有很大的差異。

函式的傳入參數預設值

TDZ 作用在 ES6 中,很明確的就是與區塊作用域(block scope),以及變數/常數的要如何被初始化有關。實際上在許多 ES6 新特性中都有出現 TDZ 作用,而另一個常會被提及的是函式的傳入參數預設值中的 TDZ 作用。

下面的範例可以看到在傳入參數預設值的識別名稱,在未經初始化(有指定到值)時,它會進入 TDZ 而產生錯誤,而這個錯誤是只有在函式呼叫時,要使用到傳入參數預設值時才會出現:

function foo(x = y, y = 1) {
  console.log(y)
}

foo(1) // 這不會有錯誤
foo(undefined, 1) // 錯誤 ReferenceError: y is not defined
foo() // 錯誤 ReferenceError: y is not defined

從這個範例可以知道 TDZ 的作用,實際上在 ES6 中到處都有類似的作用。

傳入參數預設值有另一個作用域的議題會被討論,就是對於傳入參數預設值的作用域,到底是屬於”全域作用域”還是”函式中的作用域”的議題,目前看到比較常見的說法是,它是處於”中介的作用域”,夾在這兩者之間,但仍然會互相影響。中介的作用域的一個範例,是使用其他函式作為傳入參數的預設值,這通常會是一個 callback(回調、回呼)函式,一般的情況沒什麼特別,但涉及作用域時互相影響的情況下會不易理解。下面這個範例來自這裡:

let x = 1

function foo(
  a = 1,
  b = function() {
    x = 2
  }
) {
  let x = 3
  b()
  console.log(x)
}

foo()

console.log(x)

這個範例中的最後結果,在函式 foo 中輸出的 x 值到底是 1、2 還是 3?另外,在最外圍作用域的 x 最後會被改變嗎?

函式中的 x 輸出結果不可能是 1,這是很明確的,因為函式區塊中有另一個 x 的宣告與指定值let x = 3語句,這兩個都有可能被執行產生作用。剩下的是傳入參數預設值中的那個函式,是不是會變數到函式區塊中的 x 值的問題。另一個是,在全域中的那個 x 變數,會不會被改變,這也是一個問題。

按照這個範例的出處文章的說明,作者認為答案是 3 與 1。但是根據我的實驗,下面的幾個瀏覽器與編譯器並不是這樣認為:

  • babel 編譯器: 2 與 1
  • Closure Compiler: 3 與 2
  • Google Chrome(v55): 3 與 2
  • Firefox(v50): 2 與 1
  • Edge(v38): 3 與 2

實際測試的結果,怎麼都不會有 3 與 1 的答案,要不就 3 與 2,要不就 2 與 1。

3 與 2 的答案是讓 b 傳入參數的x = 2執行出來,但因為受到中介作用域的影響,因此干擾不到函式中的原本區塊中的作用域,但會影響到全域中的 x 變數。也就是基本上認定函式預設值中的那個 callback 中的作用域與全域(或外層)有關系。

2 與 1 的答案則是倒過來,只會影響到函式中的區塊,對全域(或外層)沒有影響。

所以除非中介作用域,有自己獨立的作用域,完全與函式區塊中的作用域與全域都不相干,才有可能產生 3 與 1 的結果,這是這篇文章的作者所認為的。

這個函式預設值的作用域因為實作不同,造成兩種不同的結果,但如果以 Chrome(v55)與 Firefox(v50)來實驗,在 TDZ 期間的拋出錯誤的行為基本上會一致,但 Firefox 有兩種不同的錯誤訊息,例如下面的幾個範例:

// Chrome: ReferenceError: x is not defined
// Firefox: ReferenceError: x is not defined
function foo(
  a = 1,
  b = function() {
    let x = 2
  }
) {
  b()
  console.log(x)
}
foo()
// Chrome: ReferenceError: x is not defined
// Firefox: ReferenceError: can't access lexical declaration `x' before initialization
function foo(
  a = 1,
  b = function() {
    x = 2
  }
) {
  b()
  console.log(x)
}
foo()
let x = 1
// Chrome: ReferenceError: x is not defined
// Firefox: ReferenceError: can't access lexical declaration `x' before initialization
function foo(
  a = 1,
  b = function() {
    x = 2
  }
) {
  b()
  console.log(x)
  let x = 3
}
foo()

不管如何,這個作用域的影響仍然是有爭議的,目前並沒有統一的答案。這代表格 ES6 雖然標準定好了,但裡面的一些新特性仍然有實作細節的差異,未來有可能這些差異才會慢慢一致。但對一般的開發者來說,因為知道了有這些情況,所以要盡量避免,以免產生不相容的情況。

要如何避免這種情況?最重要的就是,“不要在傳入參數預設值中作有副作用的運算”,上面的function(){ x = 2 }是有副作用的,它有可能會改變函式區塊中,或是全域中的同名稱變數,而在整個程式碼中,可能會互相影響的作用域彼此之間,避免使用同樣識別名稱的變數,這也是一個很基本的撰寫規則。

註: 本節的內容可以參考這幾篇文章TEMPORAL DEAD ZONE (TDZ) DEMYSTIFIEDES6 Notes: Default values of parameters與這個Default parameters intermediate scope討論文。

TDZ 的其它議題(陷阱)

typeof 語句

對 TDZ 期間中的變數/常數作任何的存取動作,一律會拋出錯誤,使用typeof的語句也一樣。如下面的範例:

typeof x // "undefined"

{
  // TDZ
  typeof x // ReferenceError
  let x = 42
}

但有些開發者會認為像typeof這樣的語句,需要被用來判斷變數是否存在,不應該是導致拋出錯誤,所以有部份反對的聲音,認為它讓typeof語句變得不安全,會造成使用上的陷阱。實際上這原本就是 TDZ 的設計,變數本來就不該在沒宣告完成前存取,這是為了讓 JS 執行更為合理的改善設計,只是之前 JS 在這一部份是有缺陷的作法,實際上會用typeof與 undefined 來判別變數/常數存在與否的方式,通常是對於全域變數的才會作的事情。

TDZ 期間拋出的錯誤是執行階段的錯誤

TDZ 期間所拋出的錯誤,是一種執行階段的錯誤,因為 TDZ 除了作用域的綁定過程外,還需要有變數/常數初始化的過程,才會建立出 TDZ 的期間。下面兩個範例就可以看到 TDZ 的錯誤需要真正執行到才會出現:

// 這個範例會有因TDZ拋出的錯誤
function f() {
  return x
}
f() // ReferenceError
// 這個範例不會有錯誤
function f() {
  return x
}
let x = 1

那這會有什麼問題出現?因為要能偵測出程式碼中的因 TDZ 造成的錯誤,唯有透過靜態的程式碼分析工具,或是要真正呼叫到函式執行裡面的程式碼,才會產生錯誤,這將會讓 TDZ 在編譯工具中實作變得困難。

不過只要你理解 TDZ 的設計,就知道只能這樣設計,初始化過程原本就只會在呼叫執行階段作這事,這部份還是只能靠其它工具來補強。

支援 ES6 的瀏覽器上的執行效能

ES Discussion上對於 let/const 的效能很早以前就已經有些批評的,認為在瀏覽器上實作的結果,由於 TDZ 的設計,會讓 let 相較於 var 的效能至少要慢 5%。

上面這篇貼文是在 4 年前所發表,就算是當時的實驗性質的實作在 JS 引擎上,沒有經過最佳化,實際上真的效能有差這麼大也不得而知。加上 let 本身在 for 回圈上有另外的花費,與 var 的設計不同,這兩個比較當然會有所不同,是不是都是 TDZ 影響的也不知道。

以最近在討論區中的let 與 var 的效能比較議題來看,let 的執行效率只有在某些情況下(for 回圈中)會慢 var 很多,在基本的內部作用域測試反而是快過 var 的,當然這也是要視不同的瀏覽器與版本而定。

題外話是,在其它的回答中就有明確的指出,會促使加入 TDZ 的主因是針對 const,而不是 let。但最後 TC39 的決議是讓 let 與 const 都有一致的 TDZ 設計。

ES6 到 ES5 的編譯

ES6 中的許多新式的設計仍然是很新的 JS 語言特性,目前 ES6 仍然需要依賴如 babel 之類別的編譯器,將 ES6 語法編譯到 ES5,來進行在瀏覽器上執行前的最後編譯。

這些編譯器對於 TDZ 是會如何編譯?答案是目前”並不會直接編譯”。

以 babel 來說,它預設不會編譯出具有 TDZ 的程式碼,它需要額外使用babel-plugin-transform-es2015-block-scoping或編譯時的選項es6.blockScopingTDZ,才會將 TDZ 與區域作用域的功能編譯出來。基本上這應該屬於實驗性質的,而且現在在使用上還有滿多問題的。ES5 標準中原本就沒這種設計,所以說實在硬要使用也是麻煩,TDZ 會造成的錯誤是執行期間的錯誤,對於編譯器來說,在實作上也有一定的難度。

Loading...
EddyChang

作者: Eddy Chang