js學習之異步處理

學習js開發,不管是前端開發仍是node.js,都避免不了要接觸異步編程這個問題,就和其它大多數以多線程同步爲主的編程語言不一樣,js的主要設計是單線程異步模型。正由於js天生的不同凡響,才使得它擁有一種獨特的魅力,也給學習者帶來了不少探索的道路。本文就從js的最初設計開始,整理一下js異步編程的發展歷程。javascript

什麼是異步

在研究js異步以前,先弄清楚異步是什麼。異步是和同步相對的概念,同步,指的是一個調用發起後要等待結果返回,返回時候必須拿到返回結果。而異步的調用,發起以後直接返回,返回的時候尚未結果,也不用等待結果,而調用結果是產生結果後經過被調用者通知調用者來傳遞的。前端

舉個例子,A想找C,可是不知道C的電話號碼,可是他有B的電話號碼,因而A給B打電話詢問C的電話號碼,B須要查找才能知道C的電話號碼,以後會出現兩種場景看下面兩個場景:java

  • A不掛電話,等到B找到號碼以後直接告訴A
  • A掛電話,B找到後再給A打電話告訴A

能感覺到這兩種狀況是不一樣的吧,前一種就是同步,後一種就是異步。node

爲何是異步的

先來看js的誕生,JavaScript誕生於1995年,由Brendan Eich設計,最先是在Netscape公司的瀏覽器上實現,用來實如今瀏覽器中處理簡單的表單驗證等用戶交互。至於後來提交到ECMA,造成規範,種種歷史不是這篇文章的重點,提到這些就是想說一點,js的最初設計就是爲了瀏覽器的GUI交互。對於圖形化界面處理,引入多線程勢必會帶來各類各樣的同步問題,所以瀏覽器中的js被設計成單線程,仍是很容易理解的。可是單線程有一個問題:一旦這個惟一的線程被阻塞就沒辦法工做了--這確定是不行的。因爲異步編程能夠實現「非阻塞」的調用效果,引入異步編程天然就是瓜熟蒂落的事情了。git

如今,js的運行環境不限於瀏覽器,還有node.js,node.js設計的最初想法就是設計一個徹底由事件驅動,非阻塞式IO實現的服務器運行環境,由於網絡IO請求是一個很是大的性能瓶頸,前期使用其餘編程語言都失敗了,就是由於人們固有的同步編程思想,人們更傾向於使用同步設計的API。而js因爲最初設計就是全異步的,人們不會有不少不適應,加上V8高性能引擎的出現,才造就了node.js技術的產生。node.js擅長處理IO密集型業務,就得益於事件驅動,非阻塞IO的設計,而這一切都與異步編程密不可分。github

js異步原理

這是一張簡化的瀏覽器js執行流程圖,nodejs和它不太同樣,可是都有一個隊列編程

這個隊列就是異步隊列,它是處理異步事件的核心,整個js調用時候,同步任務和其餘編程語言同樣,在棧中調用,一旦趕上異步任務,不馬上執行,直接把它放到異步隊列裏面,這樣就造成了兩種不一樣的任務。因爲主線程中沒有阻塞,很快就完成,棧中任務邊空以後,就會有一個事件循環,把隊列裏面的任務一個一個取出來執行。只要主線程空閒,異步隊列有任務,事件循環就會從隊列中取出任務執行。api

說的比較簡單,js執行引擎設計比這複雜的多得多,可是在js的異步實現原理中,事件循環和異步隊列是核心的內容。promise

異步編程實現

異步編程的代碼實現,隨着時間的推移也在逐漸完善,不止是在js中,許多編程語言的使用者都在尋找一種優雅的異步編程代碼書寫方式,下面來看js中的曾出現的幾種重要的實現方式。瀏覽器

最經典的異步編程方式--callback

提起異步編程,不能不提的就是回調(callback)的方式了,回調方式是最傳統的異步編程解決方案。首先要知道回調能解決異步問題,可是不表明使用回調就是異步任務了。下面以最多見的網絡請求爲例來演示callback是如何處理異步任務的,首先來看一個錯誤的例子:

function getData(url) {
    const data = $.get(url);
    return data;
}

const data = getData('/api/data'); // 錯誤,data爲undefined

因爲函數getData內部須要執行網絡請求,沒法預知結果的返回時機,直接經過同步的方式返回結果是行不通的,正確的寫法是像下面這樣:

function getData(url, callback) {
    $.get(url, data => {
        if (data.status === 200) {
            callback(null, data);
        } else {
            callback(data);
        }
    });
}

getData('/api/data', (err, data) => {
    if (err) {
        console.log(err);
    } else {
        console.log(data);
    }
});

callback方式利用了函數式編程的特色,把要執行的函數做爲參數傳入,由被調用者控制執行時機,確保可以拿到正確的結果。這種方式初看可能會有點難懂,可是熟悉函數式編程其實很簡單,很好地解決了最基本的異步問題,早期異步編程只能經過這種方式。

然而這種方式會有一個致命的問題,在實際開發中,模型總不會這樣簡單,下面的場景是常有的事:

fun1(data => {
    // ...
    fun2(data, result => {
        // ...
        fun3(result, () => {
            // ...
        });
    });
});

整個隨着系統愈來愈複雜,整個回調函數的層次會逐漸加深,裏面再加上覆雜的邏輯,代碼編寫維護都將變得十分困難,可讀性幾乎沒有。這被稱爲毀掉地獄,一度困擾着開發者,甚至是曾經異步編程最爲人詬病的地方。

從地獄中走出來--promise

