JS異步編程的淺思

最近使用egg寫一個node項目時,被它的異步流控制震驚的淚流滿面。話很少說,先上代碼體驗一下。javascript

async function pay() {
    try {
        let user = await getUserByDB();
        if (!user) {
            user = await createUserByDB();
        }
        let order = await getOrderByDB();
        if (!order) {
            order = await createOrderByDB();
        }
        const newOrder = await toPayByDB();
        return newOrder;
    } catch (error) {
        console.error(new Error('支付失敗'));
    }
}
pay().then(order => console.log(order));
複製代碼

以上代碼是付款的簡易流程,先找人,再找訂單,最後支付。其中找人、找訂單和支付都是異步邏輯。寫出這段代碼的時候,回憶把我帶到了callback的時代。html

回調函數

callback是咱們最熟悉的方式了。很容易就能寫出一個熟悉又簡單異步回調java

setTimeout(function () {
    console.log(1);
}, 1000);
console.log(2);
複製代碼

這個栗子的結果仍是很容易讓人接受的:先打印出2,延遲1000ms以後,再打印出1。下面👇這個栗子就讓人抓狂了,體現出異步是如何的反人類!node

setTimeout(function () {
    console.log(1);
}, 0);
console.log(2);
複製代碼

你可能會以爲,定時0ms,就是沒有延遲,應該是先打印出1,接着打印出2。然而結果卻和第一個回調栗子的結果是同樣,惟一區別就是,前者延遲1000ms以後打印1,後者延遲0ms以後打印1。jquery

開篇提到的支付栗子,用callback的方式實現以下git

function pay() {
    getUserByDB(function (err, user) {
        if (err) {
            console.error('出錯了');
            return false;
        }
        if (user) {
            getOrderByDB(function (err, order) {
                if (err) {
                    console.error('出錯了');
                    return false;
                }
                if (order) {
                    toPayByDB(function (err) {
                        if (err) {
                            console.error('出錯了');
                            return false;
                        }
                        console.log('支付成功');
                    });
                } else {
                    createOrderByDB(function (err, order) {
                        if (err) {
                            console.error('出錯了');
                            return false;
                        }
                        toPayByDB(function (err) {
                            if (err) {
                                console.error('出錯了');
                                return false;
                            }
                            console.log('支付成功');
                        });
                    });
                }
            });
        } else {
            createUserByDB(function (err, user) {
                if (err) {
                    console.error('出錯了');
                    return false;
                }
                getOrderByDB(function (err, order) {
                    if (err) {
                        console.error('出錯了');
                        return false;
                    }
                    if (order) {
                        toPayByDB(function (err) {
                            if (err) {
                                console.error('出錯了');
                                return false;
                            }
                            console.log('支付成功');
                        });
                    } else {
                        createOrderByDB(function (err, order) {
                            if (err) {
                                console.error('出錯了');
                                return false;
                            }
                            toPayByDB(function (err) {
                                if (err) {
                                    console.error('出錯了');
                                    return false;
                                }
                                console.log('支付成功');
                            });
                        });
                    }
                });
            });
        }
    });
}
pay();
複製代碼

沒看懂?沒看懂就對了😂。我寫的時候,都是懷揣着崩潰的心情,而且檢查了N遍。後期維護的時候,可能還要看N遍,才能明白這坨代碼究竟是什麼意思。github

👇引用一下顏海鏡爲回調函數列舉了N大罪狀:ajax

  • 違反直覺
  • 錯誤追蹤
  • 模擬同步
  • 併發執行
  • 信任問題

違反直覺:直覺就是順序執行(未來要發生的事,在當前的步驟完成以後),從上天然的看到下面。而回調卻讓咱們跳來跳去,跳着跳着,就不知道跳到哪去了~編程

錯誤追蹤:異步的世界裏,能夠丟掉try catch了。但異步的錯誤也要處理的啊,通常會有兩種方案,分離回調和first error。數組

jquery的ajax就是典型的分離回調

function success(data) {
    console.log(data);
};
function error(err) {
    console.error(err);
};
$.ajax({}, success, error);
複製代碼

Node採用的是first error,它的異步接口第一個參數都是error對象,這個參數的值若是爲null,就認爲沒有錯誤。

function callback(err, data) {
    if (err) {
        // 出錯
        return;
    }
    // 成功
    console.log(data);
}
async("url", callback);
複製代碼

