ES6筆記之引數預設值(譯)

Bosn Ma發表於2015-01-29
  • 原文連結: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預設值(它們全部只建立了一個作用域,也就是函式作用域)。預設值顯然是一個有用的特性,它使得我們的程式碼更加優雅和明確。

作者

相關文章