【翻譯】JavaScript Promise 探微

於明昊發表於2014-08-30

原文連結:JavaScript Promises ... In Wicked Detail

我在 JavaScript 中使用 Promise 已經有一段時間了,目前我已經能高效的使用這一開始讓我暈頭轉向的東西。但真要細說起來,我發現還是不能完全理解它的實現原理,這也正是本文寫作的目的所在。如果諸位讀者也處在一知半解的狀態,那請讀完這篇文章,相信你也會像我一樣對 Promise 有更好的理解。

我們將會循序漸進的建立一個 Promise 的實現,最終這個實現會基本符合 Promise/A+ 規範,在此過程中你會逐步的瞭解到 Promise 是如何實現了非同步程式設計的需求。本文假設你已經有了一定的 Promise 基礎,如果你對此一無所知,請移步官網先了解學習一下。

1. 為什麼要寫這篇文章?

有些童鞋會問:為啥我們要對 Promise 瞭解得這麼細呢,會用不就好了麼?其實理解了一個東西的實現機理,可以提升你使用它的能力和效率,同時在使用出錯的時候能更有效地 debug —— 我之所以寫這篇文章就是因為有一次和同事掉進一個關於 Promise 的奇怪的坑裡去了。要是當年我就和現在這樣瞭解得這麼透徹,我就不會掉坑了~

2. 最簡單的例子

讓我們從最簡單的例子開始實現我們的 Promise 例項,我想將這樣的寫法:

doSomething(function(value) {
  console.log('Got a value:' + value);
});

實現為這樣的寫法:

doSomething().then(function(value) {
  console.log('Got a value:' + value);
});

為了實現這個效果,我們只需要將 donSomething() 函式從這樣的形式:

function doSomething(callback) {
  var value = 42;
  callback(value);
}

改成這種 “Promise” 基礎版:

function doSomething() {
  return {
    then: function(callback) {
      var value = 42;
      callback(value);
    }
  };
}

這種寫法只是給我們的回撥模式寫了一個簡單且毫無意義的語法糖。我們目前還沒有觸及 Promise 背後的核心概念,但這也是個小小的開始。

Promise 可以捕獲最終值(the eventual value)這一概念並將其置入物件

這正是 Promise 的有趣之處(譯者注:所謂的最終值,實際上是規範裡面的一個概念,表示非同步操作的最終獲取值。實際上上面這句話的意思就是將我們要傳給回撥函式的終值也儲存在 Promise 物件裡)。在後面的探索中,我們就會發現:一旦最終值的概念可以被這樣捕獲到,我們就可以幹一些非常給力的事情。

2.1. 定義 Promise 型別

讓我們進行下一步,定義一個實際的 Promise 型別來擴充套件上面的程式碼:

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    callback(value);
  }

  fn(resolve);
}

然後用 Promise 型別重寫 doSomething() 函式:

function doSomething() {
  return new Promise(function(resolve) {
    var value = 42;
    resolve(value);
  });
}

這裡就遇到一個問題:如果你逐行執行程式碼,就會發現 resolve() 函式在 then() 函式之前被呼叫,這就意味著 resolve() 被呼叫的時候,callback 還是 null 。讓我們用一個 hack 來幹掉這個問題,引入 setTimeout,程式碼如下所示:

function Promise(fn) {
  var callback = null;
  this.then = function(cb) {
    callback = cb;
  };

  function resolve(value) {
    // 將 callback 打出當前執行執行緒,使之可以被 then 函式設定
    setTimeout(function() {
      callback(value);
    }, 1);
  }

  fn(resolve);
}

用了這麼個毛招,我們的程式碼終於可以執行啦=。=

2.2. 這個程式碼太毛啦

我們寫的圖樣圖森破的 Promise 必須要加入非同步操作才能工作,這很容易使之再次失效。只要非同步地呼叫 then() 函式,我們的 callback 又會馬上變成 null 了。有作死的讀者可能會問:為啥我要寫出這麼一個破程式碼讓我這麼快就感受到失敗的挫折?因為我想用上面這個簡單易懂的例子將 Promise 的兩大關鍵概念—— then()resolve() 深深地烙印在你的腦海中。它們會陰魂不散地跟隨著你喲~

