原生 Promises 是在 ES2015 對 JavaScript 做出最大的改變。它的出現消除了采用 callback 機制的很多潛在問題,並允許我們采用近乎同步的邏輯去寫異步代碼。
可以說 promises 和 generators ,代表了異步編程的新標准。不論你是否用它,你都得 必須 明白它們究竟是什么。
Promise 提供了相當簡單的 API ,但也增加了一點學習曲線。如果你以前從沒見過它們,你會覺得這個概念很奇特,然而讓你的大腦習慣它。你只需要一個平緩的介紹和大量的練習。
讀完這篇文章后,你將會得到:
By the end of this article, you’ll be able to:
- 清晰的知道 為什么 要有 promises,以及它解決了什么問題;
- 通過它們的 實現 和 使用 ,解釋 什么是 promises;
- 使用 promises 重寫常見的 callback 模式。
下面是我們學習 promises 的大綱路徑。
- 使用 Callbacks 的問題
- Promises: 通過異步來說明定義
- Promises & 不顛倒的管理
- 使用 Promises 的控制流
- 運用
then
,reject
, 和resolve
異步機制
如果你用過 JavaScript 的話,你可能知道它的基礎是 非阻塞 , or 異步 。但這究竟是什么意思?
同步 & 異步
同步代碼 將會在任何跟在它后面的代碼 之前 運行。你也可以吧阻塞作為同步的同義詞,因為它 阻塞 了程序接下來的執行,直到這部分代碼結束。
// readfile_sync.js "use strict"; //這個例子用的是 Node ,因此不能運行在瀏覽器中。 const filename = 'text.txt', fs = require('fs'); console.log('Reading file . . . '); // readFileSync 操作阻塞后面代碼的執行,直到它返回才能繼續運行。 // 程序將會等到這個操作結束后才會執行其它的操作。 const file = fs.readFileSync(`${__dirname}/${filename}`); //這段代碼只會在readFileSync返回結果后才執行 。。。 console.log('Done reading file.'); //而這段永遠打印的是 `file` 的內容。 console.log(`Contents: ${file.toString()}`);
異步代碼 則恰恰相反:它允許程序執行剩余的部分的同時處理一些耗時的操作,比如 I/O 或者網絡操作。異步又叫非阻塞代碼。下面是一段用異步實現上面功能的例子:
// readfile_async.js "use strict"; //例子用的是 Node ,因此不能運行在瀏覽器中。 const filename = 'text.txt', fs = require('fs'), getContents = function printContent (file) { try { return file.toString(); } catch (TypeError) { return file; } } console.log('Reading file . . . '); console.log("=".repeat(76)); // readFile 異步執行。 // 程序會繼續執行 LINE A 后面的代碼, // 與此同時 readFile 也會做自己該做到事情。接下來將深入討論 callback (回調) // 現在把注意力放在日志輸出的順序上。 let file; fs.readFile(`${__dirname}/${filename}`, function (err, contents) { file = contents; console.log( `Uh, actually, now I'm done. Contents are: ${ getContents(file) }`); }); // LINE A // 下面這些日志總會在文件讀取完成之前打印 // 好吧,這似乎有點誤導和糟糕。 console.log(`Done reading file. Contents are: ${getContents(file)}`); console.log("=".repeat(76));
同步代碼的主要優勢在於可讀性強,很好理解:同步程序會自頂向下逐行執行。
同步代碼的主要劣勢在於經常很慢。每次你的用戶點擊服務時總會讓瀏覽器卡頓兩秒是多么糟糕的用戶體驗啊。
這就是為什么 JavaScript 內核要采用非阻塞的原因
異步編程的挑戰
采用異步可以加快速度,但也給我們帶來麻煩。即使上面這段並沒有什么卵用的代碼也說明了這個問題,注意:
- 無法知道什么時候
file
是可用的,除非接管readFile
的控制,讓 它 在准備好時通知我們; - 而且我們的程序不會像它讀起來那樣執行,導致我們很難理解它。
說明這些問題的篇幅足夠占用我們這篇文章的剩余部分了。
回調(Callback) & 回退(Fallback)
接下來我們梳理一下異步 readFile
例子。
"use strict"; const filename = 'throwaway.txt', fs = require('fs'); let file, useless; useless = fs.readFile(`${__dirname}/${filename}`, function callback (error, contents) { file = contents; console.log( `Got it. Contents are: ${contents}`); console.log( `. . . But useless is still ${useless}.` ); }); console.log(`File is ${undefined}, but that'll change soon.`);
因為 readFile
是非阻塞的,它會立即返回讓程序繼續執行。 而 立即這點時間 對 I/O 操作來說遠遠不夠,它會返回 undefined
,我們可以在 readFile
結束之前盡可能的向后執行。。。當然了,文件還在讀。
問題是 我們怎么知道讀操作什么時候完成 ?
不幸的是,我們無法知道。但 readFile
可以。在上面的代碼片段中,我們給 readFile
傳遞了兩個參數:文件名,以及名為 callback 的函數,這個函數會在讀操作之后立即執行。
用自然語言描述就是:“ readFile
看看 ${__dirname}/${filename}
里都有些什么,別着急。等你讀完了把 contents
傳給 callback
運行,並讓我們知道是否有 error
”
需要解決的最重要的問題是我們不能知道什么時候讀完文件內容:只有 readFile
可以。這就是為什么我們要把它交給回調函數 callback,並相信它可以正確處理。
這就是異步函數通常的處理模式:通過多個參數調用,並傳遞一個回調函數來處理結果。
回調函數是 一個 解決方案,但它並不完美。兩個很大的問題是:
- 顛倒的控制;
- 糟糕的錯誤處理。
顛倒的控制
首先這是一個信任問題。
當我們給 readFile
傳遞回調函數時,我們相信它會調用這個回調函數的。但並沒有絕對的保證這件事。關於是否會調用,是否會傳遞正確的參數,是否是正確的順序,執行次數是否正確都沒有絕對的保證。
在現實中,這顯然不是致命的錯誤:我們已經寫了20多年的的回調函數也沒有搞壞互聯網。當然,在這種情況下,我們基本可以放心的把控制權交給 Node 內核代碼了。
但把你應用的關鍵任務表現交個第三方是很冒險的行為,在過去這是產生大量難以解決的 heisenbug 。
糟糕的錯誤處理
在同步代碼中我們用 try
/catch
/finally
處理錯誤。
"use strict"; //例子用的是 Node ,因此不能運行在瀏覽器中。 const filename = 'text.txt', fs = require('fs'); console.log('Reading file . . . '); let file; try { // Wrong filename. D'oh! file = fs.readFileSync(`${__dirname}/${filename + 'a'}`); console.log( `Got it. Contents are: '${file}'` ); } catch (err) { console.log( `There was a/n ${err}: file is ${file}` ); } console.log( 'Catching errors, like a bo$.' );
異步代碼會很有愛的把錯誤仍出窗外。
Async code lovingly tosses that out the window.
"use strict"; //例子用的是 Node ,因此不能運行在瀏覽器中。 const filename = 'throwaway.txt', fs = require('fs'); console.log('Reading file . . . '); let file; try { // Wrong filename. D'oh! fs.readFile(`${__dirname}/${filename + 'a'}`, function (err, contents) { file = contents; }); // 如果文件未定義這句不會執行 console.log( `Got it. Contents are: '${file}'` ); } catch (err) { // 這種情形中 catch 應該運行,但它並不會。 // 這是因為 readFile 把錯誤傳給回調函數了,而不是拋出錯誤。 console.log( `There was a/n ${err}: file is ${file}` ); }
運行過程並不是我們所預想的。這是因為 try
語句塊包裹的 readFile
, 總會成功返回 undefined
。也就意味着 try
總是 捕獲不到異常。
讓 readFile
通知你有錯誤的唯一方法就是把它傳遞給你的回調函數,在那里再自行處理。
"use strict"; // This example uses Node, and so won't run in the browser. const filename = 'throwaway.txt', fs = require('fs'); console.log('Reading file . . . '); fs.readFile(`${__dirname}/${filename + 'a'}`, function (err, contents) { if (err) { // catch console.log( `There was a/n ${err}.` ); } else { // try console.log( `Got it. File contents are: '${file}'`); } });
這個例子還湊合,但在大型程序中會增長出大量的錯誤信息並且很快會變得笨重不堪。
Promises 着重解決了這兩個問題,以及一些其它的問題,通過不那么顛倒的控制,以及“同步化”我們的異步代碼以便我們用更加熟悉的方式做錯誤處理。
Promises
想象一下你剛剛訂閱了 O’Reilly You Don’t Know JS 的目錄。為了換取你”血汗錢”,他們會在給你發一個承諾收據,然后你下周一會收到一堆新書。直至這之前你並不會收到這些新書。但你相信它們會發,因為它們承諾(promise)會發的。
這個 promise 已經足夠了,你可以計划每天騰出一些時間來讀它,答應給你朋友看,告訴你的老板你這周將要忙於讀書沒時間去他辦公室報告工作。你制定計划時並不需要這些書,你只需要知道將你會收到它們。
當然,O’Reilly 可能會在幾天后告訴你他們不能履行訂單,或者其它什么原因,這時你會取消你每天安排的讀書時間,告訴你朋友你無法收到圖書了,告訴你的老板你下周可以去給他匯報工作了。
promise 就像一個收據。它代表着還沒有准備好的值,但等它准備好了才可以用,換句話說它是一個 未來值 。你把 promises 當做你等待的值,並在寫代碼時假設它是可用的。
在這里有個個小問題,Promises 會立即處理打斷控制流,並允許你使用 catch
關鍵字處理錯誤。它和同步版本有些小小的不同,但不管怎么說在處理協調多個錯誤處理上要比回調機制更方便。
因為 promises 會在值准備好時把它交給你,由你來決定怎么用它。這修復了顛倒控制的問題:你可以直接處理你的應用邏輯,沒必要把控制權給第三方。
Promise 生命周期:關於狀態的簡單介紹
想象一下你用 Promises 實現 API 調用。
因為服務器不能即刻響應,Promises 不會立即包含最終值,當然也不能立即報告錯誤。這種狀態對 Promises 來說叫做 pending。這就相當於你在等你的新書的狀態。
一旦服務器響應了,將可能有兩種可能的輸出。
- Promise 獲得了它想要的值,這是 fulfilled 狀態。這就相當於你收到你書的訂單。
- 在事件中傳遞路徑的某個地方出了錯,這是 rejected 狀態。這相當於你收到你不能得到書的通知。
總之,在 Promise 有三種可能的狀態。一旦 Promise 處於 fulfilled 或者 rejected 狀態, 就再不能轉換為其它任何狀態。
現在術語介紹完了,現在看看我們怎么用它。
Promises 的基本方法
引用自Promises/A+ spec:
Promise 代表着異步操作的最終結果。與 promise 交互的最主要方式就是使用
then
方法,注冊回調函數可以接收 promises 的最終值,或者失敗原因。
這節將會詳細了解 Promises 的基本用法:
- 用構造器創建 Promises;
- 用
resolve
處理成功; - 用
reject
處理失敗; - 以及用
then
和catch
設置控制流。
在這個例子中,我們會用 Promises 優化上面的 fs.readFile
代碼。
創建 Promises
創建 Promise 的最基本方法就是直接使用構造器。
'use strict'; const fs = require('fs'); const text = new Promise(function (resolve, reject) { // Does nothing })
注意我們給 Promise 構造器傳遞了一個函數作為參數。在這里我們告訴 Promise 怎么 執行異步操作,得到我們想要的值之后做什么,以及如果發生錯誤怎么處理。細節:
resolve
參數是一個函數,包括我們收到期待值時做什么。當我們得到期待的值 (val
)時 用resolve(val)
調用resolve
。reject
參數也是一個函數,代表着我們接到錯誤之后怎么處理。如果接到錯誤 (err
),通過reject(err)
調用reject
。- 最后我們傳給 Promise 構造器的函數自己處理異步代碼。如果返回值和預期一樣,用接收到的值調用
resolve
;如果拋出異常,用錯誤調用reject
。
我們運行的例子是把 fs.readFile
包裹在 Promise 中。那么 resolve
和 reject
長什么樣呢?
- 事件成功時,我們用
console.log
打印內容。 - 事件錯誤時,也用
console.log
打印錯誤。
像下面這樣。
// constructor.js const resolve = console.log, reject = console.log;
接下來,我們需要完成給構造器傳遞的函數。記着,我們的任務是:
- 讀文件
- 當成功時
resolve
內容; - 否則,
reject
。
Thus:
// constructor.js const text = new Promise(function (resolve, reject) { // 普通的 fs.readFile 調用,但是在 Promise constructor 內部 . . . fs.readFile('text.txt', function (err, text) { // . . . 如果有錯誤調用 reject . . . if (err) reject(err); // . . . 否則調用 resolve 。 else // fs.readFile 返回的是 buffer ,我們需要 toString() 轉為 String。 resolve(text.toString()); }) })
到這,技術部分結束了:這段代碼代碼創建了一個 Promises 它會嚴格按照我們的意願執行。但如果你執行這段代碼,你會發現它既沒有打印結果也沒有打印錯誤。
她做出了承諾(Promise),然后(then) …
問題是我們寫了 resolve
和 reject
方法,但沒有傳遞給 Promise!接下來我們介紹設置 Promise 的流程控制: then
。
每個 Promise 都有個叫 then
的方法,它接受兩個函數做參數:resolve
和 reject
, 按照順序傳遞。 調用 Promise 的 then
並把這些函數傳給構造器,構造器將能夠調用這些傳入的函數。
// constructor.js const text = new Promise(function (resolve, reject) { fs.readFile('text.txt', function (err, text) { if (err) reject(err); else resolve(text.toString()); }) }) .then(resolve, reject);
這樣我們的 Promise 就可以讀文件並調用 resolve
方法。
一定要記得調用 then
返回的一定是一個 Promise 對象。這意味着你可以鏈式調用 then
方法,從而為異步操作創建復雜,類似同步那樣的控制流。再下一篇文章時我們會就這點更深入一些細節,下一個小節我們將會深入講解 catch
的例子。
捕獲異常的語法糖。
我們需要傳遞兩個函數給 then
: resolve
,用於事件成功時調用, reject
用於錯誤產生時調用。
Promises 還提供了類似 then
的函數, catch
。它接受一個 reject 作為處理器(handler)。
因為 then
總是返回一個 Promise,所以在上面的例子中,我們可以只給 then
傳遞一個 resolve 處理器(handler),然后鏈式調用 catch
並傳一個 reject 處理器(handler)。
const text = new Promise(function (resolve, reject) { fs.readFile('tex.txt', function (err, text) { if (err) reject(err); else resolve(text.toString()); }) }) .then(resolve) .catch(reject);
最后值得一提的是 catch(reject)
只是 then(undefined, reject)
形式的一個語法糖。因此也可以這樣寫:
const text = new Promise(function (resolve, reject) { fs.readFile('tex.txt', function (err, text) { if (err) reject(err); else resolve(text.toString()); }) }) .then(resolve) .then(undefined, reject);
… 但這樣可讀性就下降了好多。
結束語
Promises 在異步編程中不可缺少的編程工具。起初看起來挺嚇人,但這僅僅是因為你不熟悉而已:用過一段時間,你就會覺得它們像 if
/else
一樣自然了。
下一次,我們將會把回調模式的代碼轉換為用 Promises 實現,並學習一下 Q,一個很流行的 Promises 庫。