回調地獄:我用回調的方式實現開篇的付款流程就已是回調地獄了

模擬同步:比較常見的就是在循環裏調用異步,這個坑曾經讓我懷疑過世界。

for(var i = 0; i < 10; i++) {
    (function (i) {
        setTimeout(function () {
            console.log(i);
        })
    })(i)
}
複製代碼

併發執行、信任問題:當把程序的一部分拿出來並把它執行的控制權移交給另外一個第三方時,這種狀況稱爲控制倒轉。這時候就存在了信任問題,只能僞裝第三方是可靠的,固然也不知道會不會被併發執行,被併發執行多少次。也就是說交給第三方執行咱們的回調後,須要默默的祈禱🙏...

// 第三方支付API
function weChatAPI(cb) {
    // weChatAPI作了某些咱們沒法掌控的事
    cb(null, 'success'); // 執行咱們傳來的回調
    // weChatAPI作了某些咱們沒法掌控的事
}

function toPay() {
    weChatAPI(function (err, data) {
        if (err) {
            console.log(err);
            return false;
        }
        console.log(data);
    });
}

toPay();
複製代碼

看到cb(),有股莫名的親切感。

既然回調如此的讓人頭疼和不安全,那麼有沒有方案去嘗試拯救回調呢?CommonJS工做組提出的Promise應運而生了,一出場就解決了回調的控制倒轉問題,讓咱們與第三方API合做的時候,再也不依靠祈禱了!

Promise

一開始遇到Promise的時候,我是拒絕的。看過不少Promise的博客、文章,基本都說Promise是能解決回調地獄的異步解決方案,內部具有三種狀態(pending,fulfilled,rejected)。也會舉一些小栗子

new Promise(function (resolve, reject) {
    doSomething(function (err, data) {
        if (err) {
            reject(err);
        }
        resolve(data);
    });
}).then(function (data) {
    console.log(data);
}, function (err) {
    console.error(err);
});
複製代碼

那時候的我見到這樣栗子,並無看出有什麼了不得的地方,以爲這仍是回調,並且增長了不少概念(原諒當年那個才疏學淺的我,雖然如今依舊才疏學淺)。

如今回過頭來,再看這段簡單的demo,有種驚爲天人的感受。

首先new一個Promise,將doSomething(..)包裝成Promise對象,並將結果交給後續的then方法處理。神奇的解決了回調的控制倒轉問題。

假設weChatAPI(..)返回的是一個Promoise對象,咱們就能夠在後面接上then(..)方法接收並處理它返給咱們的數據了,怎麼處理,何時處理,處理成什麼樣,處理幾回,都是咱們說的算。

weChatAPI(function (err, data) {
    // 徹底交給weChatAPI去執行
    if (err) {
        console.log(err);
        return false;
    }
    console.log(data);
});
    
weChatAPI().then(function (data) {
    // 咱們本身去執行並處理
    console.log(data);
}, function (err) {
    console.log(err);
})
    
複製代碼

後面還能夠繼續.then(..),以jQuery的鏈式風格,來處理多個異步邏輯,解決回調地獄的問題。

下面用Promise實現開篇的付款流程

// 這裏假設全部異步操做的返回都是符合Promise規範的。
// 實際場景中,好比mongoose是能夠配置的,異步回調也能夠本身去封裝
function pay() {
    return getUserByDB()
        .then(function (user) {
            if (user) return user;
            return createUserByDB();
        })
        .then(getOrderByDB)
        .then(function (order) {
            if (order) return order;
            return createOrderByDB();
        })
        .then(toPayByDB)
        .catch(function (err) {
            console.error(err);
        });
}
pay().then(function (order) {
    console.log('付款成功了');
});
複製代碼

如今看起來就很清晰了吧,並且與開篇的demo也比較相近了。當我將Promise運用到實際場景中後,就再也離不開他了,ajax所有包裝成Promise,項目裏處處充斥着Promise鏈,一條鏈橫跨好幾個文件。

隨着Promise的各類「濫用」,最終暴露出了它的缺陷——Promise的錯誤處理。《你不知道的JS》甚至用了絕望的深淵來形容這種缺陷。

默認狀況下,它會假定全部的錯誤都交給Promise處理,狀態會變成rejected。若是忘記去接收和處理錯誤,那錯誤就會在Promise鏈中默默地消失了——這時候絕望是必然的,甚至會懷疑人生。

