非同步(一):Promise深入理解與例項分析

amandakelake發表於2019-02-10

基礎定義和API方面,這裡就不說了,請自行學習

前面的理論部分基於《你不知道的JS》中卷第二部分第三章,可以結合前人的一些部落格認真理解一下。
後面的程式碼例項非常有助於理解,並且我都做了註釋,有基礎的同學可以跳過理論部分直接參閱。

一、Promise本質

先直接在控制檯列印看一下它是什麼

console.dir(Promise)複製程式碼

非同步(一):Promise深入理解與例項分析

展開後可以看到 Promise構造器上定義了resolve和reject方法,then()方法定義在其原型上。

這就解釋了為什麼下面兩種寫法都可以了

Promise.resolve().then(() => {
    ...
}) 複製程式碼
let p = new Promise((resolve, reject) => {
    ...
    resolve(someValue)
})
p.then(() => {
    ...
})複製程式碼

二、從事件迴圈角度理解Promise

Promise 所說的非同步執行,只是將 Promise 建構函式中 resolve,reject 方法和註冊的 callback 轉化為 eventLoop的 microtask/Promise Job,並放到 Event Loop 佇列中等待執行,也就是 Javascript 單執行緒中的“非同步執行”

根據規範,microtask 存在的意義是:在當前 task 執行完,準備進行 I/O,repaint,redraw 等原生操作之前,需要執行一些低延遲的非同步操作,使得瀏覽器渲染和原生運算變得更加流暢。這裡的低延遲非同步操作就是 microtask。原生的 setTimeout 就算是將延遲設定為 0 也會有 4 ms 的延遲,會將一個完整的 task 放進佇列延遲執行,而且每個 task 之間會進行渲染等原生操作。假如每執行一個非同步操作都要重新生成一個 task,將提高宿主平臺的負擔和響應時間。所以,需要有一個概念,在進行下一個 task 之前,將當前 task 生成的低延遲的,與下一個 task 無關的非同步操作執行完,這就是 microtask。

new Promise((resolve) => {
  console.log(`a`)
  resolve(`b`)
  console.log(`c`)
}).then((data) => {
  console.log(data)
})

// a, c, b
複製程式碼

建構函式中的輸出執行是同步的,輸出 a, 執行 resolve 函式,將 Promise 物件狀態置為 resolved,輸出 c。

同時註冊這個 Promise 物件的回撥 then 函式。整個指令碼執行完,stack 清空。

event loop 檢查到 stack 為空,再檢查 microtask 佇列中是否有任務,發現了 Promise 物件的 then 回撥函式產生的 microtask,推入 stack,執行。輸出 b,event loop的列隊為空,stack 為空,指令碼執行完畢。

三、從thenable看Promise

識別 Promise(或者行為類似於 Promise 的東西)就是定義某種稱為 thenable 的東 西,將其定義為任何具有 then(..) 方法的物件和函式。

我們認為,任何這樣的值就是 Promise 一致的 thenable

根據一個值的形態(具有哪些屬性)對這個值的型別做出一些假定。這種型別檢查(type check)一般用術語鴨子型別(duck typing)來表示

function checkThenable(p) {
  if (p !== null && ( typeof p === `object` || typeof p === `function`) && typeof p.then === `function`) {
    // 假設這是一個thenable
    return true
  } else {
    // 不是thenable
    return false
  }
}
複製程式碼


1、then()接收兩個函式作為引數

第一個引數是Promise執行成功時的回撥,第二個引數是Promise執行失敗時的回撥。兩個函式只會有一個被呼叫,函式的返回值將被用作建立then返回的Promise物件。

  1. return 一個同步的值 ,或者 undefined(當沒有返回一個有效值時,預設返回undefined),then方法將返回一個resolved狀態的Promise物件,Promise物件的值就是這個返回值。 
  2. return 另一個 Promise,then方法將根據這個Promise的狀態和值建立一個新的Promise物件返回。 
  3. throw 一個同步異常,then方法將返回一個rejected狀態的Promise, 值是該異常。

