摘要:從源碼看概念與實現是異步編程中的重要概念,它較好地解決了異步任務中回調嵌套的問題。這些概念中有趣的地方在于,標識狀態的變量如都是形容詞,用于傳入數據的接口如與都是動詞,而用于傳入回調函數的接口如及則在語義上用于修飾動詞的副詞。
從源碼看 Promise 概念與實現
Promise 是 JS 異步編程中的重要概念,它較好地解決了異步任務中回調嵌套的問題。在沒有引入新的語言機制的前提下,這是如何實現的呢?上手 Promise 時常見若干晦澀的 API 與概念,它們又為什么存在呢?源碼里隱藏著這些問題的答案。
下文會在介紹 Promise 概念的基礎上,以一步步代碼實現 Promise 的方式,解析 Promise 的實現機制。相應代碼參考來自 PromiseJS 博客 及 You don’t know JS 的若干章節。
Why Promise
(有使用 Promise 經驗的讀者可忽略本段)
基于 JS 函數一等公民的優良特性,JS 中最基礎的異步邏輯一般是以向異步 API 傳入一個函數的方式實現的,這個函數里包含了異步完成后的后續業務邏輯。與普通的函數參數不同的是,這類函數需在異步操作完成時才被調用,故而稱之為回調函數。以異步 Ajax 查詢為例,基于回調的代碼實現可能是這樣的:
ajax.get("xxx", data => { // 在回調函數里獲取到數據,執行后續邏輯 console.log(data) // ... })
從而,在需要多個異步操作依次執行時,就需要以回調嵌套的方式來實現,例如這樣:
ajax.get("xxx", dataA => { // 第一個請求完成后,依賴其獲取到的數據發送第二個請求 // 產生回調嵌套 ajax.get("yyy" + dataA, dataB => { console.log(dataB) // ... }) })
這樣一來,在處理越多的異步邏輯時,就需要越深的回調嵌套,這種編碼模式的問題主要有以下幾個:
代碼邏輯書寫順序與執行順序不一致,不利于閱讀與維護。
異步操作的順序變更時,需要大規模的代碼重構。
回調函數基本都是匿名函數,bug 追蹤困難。
回調函數是被第三方庫代碼(如上例中的 ajax )而非自己的業務代碼所調用的,造成了 IoC 控制反轉。
其中看似最無關緊要的控制反轉,實際上是純回調編碼模式的最大問題。 由于回調函數是被第三方庫調用的,因此回調中的代碼無法預期自己被執行時的環境 ,這可能導致:
回調被執行了多次
回調一次都沒有被執行
回調不是異步執行而是被同步執行
回調被過早或過晚執行
回調中的報錯被第三方庫吞掉
……
通過【防御性編程】的概念,上述問題其實都可以通過在回調函數內部進行各種檢查來逐一避免,但這毫無疑問地會嚴重影響代碼的可讀性與開發效率。這種異步編碼模式存在的諸多問題,也就是臭名昭著的【回調地獄】了。
Promise 較好地解決了這個問題。以上例中的異步 ajax 邏輯為例,基于 Promise 的模式是這樣的:
// 將 ajax 請求封裝為一個返回 Promise 的函數 function getData (){ return new Promise((resolve, reject) => { ajax.get("xxx", data => { resolve(data) }) }) } // 調用該函數并在 Promise 的 then 接口中獲取數據 getData().then(data => { console.log(data) })
看起來變得啰嗦了?但在上例中需要嵌套回調的情況,可以改寫成下面的形式:
function getDataA (){ return new Promise((resolve, reject) => { ajax.get("xxx", dataA => { resolve(dataA) }) }) } function getDataB (dataA){ return new Promise((resolve, reject) => { ajax.get("yyy" + dataA, dataB => { resolve(dataB) }) }) } // 使用鏈式調用解開回調嵌套 getDataA() .then(dataA => getDataB(dataA)) .then(dataB => console.log(dataB))
這就解決了異步邏輯的回調嵌套問題。那么問題來了,這樣優雅的 API 是如何實現的呢?
基礎概念
非常籠統地說,Promise 其實應驗了 CS 的名言【所有問題都可以通過加一層中間層來解決】。在上面回調嵌套的問題中,Promise 就充當了一個中間層,用來【把回調造成的控制反轉再反轉回去】。在使用 Promise 的例子中,控制流分為了兩個部分:觸發異步前的邏輯通過 new傳入 Promise,而異步操作完成后的邏輯則傳入 Promise 的 then 接口中。通過這種方式,第一方業務和第三方庫的相應邏輯都由 Promise 來調用,進而在 Promise 中解決異步編程中可能出現的各種問題。
這種模式其實和觀察者模式是接近的。下面的代碼將 resolve / then 換成了 publish / subscribe ,將通過 new Promise 生成的 Promise 換成了通過 observe 生成的 observable 實例。可以發現,這種調用同樣做到了回調嵌套的解耦。這就是 Promise 魔法的關鍵之一。
// observe 相當于 new Promise // publish 相當于 resolve let observable = observe(publish => { ajax.get("xxx", data => { // ... publish(data) }) }) // subscribe 相當于 then observable.subscribe(data => { console.log(data) // ... })
到這個例子為止,都還沒有涉及 Promise 的源碼實現。在進一步深入前,有必要列出在 Promise 中常見的相關概念:
resolve / reject : 作為 Promise 暴露給第三方庫的 API 接口,在異步操作完成時由第三方庫調用,從而改變 Promise 的狀態。
fulfilled / rejected / pending : 標識了一個 Promise 當前的狀態。
then / done : 作為 Promise 暴露給第一方代碼的接口,在此傳入【原本直接傳給第三方庫】的回調函數。
這些概念中有趣的地方在于,標識狀態的變量(如 fulfilled / rejected / pending )都是形容詞,用于傳入數據的接口(如 resolve 與 reject )都是動詞,而用于傳入回調函數的接口(如 then 及 done )則在語義上用于修飾動詞的副詞。在閱讀源碼的時候,除了變量的類型外,其名稱所對應的詞性也能對理解代碼邏輯起到幫助,例如:
標識數據的變量與 OO 對象常用名詞( result / data / Promise )
標識狀態的變量常用形容詞( fulfilled / pending )
被調用的函數接口常用動詞( resolve / reject )
用于傳入函數的參數接口常用副詞(如 then / onFulfilled 等,畢竟函數常用動詞,而副詞本來就是用來修飾動詞的)
預熱了 Promise 相關的變量名后,就可以開始實現 Promise 了。下文的行文方式既不是按行號逐行介紹,也不是按代碼執行順序來回跳躍,而是按照實際編碼時的步驟一步步地搭建出相應的功能。相信這種方式比直接在源碼里堆注釋能更為友好一些。
狀態機
一個 Promise 可以理解為一個狀態機,相應的 API 接口要么用于改變狀態機的狀態,要么在到達某個狀態時被觸發。因此首先需要實現的,是 Promise 的狀態信息:
const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 var handlers = [] }
狀態遷移
指定狀態機的狀態后,可以實現基本的狀態遷移功能,即 fulfill 與 reject 這兩個用于改變狀態的函數,相應實現也十分簡單:
const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED value = result } function reject (error){ state = REJECTED value = error } }
在這兩種底層的狀態遷移基礎上,我們需要實現一種更高級的狀態遷移方式,這就是 resolve了:
const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED value = result } function reject (error){ state = REJECTED value = error } function resolve (result){ try { let then = getThen(result) if (then) { // 遞歸 resolve 待解析的 Promise doResolve(then.bind(result), resolve, reject) return } fulfill(result) } catch (e) { reject(e) } } }
resolve 既可以接受一個 Promise,也可以接受一個基本類型。當 resolve 一個 Promise 時,就使用 doResolve 輔助函數來執行這個 Promise 并等待其完成。通過暴露 resolve 而隱藏底層的 fulfill 接口,從而保證了一個 Promise 一定不會被另一個 Promise 所 fulfill 。在這個過程中所用到的輔助函數如下:
/** * 檢查一個值是否為 Promise * 若為 Promise 則返回該 Promise 的 then 方法 * * @param {Promise|Any} value * @return {Function|Null} */ function getThen (value){ let t = typeof value if (value && (t === "object" || t === "function")) { const then = value.then // 可能需要更復雜的 thenable 判斷 if (typeof then === "function") return then } return null } /** * 傳入一個需被 resolve 的函數,該函數可能存在不確定行為 * 確保 onFulfilled 與 onRejected 只會被調用一次 * 在此不保證該函數一定會被異步執行 * * @param {Function} fn 不能信任的回調函數 * @param {Function} onFulfilled * @param {Function} onRejected */ function doResolve (fn, onFulfilled, onRejected){ let done = false try { fn(function (value){ if (done) return done = true // 執行由 resolve 傳入的 resolve 回調 onFulfilled(value) }, function (reason){ if (done) return done = true onRejected(reason) }) } catch (ex) { if (done) return done = true onRejected(ex) } }
resolve 接口
在完整完成了內部狀態機的基礎上,還需要向用戶暴露用于傳入第一方代碼的 new Promise接口,及傳入異步操作回調的 done / then 接口。下面從 resolve 一個 Promise 開始:
const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (fn){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED value = result } function reject (error){ state = REJECTED value = error } function resolve (result){ try { let then = getThen(result) if (then) { // 遞歸 resolve 待解析的 Promise doResolve(then.bind(result), resolve, reject) return } fulfill(result) } catch (e) { reject(e) } } doResolve(fn, resolve, reject) }
可以發現這里重用了 doResolve 以執行不被信任的 fn 函數。這個 fn 函數可以多次調用 resolve 和 reject 接口,甚至拋出異常,但 Promise 中對其進行了限制,保證每個 Promise 只能被 resolve 一次,且在 resolve 后不再發生狀態轉移。
觀察者 done 接口
到此為止已經完成了一個完整的狀態機,但仍然沒有暴露出一個合適的方法來觀察其狀態的變更。我們的最終目標是實現 then 接口,但由于實現 done 接口的語義要容易得多,因此可首先實現 done 。
下面的例子中要實現的是 promise.done(onFulfilled, onRejected) 接口,使得:
onFulfilled 與 onRejected 二者只有一個被調用。
該接口只會被調用一次。
該接口總是被異步執行。
調用 done 的執行時機與調用時 Promise 是否已 resolved 無關。
const PENDING = 0 const FULFILLED = 1 const REJECTED = 2 function Promise (fn){ // 存儲該 Promise 的狀態信息 let state = PENDING // 存儲 FULFILLED 或 REJECTED 時帶來的數據 let value = null // 存儲 then 或 done 時調用的成功或失敗回調 let handlers = [] function fulfill (result){ state = FULFILLED handlers.forEach(handle) handlers = null } function reject (error){ state = REJECTED value = error handlers.forEach(handle) handlers = null } function resolve (result){ try { let then = getThen(result) if (then) { // 遞歸 resolve 待解析的 Promise doResolve(then.bind(result), resolve, reject) return } fulfill(result) } catch (e) { reject(e) } } // 保證 done 中回調的執行 function handle (handler){ if (state === PENDING) { handlers.push(handler) } else { if (state === FULFILLED && typeof handler.onFulfilled === "function") { handler.onFulfilled(value) } if (state === REJECTED && typeof handler.onRejected === "function") { handler.onRejected(value) } } } this.done = function (onFulfilled, onRejected){ // 保證 done 總是異步執行 setTimeout(function (){ handle({ onFulfilled: onFulfilled, onRejected: onRejected }) }, 0) } doResolve(fn, resolve, reject) }
從而在 Promise 的狀態遷移至 resolved 或 rejected 時,所有通過 done 注冊的觀察者 handler 都能被執行。并且這個操作總是在下一個 tick 異步執行的。
觀察者 then 方法
在實現了 done 方法的基礎上,就可以實現 then 方法了。它們沒有本質的區別,但 then 能夠返回一個新的 Promise:
this.then = function (onFulfilled, onRejected){ const _this = this return new Promise(function (resolve, reject){ return _this.done(function (result){ if (typeof onFulfilled === "function") { try { return resolve(onFulfilled(result)) } catch (ex) { return reject(ex) } } else return resolve(result) }, function (error){ if (typeof onRejected === "function") { try { return resolve(onRejected(error)) } catch (ex) { return reject(ex) } } else return reject(error) }) }) }
最后梳理一下典型場景下 Promise 的執行流程。以一個 ajax 請求的異步場景為例,整個異步邏輯分為兩部分:調用 ajax 庫的代碼及異步操作完成時的代碼。前者被放入 Promise 的構造函數中,由 doResolve 方法執行,在這部分業務邏輯通過調用 resolve 與 reject 接口,在異步操作完成時改變 Promise 的狀態,從而調用后者,即調用 Promise 中通過 then 接口傳入的 onFulfilled 與 onRejected 后續業務邏輯代碼。這個過程中, doResolve 對第三方 ajax 庫的各種異常行為(多次調用回調或拋出異常)做了限制,而 then 下隱藏的 done 則封裝了 handle 接口,保證了多個通過 then 傳入的 handler 總是異步執行,并能得到合適的返回結果。由于then 中的代碼總是異步執行并返回了一個新的 Promise,因此可以通過鏈式調用的方式來串聯多個 then 方法,從而實現異步操作的鏈式調用。
總結
閱讀了 Promise 的代碼實現后可以發現,它的魔法來自于將【函數一等公民】和【遞歸】的結合。一個 resolve 如果獲得的結果還是一個 Promise,那么就將遞歸地繼續 resolve 這個 Promise。同時,Promise 的輔助函數中解決了諸多異步編程時的常見問題,如回調的多次調用及異常處理等。
介紹 Promise 時不少較為晦澀的 API 其實也來自于對 Promise 編碼實現時的涉及的若干底層功能。例如, fulfilled 這個概念就被封裝在了 resolve 下,而 done 方法則是 then 方法的依賴等。這些概念在 Promise 的演化中被封裝在了通用的 API 下,只有在閱讀源碼時才會用到。Promise 的 API 設計也是簡潔的,其接口命名和英語的詞性也有相當大的聯系,這也有利于理解代碼實現的相應功能。
除了上文中從狀態機的角度理解 Promise 以外,其實還可以從函數式編程的角度來理解這個模式。可以將 Promise 看做一個封裝了異步數據的 Monad,其 then 接口就相當于這個 Monad 的map 方法。這樣一來,Promise 也可以理解為一個特殊的對象,這個對象【通過一個函數獲取數據,并通過另一個函數來操作數據】,用戶并不需要關心其中潛在的異步風險,只需要提供相應的函數給 Promise API 即可(這展開又是一篇長文了)。
希望本文對 Promise 的分析對理解異步編程有所幫助
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/107940.html
摘要:從最開始的到封裝后的都在試圖解決異步編程過程中的問題。為了讓編程更美好,我們就需要引入來降低異步編程的復雜性。寫一個符合規范并可配合使用的寫一個符合規范并可配合使用的理解的工作原理采用回調函數來處理異步編程。 JavaScript怎么使用循環代替(異步)遞歸 問題描述 在開發過程中,遇到一個需求:在系統初始化時通過http獲取一個第三方服務器端的列表,第三方服務器提供了一個接口,可通過...
摘要:從最開始的到封裝后的都在試圖解決異步編程過程中的問題。為了讓編程更美好,我們就需要引入來降低異步編程的復雜性。異步編程入門的全稱是前端經典面試題從輸入到頁面加載發生了什么這是一篇開發的科普類文章,涉及到優化等多個方面。 TypeScript 入門教程 從 JavaScript 程序員的角度總結思考,循序漸進的理解 TypeScript。 網絡基礎知識之 HTTP 協議 詳細介紹 HTT...
摘要:如果看完本文后,還對進程線程傻傻分不清,不清楚瀏覽器多進程瀏覽器內核多線程單線程運行機制的區別。因此準備梳理這塊知識點,結合已有的認知,基于網上的大量參考資料,從瀏覽器多進程到單線程,將引擎的運行機制系統的梳理一遍。 前言 見解有限,如有描述不當之處,請幫忙及時指出,如有錯誤,會及時修正。 ----------超長文+多圖預警,需要花費不少時間。---------- 如果看完本文后,還...
摘要:我想這很好的解釋了中,僅僅一個都這么復雜,在單線程或者說串行的程序中,編程往往是很簡單的,說白了就是調用,調用,調用然后返回。 Netty源碼分析(三) 前提概要 這次停更很久了,原因是中途迷茫了一段時間,不過最近調整過來了。不過有點要說下,前幾天和業內某個大佬聊天,收獲很多,所以這篇博文和之前也會不太一樣,我們會先從如果是我自己去實現這個功能需要怎么做開始,然后去看netty源碼,與...
摘要:感謝大神的免費的計算機編程類中文書籍收錄并推薦地址,以后在倉庫里更新地址,聲音版全文狼叔如何正確的學習簡介現在,越來越多的科技公司和開發者開始使用開發各種應用。 說明 2017-12-14 我發了一篇文章《沒用過Node.js,就別瞎逼逼》是因為有人在知乎上黑Node.js。那篇文章的反響還是相當不錯的,甚至連著名的hax賀老都很認同,下班時讀那篇文章,竟然坐車的還坐過站了。大家可以很...
閱讀 2676·2023-04-25 20:19
閱讀 1930·2021-11-24 09:38
閱讀 1632·2021-11-16 11:44
閱讀 4341·2021-09-02 15:40
閱讀 1317·2019-08-30 15:55
閱讀 2022·2019-08-30 15:52
閱讀 3759·2019-08-29 17:20
閱讀 2247·2019-08-29 13:48