[譯] ES6:理解引數預設值的實現細節

Chorer發表於2019-05-07

在這篇文章中我們會介紹另一個 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],)
複製程式碼

但是,這與手動處理實際預設值的方式是一樣不方便的,並且最初的案例讓人感到疑惑。因此,為了避免這種情況,ECMAScript 會在每次函式執行時計算預設值:

function foo(x = []) {
  x.push(1);
  console.log(x);
}
 
foo(); // [1]
foo(); // [1]
foo(); // [1]
複製程式碼

一切都很好,很直觀。接下來你會發現,如果我們不瞭解預設值的工作機制,ES 語義可能會讓我們感到困惑。

外部作用域的遮蔽

思考下面的例子:

var x = 1;
 
function foo(x, y = x) {
  console.log(y);
}
 
foo(2); // 2,不是 1!
複製程式碼

正如我們看到的,上面的例子輸出的 y2,不是 1。原因是引數中的 x 與全域性的 x 不是同一個。由於執行階段會計算預設值,在賦值 = x 發生的時候, x 已經在內部作用域被解析了,並且指向了 x 引數自身。具有相同名稱的引數 x 遮蔽了全域性變數,使得對來自預設值的 x 的所有訪問都指向引數。

引數的 TDZ(暫時性死區)

ES6 提到了所謂的 TDZ(表示暫時性死區)—— 這是程式的一部分,在這個區域內變數或者引數在初始化(即接受一個值)之前將無法訪問

就引數而言,一個引數不能以自身作為預設值

var x = 1;
 
function foo(x = x) { // 丟擲錯誤!
  ...
}
複製程式碼

我們上面提到的賦值 = x 在引數作用域中解析 x ,遮蔽了全域性 x 。 但是,引數 x 位於 TDZ 內,在初始化之前無法訪問。因此,它無法初始化為自身。

注意,上面帶有 y 的例子是有效的,因為 x 已經初始化(為隱式預設值 undefined)了。我們再來看一下:

function foo(x, y = x) { // 可行
  ...
}
複製程式碼

之所以可行,是因為 ECMAScript 中的引數是按照從左到右的順序初始化的,我們已經有可供使用的 x 了。

我們提到引數已經與“內部作用域”相關聯了,在 ES5 中我們可以假定是函式體的作用域。但是,它實際上更加複雜:它可能是一個函式的作用域,或者是一個為了儲存引數繫結而特別建立的中間作用域。我們來思考一下。

特定的引數中間作用域

事實上,如果一些(至少有一個)引數具有預設值,ES6 會定義一個中間作用域用於儲存引數,並且這個作用域與函式體的作用域不共享。這是與 ES5 存在主要區別的一個方面。我們用例子來證明:

var x = 1;
 
function foo(x, y = function() { x = 2; }) {
  var x = 3;
  y(); // `x` 被共用了嗎?
  console.log(x); // 沒有,依然是 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); // 沒有,依然是 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,它將會捕獲內部的 x,也即 2。但顯而易見,它應該捕獲的是外部的 x,也即 1(除非它被同名引數遮蔽)。

同時,我們無法在外部作用域中建立函式,這意味著我們無法從這樣的函式中訪問引數。我們可以這樣做:

var x = 1;
 
function foo(y, z = function() { return x + y; }) { // 可以看到 `x` 和 `y`
  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(也就是引數自身,且位於相同作用域中)。

同時還要注意,那些重複宣告只適用於 var 和函式。用 let 或者 const 重複宣告引數是不行的:

function foo(x = 5) {
  let x = 1; // 錯誤
  const x = 2; // 錯誤
}
複製程式碼

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 日),預設值還沒有得到真正的實現(它們都只是建立了一個與函式體共享的作用域),因為這個“第二作用域”是在最近才新增到標準草案裡的。預設值一定會是一個很有用的特性,它將使我們的程式碼更加優雅和整潔。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章