太囉嗦了,總結一下then()方法的看家本領

  1. 返回另一個promise; 
  2. 返回一個同步值(或者undefined) 
  3. 丟擲一個同步錯誤。

2、Promise 例項化時傳入的函式會立即執行,then(…) 中的回撥需要非同步延遲呼叫

上面這句話請記住,對於理解下面的例子很有幫助

Promise/A+規範中解釋:實踐中要確保onFulfilled 和 onRejected 方法非同步執行,且應該在 then 方法被呼叫的那一輪事件迴圈之後的新執行棧中執行。這個事件佇列可以採用巨集任務 macro-task機制或微任務 micro-task機制來實現

四、Promise的非同步處理

Promise的兩個固有行為: 

  1. 每次對 Promise 呼叫 then(..),它都會建立並返回一個新的 Promise,我們可以將其連結起來; 
  2. 不管從 then(..) 呼叫的完成回撥(第一個引數)返回的值是什麼,它都會被自動設定為被連結 Promise(第一點中的)的完成。 

使 Promise 序列真正能夠在每一步有非同步能力的關鍵是:

Promise. resolve(..) 會直接返回接收到的真正 Promise,或展開接收到的 thenable 值,並在持續展 開 thenable 的同時遞迴地前進


嘗試去理解一下下面這段程式碼

let p = Promise.resolve(1);
p
  .then(v => {
    console.log(v);
    // 建立一個promise並返回
    return new Promise((resolve, reject) => {
      // 引入非同步,一樣正常工作
      setTimeout(() => {
        resolve(v * 2);
      }, 4);
    });
  })
  .then(v => {
    // 猜猜拿到了多少?
    console.log(v);
  });
複製程式碼

會發現:不管我們想要多少個非同步步 驟,每一步都能夠根據需要等待下一步(或者不等!)

五、Promise的錯誤處理

一個錯誤/異常是基於每個Promise的,意味著在鏈條的任意一點捕獲這些錯誤是可能的,而且這些捕獲操作在那一點上將鏈條“重置”,使它回到正常的操作上來

let p = new Promise((resolve, reject) => {
  reject(`error`)
});
let p2 = p.then(() => {
  // 永遠到達不了這裡
  console.log(`這句話不會出現`)
})
複製程式碼

再看一段程式碼

let p = Promise.resolve(1);
p.then((v) => {
  console.log(v * 2);
  foo();//這一步,underfined出錯
  // 再也到不了這裡了
  return Promise.resolve(3);
}).then((v) => {
  console.log(`到不了這裡`,v)
},(err) => {
  console.log(`錯誤來這了`,err);
  return 4
}).then((v) => {
  console.log(v)
})
複製程式碼

第 2 步出錯後,第 3 步的拒絕處理函式會捕捉到這個錯誤。拒絕處理函式的返回值(這段程式碼中是 3),如果有的話,會用來完成交給下一個步驟(第 4 步)的 promise,這樣,這個鏈現在就回到了完成狀態。

非同步(一):Promise深入理解與例項分析

注意這句話,解釋了為什麼最後會出現4,這裡要好好理解透徹

拒絕處理函式的返回值(這段程式碼中是 3),如果有的話,會用來完成交給下一個步驟(第 4 步)的 promise

總結起來,Promise的步驟

• 呼叫 Promise 的 then(..) 會自動建立一個新的 Promise 從呼叫返回。 

• 在完成或拒絕處理函式內部,如果返回一個值或丟擲一個異常,新返回的可連結的Promise 就相應地決議。 

• 如果完成或拒絕處理函式返回一個 Promise,它將會被展開,這樣一來,不管它的決議值是什麼,都會成為當前 then(..) 返回的連結 Promise 的決議值。

另外,記住這條結論,對於理解後面的例子有幫助

當使用then(resolveHandler, rejectHandler),rejectHandler不會捕獲在resolveHandler中丟擲的錯誤。