爲了迴避這個缺陷,一些開發者宣稱Promise鏈的「最佳實踐」是,老是將你的鏈條以catch(..)終結,就像這樣:

var p = Promise.resolve( 42 );

p.then(function fulfilled(msg){
	// 數字沒有字符串方法,
	// 因此這裏拋出一個錯誤
	console.log( msg.toLowerCase() );
})
.catch( handleErrors );
複製代碼

由於咱們沒有給then(..)傳遞錯誤處理器,默認的處理器會頂替上來,它僅僅簡單地將錯誤傳播到鏈條的下一個promise中。如此,在p中發生的錯誤,與在p以後的解析中(好比msg.toLowerCase())發生的錯誤都將會過濾到最後的handleErrors(..)中。

彷佛問題解決了,一開始,我天真的覺得是的,嚴格按照這個規則去處理Promise鏈。

然而,catch(..)方法其實是基於then(..)實現的,一樣會返回一個Promise,它裏面發生的異常,一樣會被Promise捕獲到,並將狀態改成rejected。但若是沒有在catch(..)後面追加錯誤處理器,這個錯誤將會永遠的丟失了,變成了絕望的深淵。

幸運的是,瀏覽器和V8引擎能夠追蹤Promise對象,當它們進行垃圾回收的時候,若是檢測到Promise的狀態是rejected,就能夠拋出未捕獲的錯誤,將開發者從絕望的深淵中拯救出來,但卻沒有完全拉出這個深淵。由於瀏覽器拋出的錯誤棧,一點也不友好(實在無法看)。

Promise雖然有着一些缺陷,但只要謹慎運用,它仍是會給咱們帶來不少不可思議的好處的。

Promise雖然沒有完全擺脫回調,但它對回調進行了從新組織,解決了臭名昭著的回調地獄,同時也解決了肆虐在回調代碼中的控制倒轉問題。

Promise鏈還開始以順序的風格定義了一種更好的(固然,還不完美)表達異步流程的方式,它幫咱們的大腦更好的規劃和維護異步JS代碼。

Generator

在阮一峯的博客裏看到Generator 函數的含義與用法,雖然阮大神講的很淺顯易懂(如今的見解),但當時我是一臉懵逼。

重讀阮大神這篇文章,我注意到裏面用了很小篇幅介紹的一個概念——協程(coroutine),意思是多個線程互相協做,完成異步任務。理解了它的流程,我以爲也就理解了generator。

如下是協程的簡化流程。

第一步,協程A開始執行。

第二步,協程A執行到一半,進入暫停,執行權轉移到協程B。

第三步,(一段時間後)協程B交還執行權。

第四步,協程A恢復執行。

對於generator,關鍵字yield則負責第二步和第三步,暫停和轉移執行權。換句話說,將執行權交給協程B(協程B開始運行),並等待協程B交還執行權(協程B運行結束)。

與協程不一樣的是第四步。generator暫停,就是中止了,不會自動走第四步。由於協程B交還的執行權,被yield轉讓出去了,由外部去控制協程A是否繼續恢復執行。

仍是舉個例子吧

function B() {
    // 協程B能夠是字符串、同步函數、異步函數、對象、數組
    // 這裏用函數更能說明問題
    console.log('協程B拿到了執行權');
    return '協程B交還了執行權';
}

function * A() {
    console.log('協程A第一部分邏輯');
    let A2 = yield B();
    console.log('協程A第二部分邏輯');
    return A2;
}

let it = A();
// it 就是generator A返回的一個指針。或者A就是個倔強的駿馬,而it則是它的主人。
console.log(it.next()); // next是主人手裏的鞭子。這時候,鞭子抽了一下,駿馬開始跑起來了。
// 打印出:協程A第一部分邏輯。
// 打印出:協程B拿到了執行權。
// 打印出:{value: '協程B交還了執行權', done: false}
// 此時駿馬停住了,確實倔強。抽了一鞭子,就走了這麼點路
console.log(it.next()); // 因而又抽了一鞭子
// 打印出:協程A第二部分邏輯
// 打印出:{value: undefined, done: true}
// 看到done的值是true了,表示駿馬跑完了賽道。
複製代碼

慣例,用generator實現如下開篇的支付流程吧。