3. Promise 是有狀態(state)的

我們糟糕易崩潰程式碼暴露了一個之前我們沒有想到的問題—— Promise 是具有狀態的。我們在執行之前需要知道其當前所處的狀態,並確保我們可以正確地進行狀態轉換。採用這種方式可以讓我的程式碼穩定性強一些。

  1. promise 可以處在等待被賦值的等待態(pending),可以被給予一個值並轉為解決態(resolved)

  2. 一旦 promise 被一個值 resolve 掉,其就會一直保持這個值並不會再被 resolve。

(一個 promise 物件也可以被拒絕 rejected,我們在稍後的錯誤處理中會提到)

讓我們在例項中加入狀態的跟蹤,以此擺脫之前的毛招:

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(onResolved) {
    if(state === 'pending') {
      deferred = onResolved;
      return;
    }

    onResolved(value);
  }

  this.then = function(onResolved) {
    handle(onResolved);
  };

  fn(resolve);
}

我們的程式碼變得更加複雜,但是這樣就使得 Promise 物件呼叫者可以隨時啟用 then() 函式,被呼叫的 Promise 物件也可以隨時啟用 resolve() 方法。這在非同步和同步的程式碼中都是完全適用的。

這正是 state 標誌的功勞。新方法 handle() 與之前的兩個重要概念 then()resolve() 互不干涉,其將會根據情況在以下兩種操作中選擇一種執行:

  • then()resolve() 之前先被呼叫,意味著還沒有最終值傳遞給回撥函式。這種狀態下即為等待態,我們便在記憶體中儲存回撥函式以便後續使用。當 resolve() 被呼叫時,我們啟用回撥函式並將終值傳入。
  • reslovethen() 之前被呼叫,這種情況下我們將終值儲存在記憶體中,一旦 then() 被呼叫,我們就將終值傳入。

注意到 setTimeout 不見了麼,這個只是暫時的,它還會回來噠~

言歸正傳:

使用 promises 的時候,我們呼叫其方法的順序並不重要,可以按照自己的意願隨時呼叫 then()resolve(),這就是將終值捕獲並置於物件之中儲存的強大優勢。

儘管我們還有一些事情米有做,我們的 promises 已經非常給力了。這套實現允許我們執行多次 then() —— 其每次都會獲取到同樣的終值。

var promise = doSomething();

promise.then(function(value) {
  console.log('Got a value:', value);
});

promise.then(function(value) {
  // 此處獲取的值和上一處相同
  console.log('Got the same value again:', value);
});

(其實吧……這個地方並不是完全正確的,如果我們反過來操作,在執行 resolve() 之前多次執行 then() ,結果只有最後一次的執行會成功。如果要修復這個問題需要在 Promise 物件中維護一個佇列來記錄回撥函式。但由於這篇文章已經夠長了,所以我決定不這麼搞了=v=)

4. 通通連起來吧

既然 Promise 將非同步操作捕獲到了物件中,我們就可以對其進行鏈式操作、map 操作以及序列並行等等其他高效率的操作。下列程式碼就是一個非常常見的 Promise 用法:

getSomeData()  
  .then(filterTheData)
  .then(processTheData)
  .then(displayTheData);

由於可以呼叫 then() 函式,這證明 getSomeData() 返回的是一個 promise 物件;但是第一個怎返回的結果頁必須是一個 promise 物件,然後我們才能再次呼叫 then() 函式(然後再次呼叫再次呼叫再次呼叫~)。而實際的 Promise 實現就是這樣的效果,假如我們能夠讓 then() 函式返回一個 promise 物件,一切就變得更有趣起來。

then() 永遠返回一個 promise 物件。

以下就是給我們的 promise 假如鏈式呼叫的情況:

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    value = newValue;
    state = 'resolved';

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if(state === 'pending') {
      deferred = handler;
      return;
    }

    if(!handler.onResolved) {
      handler.resolve(value);
      return;
    }

    var ret = handler.onResolved(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved) {
    return new Promise(function(resolve) {
      handle({
        onResolved: onResolved,
        resolve: resolve
      });
    });
  };

  fn(resolve);
}

