你真的完全掌握了promise麼?

saku發表於2018-05-09

最近在整理js中非同步程式設計方法時,回顧了一下promise,又發現了一些遺漏的重要知識點,比如promise.resolve()傳遞不同引數的含義?比如當一個promise依賴另一個promise時事件執行順序?比如當catch捕獲到了錯誤後,會不會繼續執行後面的then方法?下文將對這些問題一一解答,並再次強調一些重要的知識點。

1.promise語法

Promise程式設計的核心思想是如果資料就緒(promised),那麼(then)做點什麼。

下文是一個promise例項。

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 非同步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});
複製程式碼

Promise建構函式接受一個函式作為引數,該函式的兩個引數分別是resolve和reject。

resolve函式的作用是,將Promise物件的狀態從“未完成”變為“成功”(即從 pending 變為resolved),在非同步操作成功時呼叫,並將非同步操作的結果,作為引數傳遞出去;

reject函式的作用是,將Promise物件的狀態從“未完成”變為“失敗”(即從 pending 變為rejected), 在非同步操作失敗時呼叫,並將非同步操作報出的錯誤,作為引數傳遞出去。

Promise例項生成以後,可以用then方法分別指定resolved狀態和rejected狀態的回撥函式。

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});
複製程式碼

then方法可以接受兩個回撥函式作為引數。

第一個回撥函式是Promise物件的狀態變為resolved時呼叫,第二個回撥函式是Promise物件的狀態變為rejected時呼叫。其中,第二個函式是可選的,不一定要提供。這兩個函式都接受Promise物件傳出的值作為引數。

下面是一個使用then的例子。then方法返回的是一個新的Promise例項。 因此可以採用鏈式寫法,即then方法後面再呼叫另一個then方法。

getJSON("/posts.json").then(function(json) {
  return json.post;
}).then(function(post) {
  // ...
});

複製程式碼

上面的程式碼使用then方法,依次指定了兩個回撥函式。第一個回撥函式完成以後,會將返回結果作為引數,傳入第二個回撥函式。

Promise.prototype.catch方法用於指定發生錯誤時的回撥函式。

getJSON('/posts.json').then(function(posts) {
  // ...
}).catch(function(error) {
  // 處理 getJSON 和 前一個回撥函式執行時發生的錯誤
  console.log('發生錯誤!', error);
});
複製程式碼

上面程式碼中,getJSON方法返回一個 Promise 物件;如果非同步操作丟擲錯誤,狀態就會變為rejected,就會呼叫catch方法指定的回撥函式,處理這個錯誤。另外,then方法指定的回撥函式,如果執行中丟擲錯誤,也會被catch方法捕獲。

一般總是建議,Promise 物件後面要跟catch方法,這樣可以處理 Promise 內部發生的錯誤。catch方法返回的還是一個 Promise 物件,因此後面還可以接著呼叫then方法。

注意:

1.如果呼叫resolve函式和reject函式時帶有引數,那麼它們的引數會被傳遞給回撥函式。resolve函式的引數除了正常的值以外,還可能是另一個 Promise 例項。

const p1 = new Promise(function (resolve, reject) {
  // ...
});

const p2 = new Promise(function (resolve, reject) {
  // ...
  resolve(p1);
})
複製程式碼

上面程式碼中,p1和p2都是 Promise 的例項,但是p2的resolve方法將p1作為引數,即一個非同步操作的結果是返回另一個非同步操作。

這時p1的狀態就會傳遞給p2,也就是說,p1的狀態決定了p2的狀態。如果p1的狀態是pending,那麼p2的回撥函式就會等待p1的狀態改變;如果p1的狀態已經是resolved或者rejected,那麼p2的回撥函式將會立刻執行。

2.呼叫resolve或reject並不會終結 Promise 的引數函式的執行。

3.then方法是定義在原型物件Promise.prototype上的。

4.如果沒有使用catch方法指定錯誤處理的回撥函式,Promise 物件丟擲的錯誤不會傳遞到外層程式碼,即不會有任何反應。

5.Promise 在resolve語句後面,再丟擲錯誤,不會被捕獲,等於沒有丟擲。因為 Promise的狀態一旦改變,就永久保持該狀態,不會再變了。

2. Promise.resolve()

有時需要將現有物件轉為 Promise 物件,Promise.resolve方法就起到這個作用。

Promise.resolve等價於下面的寫法。

Promise.resolve('foo')
// 等價於
new Promise(resolve => resolve('foo'))
複製程式碼

Promise.resolve方法的引數分成四種情況。

1)引數是一個 Promise 例項

如果引數是 Promise 例項,那麼Promise.resolve將不做任何修改、原封不動地返回這個例項。

2)引數是一個thenable物件

thenable物件指的是具有then方法的物件,比如下面這個物件。

let thenable = {
  then: function(resolve, reject) {
    resolve(42);
  }
};
複製程式碼

Promise.resolve方法會將這個物件轉為 Promise 物件,然後就立即執行thenable物件的then方法。

let thenable = {
  then: function(resolve, reject) {
    resolve(42);
  }
};

let p1 = Promise.resolve(thenable);
p1.then(function(value) {
  console.log(value);  // 42
});
複製程式碼

上面程式碼中,thenable物件的then方法執行後,物件p1的狀態就變為resolved,從而立即執行最後那個then方法指定的回撥函式,輸出 42。

3)引數不是具有then方法的物件,或根本就不是物件。

如果引數是一個原始值,或者是一個不具有then方法的物件,則Promise.resolve方法返回一個新的 Promise 物件,狀態為resolved。

const p = Promise.resolve('Hello');

p.then(function (s){
  console.log(s)
});
// Hello
複製程式碼

