- 原文連結:http://dmitrysoshnikov.com/
- 原文作者:Dmitry Soshnikov
- 譯者做了少量補充。這樣的的文字是譯者加的,可以選擇忽略。
- 作者微博:@Bosn
在這個簡短的筆記中我們聊一聊ES6的又一特性:帶預設值的函式引數。正如我們即將看到的,有些較為微妙的CASE。
ES5及以下手動處理預設值
在ES6預設值特性出現前,手動處理預設值有幾種方式:
function log(message, level) { level = level || 'warning'; console.log(level, ': ', message); } log('low memory'); // warning: low memory log('out of memory', 'error'); // error: out of memory
為了處理引數未傳遞的情況,我們常看到typeof檢測:
if (typeof level == 'undefined') { level = 'warning'; }
有時也可以檢查arguments.length
if (arguments.length == 1) { level = 'warning'; }
這些方法都可以很好的工作,但都過於手動且缺少抽象。ES6規範了直接在函式頭定義引數預設值的句法結構。
ES6預設值:基本例子
預設引數特性在很多語言中普遍存在,其基本形式可能大多數開發者都比較熟悉:
function log(message, level = 'warning') { console.log(level, ': ', message); } log('low memory'); // warning: low memory log('out of memory', 'error'); // error: out of memory
引數預設值使用方便且毫無違和感。接下來讓我們深入細節實現,掃除預設引數所帶來的一些困惑。
實現細節
以下為一些函式預設引數的ES6實現細節。
執行時求值
相對其它一些語言(如Python)在定義時一次性對預設值求值,ECMAScript在每次函式呼叫的執行期才會計算預設值。這種設計是為了避免在複雜物件作為預設值使用時引發一些困惑。接下來請看下面Python的例子:
def foo(x = []): x.append(1) return x # 我們可以看到預設值在函式定義時只建立了一次 # 並且存於函式物件的屬性中 print(foo.__defaults__) # ([],) foo() # [1] foo() # [1, 1] foo() # [1, 1, 1] print(foo.__defaults__) # ([1, 1, 1],)
為了避免這種現象,Python開發者通常把預設值定義為None
,然後為這個值做顯式檢查:
def foo(x = None): if x is None: x = [] x.append(1) print(x) print(foo.__defaults__) # (None,) foo() # [1] foo() # [1] foo() # [1] print(foo.__defaults__) # ([None],)
就目前,很好很直觀。接下來你會發現,若不瞭解預設值的工作方式,ES5語義上會產生一些困惑。
外層作用域的遮蔽
來看下面的例子:
var x = 1; function foo(x, y = x) { console.log(y); } foo(2); // 2, 不是 1!
來上例的y
輸出結果看起來像是1
,但實際上是2
,不是1
。原因是引數中的x
與全域性的x
不同。由於預設值在函式呼叫時求值,所以當賦值=x
時,x
已經在內部作用域決定了,引用的是引數x
本身。也就是說,引數x
被全域性的同名變數遮蔽,所以每次預設值中訪問x
時,實際訪問到的是引數中的x
。
引數的TDZ(Temporal Dead Zone,暫存死區)
ES6提到所謂的TDZ(暫存死區),意指這樣的程式區域:初始化前的變數或引數不能被訪問。
考慮到對於引數,不能將自己作為預設值:
var x = 1; function foo(x = x) { // throws! ... }
賦值=x
正如我們上面提到的那樣,x
會被解釋為引數級作用域中的x
,而全域性的x
會被遮蔽。但是,x
位於TDZ,在初始化前不能被訪問。因此,它不能自己初始化自己。
注意,上面之前的例子中的y
卻是合法的,因為x在之前已經初始化了(隱式的預設值undefined
)。所以我們再看下:
function foo(x, y = x) { // OK ... }
這樣不會出問題,因為在ECMAScript中,引數的解析順序是從左到右,所以在對y
求值時x
已經可用。
我們提到過引數是和”內部作用域”相關的,在ES5中我們可假設這個”內部作用域”就是函式作用域。但更復雜的情況:可能是函式的作用域,或者,一個只為儲存引數繫結的立即作用域。讓我們繼續探索。
有條件的引數立即作用域
事實上,對於一些引數(至少一個)有預設值的情況,ES6會定義一個立即作用域來儲存這些引數,並且這個作用域並不會與函式作用域共享。在這方面這是ES6與ES5的一個主要區別。有點暈?不要緊,看下例子你就懂。
var x = 1; function foo(x, y = function() { x = 2; }) { var x = 3; y(); // 區域性變數`x`會被改寫乎? console.log(x); // no, 依然是3, 不是2 } foo(); // 而且外層的`x`也未變化 console.log(x); // 1
在這個例子中,我們有三個作用域:全域性環境、引數環境、函式環境:
: {x: 3} // 函式
-> {x: undefined, y: function() { x = 2; }} // 引數
-> {x: 1} // 全域性
現在我們應該清楚了,當作為引數的函式物件y
執行時,它內部的x
會被就近解析(也就是上面說的引數環境),函式作用域對其並不可見。
編譯到ES5
如果我們想把ES6程式碼編譯到ES5,並且需要搞清楚這個立即作用域究竟是什麼樣的,我們可以得到像這樣的東東:
// ES6 function foo(x, y = function() { x = 2; }) { var x = 3; y(); // 區域性變數`x`會被改寫嗎? console.log(x); // no, 依然是3, 不是2 } // 編譯到ES5 function foo(x, y) { // 設定預設引數 if (typeof y == 'undefined') { y = function() { x = 2; }; // 現在弄清楚了,將會更新引數中的`x` } return function() { var x = 3; // 這裡的`x`是函式作用域的 y(); console.log(x); }.apply(this, arguments); }
引數級作用域的存在原因
設計引數級作用域的目的究竟是什麼?為什麼不能像ES5那樣可以訪問到函式作用域中的變數?原因:引數預設值是函式時,其函式體內的同名變數不應該影響被捕獲閉包中的同名繫結。
例:
var x = 1; function foo(y = function() { return x; }) { // 捕獲 `x` var x = 2; return y(); } foo(); // 正確的應該是 1, 不是 2
如果我們在函式體內建立函式y
,它內部的return x
中的x
會捕獲函式作用域下的x
,也就是2
。但是,很明顯,引數y
函式中的x
應該捕獲到全域性的x
,也就是1
(除非被同名引數遮蔽)。
同時,這裡不能在外部作用域下建立函式,因為這樣就意味著無法訪問這個函式的引數了,所以我們應該這樣做:
var x = 1; function foo(y, z = function() { return x + y; }) { // 現在全域性`x` 和引數`y`均在引數`z`函式中可見 var x = 3; return z(); } foo(1); // 2, 不是 4
若不建立引數級作用域
上面的描述的預設值工作方式,在語義上與最開始我們手動實現預設值完全不同,例:
var x = 1; function foo(x, y) { if (typeof y == 'undefined') { y = function() { x = 2; }; } var x = 3; y(); // 區域性變數`x`會被改寫麼? console.log(x); // 這次被改寫了!輸出2 } foo(); // 而全域性的`x`仍然未變化 console.log(x); // 1
這個事實很有趣:如果函式無預設值,它不會建立這個立即作用域,並且與函式環境共享引數繫結,也就是像ES5那樣處理。這也是為什麼說是『有條件的引數立即作用域』
為什麼會這樣?為什麼不每次建立引數級作用域?只是為了優化?非也非也。這麼做的原因其實是為了向後相容ES5:上面手動模擬預設值機制的程式碼應該更新函式體的x
(也就是引數x
,在相同作用域下實際是同一個變數被重複宣告,一次是引數定義,一次是區域性變數`x`)。
另外,需要注意到只有變數和函式允許重複宣告,而用let/const重複宣告引數是不允許的:
function foo(x = 5) { let x = 1; // error const x = 2; // error }
undefined的檢測
另外一個有趣的事情是:是否預設值會被應用將取決於初始值也就是傳參是否為undefined
(在進入上下文時被賦值)。例:
function foo(x, y = 2) { console.log(x, y); } foo(); // undefined, 2 foo(1); // 1, 2 foo(undefined, undefined); // undefined, 2 foo(1, undefined); // 1, 2
通常情況下在一些程式語言中,帶預設值引數會在必選引數的後面,但是,在JavaScript中允許下面的構造:
function foo(x = 2, y) { console.log(x, y); } foo(1); // 1, undefined foo(undefined, 1); // 2, 1
解構元件的預設值
另一個預設值涉及到的地方是解構元件的預設值。解構賦值的討論不在本文中詳述,但我們可以看一些簡單的例子。對於在函式引數中使用解構的處理,與上面描述過的預設值處理相同:也就是必要時會建立兩個作用域:
function foo({x, y = 5}) { console.log(x, y); } foo({}); // undefined, 5 foo({x: 1}); // 1, 5 foo({x: 1, y: 2}); // 1, 2
當然,解構的預設值更加通用,不只在函式引數預設值中可用:
var {x, y = 5} = {x: 1}; console.log(x, y); // 1, 5
結論
希望這個簡短的記錄能幫助大家理解ES6中的預設值特性的細節。需要注意的是,由於這個”第二作用域”是最近才加入到規範草稿中的,因此截至本文撰寫時(2014年8月21日),沒有任何引擎正確的實現了ES6預設值(它們全部只建立了一個作用域,也就是函式作用域)。預設值顯然是一個有用的特性,它使得我們的程式碼更加優雅和明確。
作者
- 作者:Dmitry Soshnikov
- 釋出時間:2014年8月21日
- 譯者:Bosn