function * Pay() {
    // 這四個變量是爲了更好的說明這個過程
    // 其實只需user 和 order 兩個變量就能解決問題
    let oldUser = null;
    let newUser = null;
    let oldOrder = null;
    let newOrder = null;
    try {
        let oldUser = yield getUserByDB();
        if (!oldUser) {
            newUser = yield createUserByDB();
        }
        let oldOrder = yield getOrderByDB();
        if (!oldOrder) {
            newOrder = yield createOrderByDB();
        }
        const result = yield toPayByDB();
        return result;
    } catch (error) {
        console.error('支付失敗');
    }
}

const pay = Pay();
pay.next().value.then(function (user) { // 執行getUserByDB(),獲得user,並中止
    // user不存在,next()不傳值,則oldUser被賦值爲undefined,而後執行createUserByDB(),獲得user,並中止
    if (!user) return pay.next().value;
    return user; // 若是user存在,直接返回
}).then(function (user) {
    // 這個next(user)就有點複雜了。
    // 若是代碼在執行了getUserByDB()後中止的,則next(user)就是把user賦值給oldUser
    // 若是代碼在執行了createUserByDB()後中止的,則next(user)就是user賦值給newUser
    // 而後執行getOrderByDB(),獲得order,並中止
    return pay.next(user).value.then(function (order) {
        // order不存在,next()不傳值,則oldOrder被賦值爲undefined,而後執行createOrderByDB(),獲得order,並中止
        if (!order) return pay.next().value;
        return order; // 若是order存在,直接返回
    });
}).then(function (order) {
    // 這個next(order)一樣。
    // 若是代碼在執行了getOrderByDB()後中止的,則next(order)就是把order賦值給oldOrder
    // 若是代碼在執行了createOrderByDB()後中止的,則next(order)就是order賦值給newOrder
    // 而後執行toPayByDB(),並中止。
    return pay.next(order).value; // done的值爲false
}).then(function () {
    // next(),將undefined賦值給result,並返回result
    pay.next(); // 此時done的值爲true
});
複製代碼

不看下面的抽鞭子邏輯,只看*Pay(..)邏輯,是否是感受無限接近開篇的demo了,只是關鍵字不一樣而已。至於抽鞭子邏輯,我是瘋了。

跟純Promise實現的demo相比,雖然前面的邏輯更加接近順序執行,同時還能找回丟失已久的try catch來處理錯誤。可是後面的抽鞭子邏輯,恕我不敢苟同。

幸運是的tj大神出品的CO庫則幫咱們接過了鞭子,自動去抽打這匹倔強的駿馬。下面用CO庫實現上面的邏輯。

// Pay依然是上面的generator
co(Pay()).then(function () {
    console.log('支付完成了');
});
複製代碼

一下感受整個世界都清淨了很多,能夠愉快的享受generator帶給咱們的快感了。

雖然CO封裝的generator用起來感受很爽,但(看到這個字,我想到了辯證法,凡是都有兩面性)CO約定,yield後面只能跟 Thunk 函數或 Promise 對象。並且拋出的錯誤棧也極其的不友好,可參考egg團隊的分析

此時我依然不明白,yield爲何要把執行權轉讓出去。《你不知道的JS》中關於這個的解釋大體就是,爲了打破「運行至完成」這個常規行爲,但願外部能夠控制generator的內部運行。恕我才疏學淺,我更願意相信這是給async/await的出場作鋪墊。

async/await

async/await就像天然界遵循着進化論同樣,從最初的回調一步一步的演化而來,達到異步編程的最高境界,就是根本不用關心它是否是異步

async function demo() {
    try {
        const a = await 1;
        console.log(a); // 1
        const b = await [2];
        console.log(b); // [2]
        const c = await { c: 3 };
        console.log(c); // {c: 3}
        const d = await (function () {
            return 4;
        })();
        console.log(d); // 4
        const e = await Promise.resolve(5);
        console.log(e); // 5
        throw new Error(6);
        // 不執行
        console.log(7);
    } catch (error) {
        console.log(error); // 6
    }
}
demo();
複製代碼

篇首的例子加上面的例子,足可說明,async/await已經達到異步編程的最高境界了。

簡單就是美。

參考:

一、《你不懂的JS:異步與性能》

二、異步編程那些事

三、Generator 函數的含義與用法

四、async 函數的含義和用法

相關文章
相關標籤/搜索