上面程式碼生成一個新的 Promise 物件的例項p。由於字串Hello不屬於非同步操作(判斷方法是字串物件不具有 then 方法),返回 Promise 例項的狀態從一生成就是resolved,所以回撥函式會立即執行。Promise.resolve方法的引數,會同時傳給回撥函式。

4)不帶有任何引數

Promise.resolve方法允許呼叫時不帶引數,直接返回一個resolved狀態的 Promise 物件。

所以,如果希望得到一個 Promise 物件,比較方便的方法就是直接呼叫Promise.resolve方法。

const p = Promise.resolve();

p.then(function () {
  // ...
});
複製程式碼

上面程式碼的變數p就是一個 Promise 物件。需要注意的是,立即resolve的 Promise 物件,是在本輪“事件迴圈”(event loop)的結束時,而不是在下一輪“事件迴圈”的開始時。

setTimeout(function () {
  console.log('three');
}, 0);

Promise.resolve().then(function () {
  console.log('two');
});

console.log('one');

// one
// two
// three
複製程式碼

上面程式碼中,setTimeout(fn, 0)在下一輪“事件迴圈”開始時執行,Promise. resolve()在本輪“事件迴圈”結束時執行,console.log('one')則是立即執行,因此最先輸出。

3.新手錯誤

1)用了 promises 後怎麼用 forEach?

// 我想刪除所有的docs
db.allDocs({include_docs: true}).then(function (result) {
  result.rows.forEach(function (row) {
    db.remove(row.doc);  
  });
}).then(function () {
  // 我天真的以為所有的docs都被刪除了!
});
複製程式碼

問題在於第一個函式實際上返回的是 undefined,這意味著第二個方法不會等待所有 documents 都執行 db.remove()。實際上他不會等待任何事情,並且可能會在任意數量的文件被刪除後執行!

簡而言之,forEach()/for/while 並非你尋找的解決方案。你需要的是 Promise.all():

db.allDocs({include_docs: true}).then(function (result) {
  return Promise.all(result.rows.map(function (row) {
    return db.remove(row.doc);
  }));
}).then(function (arrayOfResults) {
  // All docs have really been removed() now!
});
複製程式碼

上面的程式碼是什麼意思呢?大體來說,Promise.all()會以一個 promises 陣列為輸入,並且返回一個新的 promise。這個新的 promise 會在陣列中所有的 promises 都成功返回後才返回。他是非同步版的 for 迴圈。

並且 Promise.all() 會將執行結果組成的陣列返回到下一個函式,比如當你希望從 PouchDB 中獲取多個物件時,會非常有用。此外一個更加有用的特效是,一旦陣列中的 promise 任意一個返回錯誤,Promise.all() 也會返回錯誤。

2)忘記使用catch

單純的堅信自己的 promises 會永遠不出現異常,很多開發者會忘記在他們的程式碼中新增一個 .catch()。然而不幸的是這也意味著,任何被丟擲的異常都會被吃掉,並且你無法在 console 中觀察到他們。這類問題 debug 起來會非常痛苦。

3) 使用副作用呼叫而非返回

下面的程式碼有什麼問題?

somePromise().then(function () {
  someOtherPromise();
}).then(function () {
  // 我希望someOtherPromise() 狀態變成resolved!
  // 但是並沒有
});
複製程式碼

每一個 promise 都會提供給你一個 then() 函式 (或是 catch(),實際上只是 then(null, ...) 的語法糖)。當我們在 then() 函式內部時:

somePromise().then(function () {
  // I'm inside a then() function!
});
複製程式碼

我們可以做什麼呢?有三種事情:

  • return 另一個 promise
  • return 一個同步的值 (或者 undefined)
  • throw 一個同步異常

就是這樣。一旦你理解了這個技巧,你就理解了 promises。

因此讓我們逐個瞭解下。

返回另一個 promise

getUserByName('nolan').then(function (user) {
  return getUserAccountById(user.id);
}).then(function (userAccount) {
  // I got a user account!
});
複製程式碼

注意:我是 return 第二個 promise,這個 return 非常重要。如果我沒有寫 returngetUserAccountById() 就會成為一個副作用,並且下一個函式將會接收到 undefined 而非 userAccount

返回一個同步值 (或者 undefined)

返回 undefined 通常是錯誤的,但是返回一個同步值實際上是將同步程式碼包裹為 promise 風格程式碼的一種非常讚的手段。舉例來說,我們對 users 資訊有一個記憶體快取。我們可以這樣做:

getUserByName('nolan').then(function (user) {
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];    // returning a synchronous value!
  }
  return getUserAccountById(user.id); // returning a promise!
}).then(function (userAccount) {
  // I got a user account!
});

複製程式碼

第二個函式不需要關心 userAccount 是從同步方法還是非同步方法中獲取的,並且第一個函式可以非常自由的返回一個同步或者非同步值。

丟擲同步異常

比如我們希望在使用者已經登出時,丟擲一個同步異常。這會非常簡單:

getUserByName('nolan').then(function (user) {
  if (user.isLoggedOut()) {
    throw new Error('user logged out!'); // throwing a synchronous error!
  }
  if (inMemoryCache[user.id]) {
    return inMemoryCache[user.id];       // returning a synchronous value!
  }
  return getUserAccountById(user.id);    // returning a promise!
}).then(function (userAccount) {
  // I got a user account!
}).catch(function (err) {
  // Boo, I got an error!
});
複製程式碼

如果使用者已經登出,我們的 catch() 會接收到一個同步異常,並且如果 後續的 promise 中出現非同步異常,他也會接收到。再強調一次,這個函式並不需要關心這個異常是同步還是非同步返回的。

4.參考網址

http://fex.baidu.com/blog/2015/07/we-have-a-problem-with-promises/

http://es6.ruanyifeng.com/#docs/promise

原文見我的部落格

相關文章