額……已經變得有點令人抓狂啦,你是不是在慶幸我們進展的比較緩慢呢~這裡的關鍵之處就在於:then() 函式返回了一個新的 Promise 物件

(由於 then() 永遠返回一個新的 promise 物件,導致每次都至少有一個 promise 物件被建立、解決然後被忽略,這就產生了一定程度了記憶體浪費。這是 Promise 被詬病的一個原因,因為傳統的回撥金字塔就不存在這樣的問題。由此你可以理解為啥一些 JavaScript 社群已經拋棄了 promise )

那第二個 promise 要 resolve 的值是什麼呢?答案是:第一個 promise 的返回值handle() 函式的最後兩行體現了這一點, handler 物件儲存了 onResolved() 回撥函式和 resolve() 函式的引用。在鏈式呼叫中儲存了多個 resolve() 函式的拷貝,每一個 promise 物件的內部都擁有一個自己的 resolve() 方法,並在閉包中執行。 這建立起了第一個 promise 與第二個 promise 之間聯絡的橋樑。我們在這一行程式碼 resolve 了第一個 promise:

var ret = handler.onResolved(value);

在上文的例子中,程式裡的 handler.onResolved 是這個函式:

function(value) {  
  console.log('Got a value:', value);
}

換句話說,這就是我們第一次呼叫 then() 時傳入的處理函式,第一個處理函式的返回值將會用來傳遞給第二個 promise,鏈式呼叫就這麼完成啦~

doSomething().then(function(result) {
  console.log('first result', result);
  return 88;
}).then(function(secondResult) {
  console.log('second result', secondResult);
});

// 輸出結果是:
//
// 第一個結果:42
// 第二個結果:88


doSomething().then(function(result) {
  console.log('first result', result);
  // 沒有顯示的返回值(也就是 undefined)
}).then(function(secondResult) {
  console.log('second result', secondResult);
});

// 輸出結果是:
//
// 第一個結果:42
// 第二個結果:undefined

既然 then() 方法永遠返回一個新的 promise ,因此這個鏈式呼叫就可以越鏈越深:

doSomething().then(function(result) {
  console.log('first result', result);
  return 88;
}).then(function(secondResult) {
  console.log('second result', secondResult);
  return 99;
}).then(function(thirdResult) {
  console.log('third result', thirdResult);
  return 200;
}).then(function(fourthResult) {
  // 鏈呀鏈...
});

我們如果想在上面的例子中獲取每次處理函式呼叫返回的結果集,就必須在鏈式呼叫中人工構建一個存放結果集的陣列:

