JS非同步程式設計的淺思

panda080發表於2018-03-18

最近使用egg寫一個node專案時,被它的非同步流控制震驚的淚流滿面。話不多說,先上程式碼體驗一下。

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的時代。

回撥函式

callback是我們最熟悉的方式了。很容易就能寫出一個熟悉又簡單非同步回撥

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

這個栗子的結果還是很容易讓人接受的:先列印出2,延遲1000ms之後,再列印出1。下面?這個栗子就讓人抓狂了,體現出非同步是如何的反人類!

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

你可能會覺得,定時0ms,就是沒有延遲,應該是先列印出1,接著列印出2。然而結果卻和第一個回撥栗子的結果是一樣,唯一區別就是,前者延遲1000ms之後列印1,後者延遲0ms之後列印1。

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

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遍,才能明白這坨程式碼到底是什麼意思。

?引用一下顏海鏡為回撥函式列舉了N大罪狀:

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

違反直覺:直覺就是順序執行(將來要發生的事,在當前的步驟完成之後),從上自然的看到下面。而回撥卻讓我們跳來跳去,跳著跳著,就不知道跳到哪去了~

錯誤追蹤:非同步的世界裡,可以丟掉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已經達到非同步程式設計的最高境界了。

簡單就是美。

參考:

1、《你不懂的JS:非同步與效能》

2、非同步程式設計那些事

3、Generator 函式的含義與用法

4、async 函式的含義和用法

相關文章