- 原文鏈接: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