doSomething().then(function(result) {
  var results = [result];
  results.push(88);
  return results;
}).then(function(results) {
  results.push(99);
  return results;
}).then(function(results) {
  console.log(results.join(', ');
});

// 輸出結果:
//
// 42, 88, 99

Promise 每次只會 resolve 一個值,如果你想傳遞多個值,就需要建立一種儲存方式來進行傳遞(如陣列、物件和字串)

更好的解決途徑是使用 Promise 庫中的 all() 方法或其他實用的方法來提升 promise 的使用效率,這就有待諸位讀者自己挖掘啦。

4.1. 可選的回撥函式

then() 中的回撥函式並不是嚴格要求必寫的,加入你不寫這個回撥, promise 也會用上一個 promise 返回的終值來傳遞。

doSomething().then().then(function(result) {
  console.log('got a result', result);
});

// 輸出結果是:
//
// got a result 42

你可以在 handle() 函式內部觀察到這個情況,如果當前的 then() 沒有傳遞迴調函式,該函式就會直接使用前一個 promise 返回的終值來解決下一個 promise:

if(!handler.onResolved) {
  handler.resolve(value);
  return;
}

4.2. 鏈中返回 promise

我們實現鏈式的例項還是略顯簡單,其僅僅是將解決終值傳遞下去,但如果有個終值就是 promise 咋辦?舉個例子:

doSomething().then(result) {
  // doSomethingElse 返回一個 promise
  return doSomethingElse(result)
}.then(function(finalResult) {
  console.log("the final result is", finalResult);
});

目前來看,上面的結果不會是我們期望的那樣。finalResult 不會是的第一個 result 的值,而會是一個 promise 物件。為了達到我們期望的結果(也就是依然讓返回值傳遞下去),我們需要這樣做:

doSomething().then(result) {
  // doSomethingElse returns a promise
  return doSomethingElse(result)
}.then(function(anotherPromise) {
  anotherPromise.then(function(finalResult) {
    console.log("the final result is", finalResult);
  });
});

=。=但是你會讓這一坨翔一樣的程式碼出現在你的專案中麼…讓我們在 promise 例項中隱式的處理掉這個問題。這個處理方式還是比較簡單的,只要在 resolve() 方法中加入一個對返回值是 promise 物件的特殊處理就行啦:

function resolve(newValue) {
  if(newValue && typeof newValue.then === 'function') {
    newValue.then(resolve);
    return;
  }
  state = 'resolved';
  value = newValue;

  if(deferred) {
    handle(deferred);
  }
}

這樣我們就可以繼續持續的呼叫 resolve() 直到我們獲取到一個 promise。當其返回值不是 promise 物件時,呼叫鏈就會和之前一樣正常執行。

這樣的話可能會造成無窮迴路(譯者注:也就是 then() 返回 promise 物件然後又呼叫 then())。儘管 A+ 規範裡建議 promise 的實現中對無窮迴路進行判斷,但這種判斷是沒什麼必要的(譯者注:在規範的最後寫了說明,規範本身建議判斷,但 promise 的實現並不建議判斷)。

另外,我們這個實現實際上並不完全符合規範,這篇文章裡說的東西也沒有完全符合規範。假如你對規範本身感興趣,請移步文章開始處的規範連結。

有沒有注意到我們對於 newValue 是否為 promise 物件的檢測是多麼的寬鬆麼,我們只是判斷它是否擁有 then() 方法。這個鴨子型別是我故意這麼寫的(譯者注:這不是一個 bug,這是個 feature)!這使得不同的 promise 實現可以相互運作,實際上這也是不同的第三方 promise 庫的比較常見的混用方式。

不同的 promise 實現只要恰當的遵循規範,就可以相互混用。

搞定了鏈式呼叫之後,我們的實現基本接近完成,除了最初被我們完全忽略掉的一個問題 —— 錯誤處理

5. Promise 的拒絕(reject)

當一個 promise 執行發生錯誤,其需要被拒絕(reject)並傳入一個原因(reason)。那麼呼叫者怎麼知道何時進行 reject 呢?這可以通過給 then() 函式的第二個引數傳入回撥來實現。

doSomething().then(function(value) {
  console.log('Success!', value);
}, function(error) {
  console.log('Uh oh', error);
});

正如之前所提到的那樣,promise 物件可以從 pending 轉換到 resolved 或者 rejected,但不能同時 resolved 和 rejected。換言之,then 的兩個回撥中僅有一個會被呼叫。

Promise 可以通過 resolve() 方法的孿生兄弟 —— reject() 方法來實現拒絕。下面是給 doSomething() 加入錯誤處理的情況:

function doSomething() {
  return new Promise(function(resolve, reject) {
    var result = somehowGetTheValue();
    if(result.error) {
      reject(result.error);
    } else {
      resolve(result.value);
    }
  });
}

在我們 promise 的實現中,我們也必須考慮到 reject 。一旦一個 promise 被拒絕,其後面的呼叫鏈中的 promise 也必須被拒絕。

讓我們再來一起看一下完成版的 promise 例項的實現,這其中加入了拒絕的處理。

function Promise(fn) {
  var state = 'pending';
  var value;
  var deferred = null;

  function resolve(newValue) {
    if(newValue && typeof newValue.then === 'function') {
      newValue.then(resolve, reject);
      return;
    }
    state = 'resolved';
    value = newValue;

    if(deferred) {
      handle(deferred);
    }
  }

  function reject(reason) {
    state = 'rejected';
    value = reason;

    if(deferred) {
      handle(deferred);
    }
  }

  function handle(handler) {
    if(state === 'pending') {
      deferred = handler;
      return;
    }

    var handlerCallback;

    if(state === 'resolved') {
      handlerCallback = handler.onResolved;
    } else {
      handlerCallback = handler.onRejected;
    }

    if(!handlerCallback) {
      if(state === 'resolved') {
        handler.resolve(value);
      } else {
        handler.reject(value);
      }

      return;
    }

    var ret = handlerCallback(value);
    handler.resolve(ret);
  }

  this.then = function(onResolved, onRejected) {
    return new Promise(function(resolve, reject) {
      handle({
        onResolved: onResolved,
        onRejected: onRejected,
        resolve: resolve,
        reject: reject
      });
    });
  };

  fn(resolve, reject);
}

除去額外加入的 reject() 函式,handle() 函式本身也能對拒絕進行應對。其根據 state 的值來決定進行 resolve 還是 reject,而後 state 的值會被推送到下一個 promise 中,作為決定下個 promise 進行解決還是拒絕的依據(譯者注:在這個實現中,並沒有體現出這一點。因為本例項使用的是 then() 鏈而不是 done() fail() 鏈,每次傳遞的都是一個新的 promise 物件,因此上一個 promise 被拒絕了,也僅僅會把其拒絕回撥函式的返回值傳遞給下一個鏈的 resolve 回撥。言下之意,本例項中的只有第一個 promise 物件可以被拒絕,第二個起直到鏈尾的 promise 其拒絕回撥都無法被呼叫 —— 除非發生下一章節的非預期異常,有興趣的讀者可以自己試一試)。

當使用 promise 的時候,我們很容易把錯誤處理的回撥省略掉,但這樣會導致我們無法捕獲到任何報錯。你至少應該在鏈式 promise 的最後寫一個錯誤處理回撥。這裡可以參加下一章的錯誤吞沒。

5.1. 非預期的錯誤也應該被拒絕

目前我們處理的錯誤僅僅是已知的錯誤,但也可能突然蹦出來一個意料之外的錯誤然後把一切搞崩掉。因此 promise 例項對這些異常進行捕獲並拒絕也是十分必要的。

這就意味著 resolve() 方法需要被包裹在 try/catch 語句塊中:

function resolve(newValue) {
  try {
    // ... 這裡和以前一樣
  } catch(e) {
    reject(e);
  }
}

保證 then() 中傳入的回撥函式不會丟擲一些無法處理的異常也很重要。由於這些回撥在 handle() 中被呼叫,因此我們最終的實現結果是這樣的:

function handle(deferred) {
  // ... 一切如前

  var ret;
  try {
    ret = handlerCallback(value);
  } catch(e) {
    handler.reject(e);
    return;
  }

  handler.resolve(ret);
}

5.2. promises 可能吞沒錯誤!

譯者注:非常懷疑作者在文章開頭掉進的大坑就是這個“錯誤吞噬”。)