個人習慣是從不使用then方法的第二個引數,轉而使用catch()方法,但後面的例子是為了更清晰的講述promise,所以幾乎都用了第二個引數

六、Promise的穿透

下面這段程式碼先自己想一下,再去控制檯列印

Promise.resolve(1).then(Promise.resolve(2)).then((v) => {
  console.log(v)
})
複製程式碼
Promise.resolve(1).then(return Promise.resolve(2)).then((v) => {
  console.log(v) 
})
複製程式碼
Promise.resolve(1).then(null).then((v) => {
  console.log(v) 
})
複製程式碼
Promise.resolve(1).then(return 2).then((v) => {
  console.log(v) 
})
複製程式碼
Promise.resolve(1).then(() => {
  return 2
}).then((v) => {
  console.log(v)
})
複製程式碼

答案是

1;
Uncaught SyntaxError: Unexpected token return;
1
Uncaught SyntaxError: Unexpected token return;
2複製程式碼

為當then()受非函式的引數時,會解釋為then(null),這就導致前一個Promise的結果穿透到下面一個Promise。 

 所以要提醒自己:永遠給then()傳遞一個函式引數

七、Promise侷限性

1、順序錯誤處理

 Promise 鏈中的錯誤很容易被 無意中默默忽略掉 

2、單一值

 Promise 只能有一個完成值或一個拒絕理由

八、Promise效能

Promise 進行的動作要多一些,這自然意味著它也會稍慢一些
更多的工作,更多的保護,這些意味著 Promise 與不可信任的裸回撥相比會更慢一些 

Promise 使所有一切都成為非同步的了,即有一些立即(同步)完成的步驟仍然會延遲到任務的下一步。這意味著一個 Promise 任務序列可能比完全通過回撥連線的同樣的任務序列執行得稍慢一點
 

Promise 稍慢一些,但是作為交換,你得到的是大量內建的可信任性、對 Zalgo 的避免以及 可組合性

九、幾個不錯的例子

1、理解三種狀態

var p1 = new Promise(function(resolve,reject){
  resolve(1);
});
var p2 = new Promise(function(resolve,reject){
  setTimeout(function(){
    resolve(2);  
  }, 500);      
});
var p3 = new Promise(function(resolve,reject){
  setTimeout(function(){
    reject(3);  
  }, 500);      
});
// 直接返回1
console.log(p1);
// 由於加入了非同步,而且是事件迴圈中的巨集任務,所以暫時處於pending狀態,underfined
console.log(p2);
// 同理,pending狀態
console.log(p3);

// 直接加到下一個事件迴圈,暫時沒輸出,最後會輸出resolve 2
setTimeout(function(){
  console.log(p2);
}, 1000);
// 同理,在下一個事件迴圈,最後會輸出reject 3
setTimeout(function(){
  console.log(p3);
}, 1000);

// promise屬於事件迴圈中的微任務,所以要比上兩個setTimeout輸出的快,1
p1.then(function(value){
  console.log(value);
});
// 同理,2
p2.then(function(value){
  console.log(value);
});
// 這裡注意是catch,所以輸出3
p3.catch(function(err){
  console.log(err);
});
複製程式碼

非同步(一):Promise深入理解與例項分析

2、鏈式呼叫以及返回值

var p = new Promise(function(resolve, reject){
  resolve(1);
});
p.then(function(value){               //第一個then
  console.log(value); // 1
  return value*2;
}).then(function(value){              //第二個then
  console.log(value); // 2
}).then(function(value){              //第三個then
  console.log(value); // underfined
  return Promise.resolve(`resolve`); 
}).then(function(value){              //第四個then
  console.log(value); // `resolve`
  return Promise.reject(`reject`);
}).then(function(value){              //第五個then
  console.log(`resolve: `+ value); // 不到這裡,沒有值
}, function(err){
  console.log(`reject: ` + err);  // `reject`
})
複製程式碼