使用回調函數來編程很簡單,可是回調地獄實在是太可怕了,嵌套層級足夠深以後絕對是維護的噩夢,而promise的出現就是解決這一問題的。promise是按照規範實現的一個對象,ES6提供了原生的實現,早期的三方實現也有不少。在此不會去討論promise規範和實現原理,重點來看promise是如何解決異步編程的問題的。

Promise對象表明一個未完成、但預計未來會完成的操做,有三種狀態:

  • pending:初始值,不是fulfilled,也不是rejected
  • resolved(也叫fulfilled):表明操做成功
  • rejected:表明操做失敗

整個promise的狀態只支持兩種轉換:從pending轉變爲resolved,或從pending轉變爲rejected,一旦轉化發生就會保持這種狀態,不能夠再發生變化,狀態發生變化後會觸發then方法。這裏比較抽象,咱們直接來改造上面的例子:

function getData(url) {
    return new Promise((resolve, reject) =>{
        $.get(url, data => {
            if (data.status === 200) {
                reject(data);
            } else {
                resolve(data);
            }
        });
    });
}

getData('/api/data').then(data => {
    console.log(data);
}).catch(err => {
    console.log(err);
});

Promise是一個構造函數,它建立一個promise對象,接收一個回調函數做爲參數,而回調函數又接收兩個函數作參數,分別表明promise的兩種狀態轉化。resolve回調會使promise由pending轉變爲resolved,而reject 回調會使promise由pending轉變爲rejected。

當promise變爲resolved時候,then方法就會被觸發,在裏面能夠獲取到resolve的內容,then方法。而一旦promise變爲rejected,就會產生一個error。不管是resolve仍是reject,都會返回一個新的Promise實例,返回值將做爲參數傳入這個新Promise的resolve函數,這樣就能夠實現鏈式調用,對於錯誤的處理,系統提供了catch方法,錯誤會一直向後傳遞,老是能被下一個catch捕獲。用promise能夠有效地避免回調嵌套的問題,代碼會變成下面的樣子:

fun1().then(data => {
    // ...
    return fun2(data);
}).then(result => {
    // ...
    return fun3(result);
}).then(() => {
    // ...
});

整個調用過程變的很清晰,可維護性可擴展性都會大大加強,promise是一種很是重要的異步編程方式,它改變了以往的思惟方式,也是後面新方式產生的重要基礎。

轉換思惟--generator

promise的寫法是最好的嗎,鏈式調用相比回調函數而言倒是可維護性增長了很多,可是和同步編程相比,異步看起來不是那麼和諧,而generator的出現帶來了另外一種思路。

generator是ES對協程的實現,協程指的是函數並非整個執行下去的,一個函數執行到一半能夠移交執行權,等到能夠的時候再得到執行權,這種方式最大的特色就是同步的思惟,除了控制執行的yield命令以外,總體看起來和同步編程感受幾乎同樣,下面來看一下這種方式的寫法:

function getDataPromise(url) {
    return new Promise((resolve, reject) =>{
        $.get(url, data => {
            if (data.status === 200) {
                reject(data);
            } else {
                resolve(data);
            }
        });
    });
}

function *getDataGen(url) {
    yield getDataPromise(url);
}

const g = getDataGen('/api/data');
g.next();

generator與普通函數的區別就是前面多一個*,不過這不是重點,重點是generator裏面可使用yield關鍵字來表示暫停,它接收一個promise對象,返回promise的結果而且停在此處等待,不是一次性執行完。generator執行後會返回一個iterator,iterator裏面有一個next方法,每次調用next方法,generator都會向下執行,直到趕上yield,返回結果是一個對象,裏面有一個value屬性,值爲當前yield返回結果,done屬性表明整個generator是否執行完畢。generator的出現使得像同步同樣編寫異步代碼成爲可能,下面是使用generator改造後的結果:

* fun() {
    const data = yield fun1();
    // ...
    const result = yield fun2(data);
    // ...
    yield fun3(result);
    // ...
}

const g = fun();
g.next();
g.next();
g.next();
g.next();

在generator的編寫過程當中,咱們還須要手動控制執行過程,而實際上這是能夠自動實現的,接下來的一種新語法的產生使得異步編程真的和同步同樣容易了。

新時代的寫法--async,await

異步編程的最高境界,就是根本不用關心它是否是異步。在最新的ES中,終於有了這種激動人心的語法了。async函數的寫法和generator幾乎相同,把*換成async關鍵字,把yield換成await便可。async函數內部自帶generator執行器,咱們再也不須要手動控制執行了,如今來看最終的寫法:

function getDataPromise(url) {
    return new Promise((resolve, reject) =>{
        $.get(url, data => {
            if (data.status === 200) {
                reject(data);
            } else {
                resolve(data);
            }
        });
    });
}

async function getData(url) {
    return await getDataPromise(url);
}

const data = await getData(url);

除了多了關鍵字,剩下的和同步的編碼方式徹底相同,對於異常捕獲也能夠採起同步的try-catch方式,對於再複雜的場景也不會邏輯混亂了:

* fun() {
    const data = await fun1();
    // ...
    const result = await fun2(data);
    // ...
    return await fun3(result);
    // ...
}
fun()

如今回去看回調函數的寫法,感受好像換了一個世界。這種語法比較新,在不支持的環境要使用babel轉譯。

寫在最後

在js中,異步編程是一個長久的話題,很慶幸如今有這麼好用的async和await,不過promise原理,回調函數都是要懂的,很重要的內容,弄清楚異步編程模式,算是掃清了學習js尤爲是node.js路上最大的障礙了。


尊重原創,轉載分享前請先知悉做者,也歡迎指出錯誤不足共同交流,更多內容歡迎關注做者博客點擊這裏

相關文章
相關標籤/搜索