對於 promises 的誤解可能會導致報錯資訊的丟失。這是一個不少人都會掉進去的大坑。

我們看下面這個例子:

function getSomeJson() {
  return new Promise(function(resolve, reject) {
    var badJson = "<div>uh oh, this is not JSON at all!</div>";
    resolve(badJson);
  });
}

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}, function(error) {
  console.log('uh oh', error);
});

這裡會發生什麼事情呢?我們在 then() 中傳遞的回撥函式期望獲得一個有效的 JSON 串,並用原生方法去解析它,因此導致了一個異常。但是我們有一個處理錯誤的回撥函式(也就是 reject 回撥),所以是不是米有問題呢?

大錯特錯。 reject 回撥根本不會被呼叫到!如果你執行上述例子,你不會得到任何的輸出。萬籟此俱寂,沒有錯誤輸出,啥都米有。

為什麼會這樣呢?因為未經處理的異常在我們 then() 函式傳入的回撥中發生了,這在我們的例項中被 handle() 捕獲到。這導致 handle() 拒絕的 promise 是這個 then() 函式返回的那個 promise,而不是當前的我們準備進行錯誤處理的 promise ,而當前這個 promise 已經被 resolve 掉了。

如果你想捕獲上述的異常,你需要再鏈一個 then()

getSomeJson().then(function(json) {
  var obj = JSON.parse(json);
  console.log(obj);
}).then(null, function(error) {
  console.log("an error occured: ", error);
});

現在我們能正確的列印錯誤啦。