非同步(一):Promise深入理解與例項分析

上面說的兩條重要原則

  1. then()接收兩個函式作為引數
  2. 返回值有三種情況

可以翻上去上面看看

3、異常處理

let p1 = new Promise((resolve, reject) => {
  foo();
  resolve(1)
})
p1.then((v) => {
  console.log(`1不會到這裡`)
},(err) => {
  console.log(`p1的第一次錯誤來了這裡`,err)
}).then((v) => {
  console.log(`p1第二次,在這裡拿到了underfined`,v)
},(err) => {
  console.log(`第二次,沒有錯誤,這裡不會出現`,err)
})

let p2 = new Promise((resolve,reject) => {
  resolve(2);
})
p2.then((v) => {
  console.log(`p2第一次的值2來這裡了`,2);
  foo()
},(err) => {
  console.log(`p2這裡不會拿到第一次的錯誤`,err)
}).then((v) => {
  console.log(`p2上面第一次有錯誤,這裡不會有值`,v)
},(err) => {
  console.log(`這裡拿到了p2上一次的錯誤`,err);
  return `即使錯誤,也能繼續傳值`
}).then((v) => {
  console.log(`到這裡應該很清晰了吧`,v)
},(err) => {
  console.log(`這裡已經沒有錯誤了`,err)
})
複製程式碼

Promise中的異常由then引數中第二個回撥函式(Promise執行失敗的回撥)處理,異常資訊將作為Promise的值。異常一旦得到處理,then返回的後續Promise物件將恢復正常,並會被Promise執行成功的回撥函式處理。
 

需要注意p1、p2 多級then的回撥函式是交替執行的 ,這正是由Promise then回撥的非同步性決定的。

4、resolve與reject的區別

var p1 = new Promise(function(resolve, reject){
  resolve(Promise.resolve(`resolve`));
});
p1.then(
  function fulfilled(value){
    console.log(`fulfilled: ` + value);
  }, 
  function rejected(err){
    console.log(`rejected: ` + err);
  }
);
複製程式碼

這段毫無疑問,resolve直通車

var p2 = new Promise(function(resolve, reject){
  resolve(Promise.reject(`reject`));
});
p2.then(
  function fulfilled(value){
    console.log(`fulfilled: ` + value);
  }, 
  function rejected(err){
    console.log(`rejected: ` + err);
  }
);
複製程式碼

這段可能會有點疑問,主要在於理解這句程式碼

resolve(Promise.reject(`reject`))複製程式碼

再回想一下上面的錯誤處理以及thenable物件的展開功能,是不是就好理解一點了,其實可以理解為與運算(&&),有一個reject,傳下去的也會是reject 

但是!!!並不是一直鏈式的傳下去的全都是reject,只是緊跟著的下一個then會收到reject而已,萬望好好理解這句話(我這裡不展開講了)

var p3 = new Promise(function(resolve, reject){
  reject(Promise.resolve(`resolve`));
});
p3.then(
  function fulfilled(value){
    console.log(`fulfilled: ` + value);
  }, 
  function rejected(err){
    console.log(`rejected: ` + err);
  }
);
複製程式碼

有了第二段的基礎,這一段應該就非常好理解了

如果上述內容,看得不是很懂,建議多看幾遍(不一定看我這篇,看看前人的也好),正所謂讀書百遍其義自見


後話

感謝您耐心看到這裡,希望有所收穫!

如果不是很忙的話,麻煩點個star⭐【Github部落格傳送門】,舉手之勞,卻是對作者莫大的鼓勵。

我在學習過程中喜歡做記錄,分享的是自己在前端之路上的一些積累和思考,希望能跟大家一起交流與進步,更多文章請看【amandakelake的Github部落格】


參考資料

深入理解Promise執行原理

promises 很酷,但很多人並沒有理解就在用了

寫一個符合 Promises/A+ 規範並可配合 ES7 async/await 使用的 Promise

八段程式碼徹底掌握 Promise

相關文章