根據我這麼多年使用 promise 的經驗,錯誤吞噬這東西是 promise 最大的坑了(譯者注:果然是作者掉的那個坑=。=),請閱讀下一章節發現更好的解決方案—— done()

5.3. 救世者 done()

大多數的 promise 庫中都整合了 done() 方法。它與 then() 十分類似,但他避免了上述陷阱。

done() 函式可以和 then() 函式一樣被呼叫,其差異之處在於它不會返回一個 promise 物件,且在 done() 中未經處理的異常不會被 promise 例項所捕獲。換句話說,當整個 promise 鏈被完全解決時才會呼叫 done()。我們的 getSomeJson() 的例子可以使用 done() 來讓之變得更加健壯。

getSomeJson().done(function(json) {
  // when this throws, it won't be swallowed
  var obj = JSON.parse(json);
  console.log(obj);
});

done() 函式也和 then() 一樣有一個錯誤回撥, done(callback, errback),當整個 promise 鏈被執行完成後,你可以保證任何丟擲的異常都在錯誤回撥中被捕獲。

done() 目前為止還沒有加入 promise/A+ 規範,所以某些 promise 庫可能並不包含此功能。

譯者注:實際上這裡作者所敘述的 done() 和我們熟悉的 jQuery 裡面實現的 done() 並不相同。jQuery 用 Callback 物件實現的 done() 方法,只能傳遞一個成功回撥函式,且其返回的不是一個新的 promise 物件,而是當前的 promise 物件。)

6. Promise 解決程式需要非同步呼叫

在文章的開始我們使用 setTimeout 搞了一個毛招,當我們用“狀態”這一概念解決掉這個毛招之後,我們就再未曾再見到過 setTimeout 了呢。但實際上 Promise/A+ 規範要求 promise 的解決程式必須是非同步的。為了符合這個小需求,我們只需簡單的將 handle() 方法中的大部分實現包裹在 setTimeout 中即可。

function handle(handler) {
  if(state === 'pending') {
    deferred = handler;
    return;
  }
  setTimeout(function() {
    // ... 一切如前
  }, 1);
}

以上就是我們所需要做的事情。事實上真正的 promise 庫不必非使用 setTimeout。如果 promise 庫是基於 NodeJS 的,那可能會用到 process.nextTick;如果基於前端瀏覽器可能會用到最新的 setImmediate 或是 setImmediate shim (因為迄今為止只有 IE 支援 setImmediate),或者可能是一個非同步的函式庫,如 Kris Kowal 的 asap(此人還寫了一個著名的 promise 庫 —— Q)。

6.1. 為何在規範裡要求非同步呼叫?

這是為了保證一致性和可靠的執行流程,例如下面這個例子:

var promise = doAnOperation();
invokeSomething();
promise.then(wrapItAllUp);
invokeSomethingElse();

這裡的執行流程會是怎樣的呢?根據函式命名我們猜測應該是這樣invokeSomething() -> invokeSomethingElse() -> wrapItAllUp。但這其實完全取決於你當前實現的 promise 的解決方式是同步的還是非同步的。如果 doAnOperation() 是非同步的,那其執行順序就和我們猜測的一樣;如果它是同步執行的,實際的執行順序就會是這樣:invokeSomething() -> wrapItAllUp -> invokeSomethingElse(),這可能就會出現問題。

為了處理這種情況, 即使非同步不是必須的,但promise 的解決程式也必須是非同步的。這減少了不必要的困擾,也讓使用者在使用過程中不必考率程式碼裡的非同步實現。

7. 總結

能讀到這裡你也是挺給力的……本文涵蓋了規範中所要求的 promise 的核心實現,但大多數的 promise 庫都提供了更多的功能,如 all()spread()race()denodeify() 等等。如果想了解 promise 的更多功能,我建議諸位看看 Bluebird 函式庫的 API

在我瞭解了 promise 的運作方式和可能的坑之後,我愛上了 promise =v=。她讓我們的程式碼變得非常整潔和優雅。當然這篇文章僅僅是個開始,對於 promise 而言,能夠討論的東西還有太多太多。

如果你喜歡這篇文章,你可以在我的 twitter 上關注我一下,當我有別的更新的時候你也能及時發現~

8. 推薦閱讀

相關文章