JavaScript異步編程

#前言 從我們一開始學習JavaScript的時候就聽到過一段話:JS是單線程的,天生異步,適合IO密集型,不適合CPU密集型。但是,多數JavaScript開發者從來沒有認真思考過自己程序中的異步到底是怎麼出現的,以及爲什麼會出現,也沒有探索過處理異步的其他方法。到目前爲止,還有很多人堅持認爲回調函數就完全夠用了。

但是,隨着JavaScript面臨的需求越來越多,它可以運行在瀏覽器、服務器、甚至是嵌入式設備上,爲了滿足這些需求,JavaScript的規模和複雜性也在持續增長,使用回調函數來管理異步也越來越讓人痛苦,這一切,都需要更強大、更合理的異步方法,通過這篇文章,我想對目前已有JavaScript異步的處理方式做一個總結,同時試着去解釋爲什麼會出現這些技術,讓大家對JavaScript異步編程有一個更宏觀的理解,讓知識變得更體系化一些。

本文也會同步到我的個人網站。

#正文

Step1 - 回調函數

回調函數大家肯定都不陌生,從我們寫一段最簡單的定時器開始:

setTimeout(function () {
    console.log('Time out');
}, 1000);
複製代碼

定時器裏面的匿名函數就是一個回調函數,因爲在JS中函數是一等公民,所以它可以像其他變量一樣作爲參數進行傳遞。這樣看來,通過回調函數來處理異步挺好的,寫着也順手,爲什麼要用別的方法呢?

我們來看這樣一個需求:

上面是微信小程序的登錄時序圖,我們的需求和它類似但又有些差別,想要獲取一段業務數據,整個過程分爲3步:

  1. 調用祕鑰接口,獲取key
  2. 攜帶key調用登錄接口,獲取token和userId
  3. 攜帶token和userId調用業務接口,獲取數據

可能上述步驟和實際業務中的有些出入,但是卻可以用來說明問題,請大家諒解。

我們寫一段代碼來實現上述需求:

let key, token, userId;

$.ajax({
    type: 'get',
    url: 'http://localhost:3000/apiKey',
    success: function (data) {
        key = data;
        
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                token = data.token;
                userId = data.userId;
                
                $.ajax({
                    type: 'get',
                    url: 'http://localhost:3000/getData',
                    data: {
                        token: token,
                        userId: userId
                    },
                    success: function (data) {
                        console.log('業務數據:', data);
                    },
                    error: function (err) {
                        console.log(err);
                    }
                });
            },
            error: function (err) {
                console.log(err);
            }
        });
    },
    error: function (err) {
        console.log(err);
    }
});
複製代碼

可以看到,整段代碼充滿了回調嵌套,代碼不僅在縱向擴展,橫向也在擴展。我相信,對於任何人來說,調試起來都會很困難,我們不得不從一個函數跳到下一個,再跳到下一個,在整個代碼中跳來跳去以查看流程,而最終的結果藏在整段代碼的中間位置。真實的JavaScript程序代碼可能要混亂的多,使得這種追蹤難度會成倍增加。這就是我們常說的回調地獄(Callback Hell)

爲什麼會出現這種現象?

如果某個業務,依賴於上層業務的數據,上層業務又依賴於更上一層的數據,我們還採用回調的方式來處理異步的話,就會出現回調地獄

大腦對於事情的計劃方式是線性的、阻塞的、單線程的語義,但是回調錶達異步流程的方式是非線性的、非順序的,這使得正確推導這樣的代碼的難度很大,很容易產生Bug。

這裏我們引出了回調函數解決異步的第1個問題:回調地獄

回調函數還會存在別的問題嗎? 讓我們再深入思考一下回調的概念:

// A
$.ajax({
    ...
    success: function (...) {
        // C
    }
});
// B
複製代碼

A和B發生於現在,在JavaScript主程序的直接控制之下,而C會延遲到將來發生,並且是在第三方的控制下,在本例中就是函數$.ajax(...)。從根本上來說,這種控制的轉移通常不會給程序帶來很多問題。

但是,請不要被這個小概率迷惑而認爲這種控制切換不是什麼大問題。實際上,這是回調驅動設計最嚴重(也是最微妙)的問題。它以這樣一個思路爲中心:有時候ajax(...),也就是你交付回調函數的第三方不是你編寫的代碼,也不在你的直接控制之下,它是某個第三方提供的工具。

這種情況稱爲控制反轉,也就是把自己程序一部分的執行控制交給某個第三方,在你的代碼和第三方工具直接有一份並沒有明確表達的契約。

既然是無法控制的第三方在執行你的回調函數,那麼就有可能存在以下問題,當然通常情況下是不會發生的:

  1. 調用回調過早
  2. 調用回調過晚
  3. 調用回調次數太多或者太少
  4. 未能把所需的參數成功傳給你的回調函數
  5. 吞掉可能出現的錯誤或異常
  6. ......

這種控制反轉會導致信任鏈的完全斷裂,如果你沒有採取行動來解決這些控制反轉導致的信任問題,那麼你的代碼已經有了隱藏的Bug,儘管我們大多數人都沒有這樣做。

這裏,我們引出了回調函數處理異步的第二個問題:控制反轉

綜上,回調函數處理異步流程存在2個問題:

1. 缺乏順序性: 回調地獄導致的調試困難,和大腦的思維方式不符 2. 缺乏可信任性: 控制反轉導致的一系列信任問題

那麼如何來解決這兩個問題,先驅者們開始了探索之路......

Step2 - Promise

開門見山,Promise解決的是回調函數處理異步的第2個問題:控制反轉

至於Promise是什麼,大家肯定都有所瞭解,這裏是PromiseA+規範,ES6的Promise也好,jQuery的Promise也好,不同的庫有不同的實現,但是大家遵循的都是同一套規範,所以,Promise並不指特定的某個實現,它是一種規範,是一套處理JavaScript異步的機制

我們把上面那個多層回調嵌套的例子用Promise的方式重構:

let getKeyPromise = function () {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/apiKey',
            success: function (data) {
               let key = data;
               resolve(key);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getTokenPromise = function (key) {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getDataPromise = function (data) {
    let token = data.token;
    let userId = data.userId;
    
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getData',
            data: {
                token: token,
                userId: userId
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

getKeyPromise()
    .then(function (key) {
        return getTokenPromise(key);
    })
    .then(function (data) {
        return getDataPromise(data);
    })
    .then(function (data) {
        console.log('業務數據:', data);
    })
    .catch(function (err) {
        console.log(err);
    }); 
複製代碼

可以看到,Promise在一定程度上其實改善了回調函數的書寫方式,最明顯的一點就是去除了橫向擴展,無論有再多的業務依賴,通過多個then(...)來獲取數據,讓代碼只在縱向進行擴展;另外一點就是邏輯性更明顯了,將異步業務提取成單個函數,整個流程可以看到是一步步向下執行的,依賴層級也很清晰,最後需要的數據是在整個代碼的最後一步獲得。

所以,Promise在一定程度上解決了回調函數的書寫結構問題,但回調函數依然在主流程上存在,只不過都放到了then(...)裏面,和我們大腦順序線性的思維邏輯還是有出入的。

這裏我想主要討論的是,Promise是如何解決控制反轉帶來的信任缺失問題。

首先明確一點,Promise可以保證以下情況,引用自JavaScript | MDN

  1. 在JavaScript事件隊列的當前運行完成之前,回調函數永遠不會被調用
  2. 通過 .then 形式添加的回調函數,甚至都在異步操作完成之後才被添加的函數,都會被調用
  3. 通過多次調用 .then,可以添加多個回調函數,它們會按照插入順序並且獨立運行

下面我們針對前面提過的回調函數處理異步導致的一系列信任問題來討論,如果是用Promise來處理,是否還會存在這些問題,當然前提是實現的Promise完全遵循**PromiseA+規範**。

調用過早

當使用回調函數的時候,我們無法保證或者不知道第三方對於回調函數的調用是何種形式的,如果它在某種情況下是立即完成以同步的方式來調用,那可能就會導致我們代碼中的邏輯錯誤。

但是,根據PromiseA+規範,Promise就不必擔心這種問題,因爲即使是立即完成的Promise(類似於new Promise(function (resolve, reject) {resolve(2);})),也無法被同步觀察到。

也就是說,對一個Promise調用then(...)的時候,即使這個Promise已經決議,提供給then(...)的回調也總會在JavaScript事件隊列的當前運行完成後,再被調用,即異步調用。

調用過晚

當Promise創建對象調用resolve(...)或reject(...)時,這個Promise通過then(...)註冊的回調函數就會在下一個異步時間點上被觸發。

並且,這個Promise上的多個通過then(...)註冊的回調都會在下一個異步時間點上被依次調用,這些回調中的任意一個都無法影響或延誤對其他回調的調用。

舉例如下:

p.then(function () {
    p.then(function () {
        console.log('C');
    });
    console.log('A');
})
.then(funtion () {
    console.log('B');
});

// 打印 A B C
複製代碼

通過這個例子可以看到,C無法打斷或搶佔B,所以Promise沒有調用過晚的現象,只要你註冊了then(...),就肯定會按順序依次調用,因爲這就是Promise的運作方式。

回調未調用

沒有任何東西(甚至JavaScript錯誤)能阻止Promise向你通知它的決議(如果它決議了的話)。如果你對一個Promise註冊了一個成功回調和拒絕回調,那麼Promise在決議的時候總會調用其中一個。

當然,如果你的回調函數本身包含JavaScript錯誤,那可能就會看不到你期望的結果,但實際上回調還是被調用了。

p.then(function (data) {
    console.log(data);
    foo.bar();       // 這裏沒有定義foo,所以這裏會報Type Error, foo is not defined
}, function (err) {

});
複製代碼

調用次數太多或者太少

根據PromiseA+規範,回調被調用的正確次數應該是1次。“太少”就是不調用,前面已經解釋過了。

“太多”的情況很容易解釋,Promise的定義方式使得它只能被決議一次。如果處於多種原因,Promise創建代碼試圖調用多次resolve(...)或reject(...),或者試圖兩者都調用,那麼這個Promise將只會接受第一次決議,並默默忽略任何後續調用。

由於Promise只能被決議一次,所以任何通過then(...)註冊的回調就只會被調用一次。

未能傳遞參數值

如果你沒有把任何值傳遞給resolve(...)或reject(...),那麼這個值就是undefined。但不管這個值是什麼,它都會被傳給所有註冊在then(...)中的回調函數。

如果使用多個參數調用resolve(...)或reject(...),那麼第一個參數之後的所有參數都會被忽略。如果要傳遞多個值,你就必須把它們封裝在單個值中進行傳遞,比如一個數組或對象。

吞掉可能出現的錯誤或異常

如果在Promise的創建過程中或在查看其決議結果的過程中的任何時間點上,出現了一個JavaScript異常錯誤,比如一個TypeError或ReferenceError,這個異常都會被捕捉,並且會使這個Promise被拒絕。

舉例如下:

var p = new Promise(function (resolve, reject) {
    foo.bar();    // foo未定義
    resolve(2);
});

p.then(function (data) {
    console.log(data);    // 永遠也不會到達這裏
}, function (err) {
    console.log(err);    // err將會是一個TypeError異常對象來自foo.bar()這一行
});
複製代碼

foo.bar()中發生的JavaScript異常導致了Promise的拒絕,你可以捕捉並對其作出響應。

不是所有的thenable都可以信任

到目前爲止,我們討論了使用Promise可以避免上述多種由控制反轉導致的信任問題。但是,你肯定也注意到了,Promise並沒有完全擺脫回調,它只是改變了傳遞迴調的位置。我們並不是把回調傳遞給foo(...)讓第三方去執行,而是從foo(...)得到某個東西(Promise對象),然後把回調傳遞給這個東西。

但是,爲什麼這就比單純使用回調更值得信任呢?如何能夠確定返回的這個東西實際上就是一個可信任的Promise呢?

Promise對於這個問題已經有了解決方案,ES6實現的Promise的解決方案就是Promise.resolve(...)

如果向Promise.resolve(...)傳遞一個非Promise,非thenable得立即值,就會得到一個用這個值填充的Promise。

舉例如下:

var p1 = new Promise(function (resolve, reject) {
    resolve(2);
});

var p2 = Promise.resolve(2);

// 這裏p1和p2的效果是一樣的
複製代碼

而如果向Promise.resolve(...)傳遞一個真正的Promise,就只會返回同一個Promise。

var p1 = Promise.resolve(2);
var p2 = Promise.resolve(p1);

p1 === p2;    // true
複製代碼

更重要的是,如果向Promise.resolve(...)傳遞了一個非Promise的thenable值,前者就會試圖展開這個值,而且展開過程中會持續到提取出一個具體的非類Promise的最終值。

舉例如下:

var p = {
    then: function (cb, errCb) {
        cb(2);
        errCb('haha');
    }
};

// 這可以工作,因爲函數是一等公民,可以當做參數進行傳遞
p.then(function (data) {
    console.log(data);    // 2
}, function (err) {
    console.log(err);    // haha
});
複製代碼

這個p是一個thenable,但不是一個真正的Promise,其行爲和Promise並不完全一致,它同時觸發了成功回調和拒絕回調,它是不可信任的。

儘管如此,我們還是都可以把這樣的p傳給Promise.resolve(...),然後就會得到期望中的規範化後的安全結果:

Promise.resolve(p)
    .then(function (data) {
        console.log(data);    // 2
    }, function (err) {
        console.log(err);    // 永遠不會到達這裏
    });
複製代碼

因爲前面討論過,一個Promise只接受一次決議,如果多次調用resolve(...)或reject(...),後面的會被自動忽略。

Promise.resolve(...)可以接受任何thenable,將其解封爲它的非thenable值。從Promise.resolve(...)得到的是一個真正的Promise,是一個可以信任的值。如果你傳入的已經是真正的Promise,那麼你得到的就是它本身,所以通過Promise.resolve(...)過濾來獲得可信任性完全沒有壞處。

綜上,我們明確了,使用Promise處理異步可以解決回調函數控制反轉帶來的一系列信任問題很好,我們又向前邁了一步

Step3 - 生成器Generator

在Step1中,我們確定了用回調錶達異步流程的兩個關鍵問題:

  1. 基於回調的異步不符合大腦對任務步驟的規範方式
  2. 由於控制反轉,回調並不是可信任的

在Step2中,我們詳細介紹了Promise是如何把回調的控制反轉又反轉過來,恢復了可信任性。

現在,我們把注意力轉移到一種順序、看似同步的異步流程控制表達風格,這就是ES6中的生成器(Gererator)

可迭代協議和迭代器協議

瞭解Generator之前,必須先了解ES6新增的兩個協議:可迭代協議迭代器協議

可迭代協議

可迭代協議運行JavaScript對象去定義或定製它們的迭代行爲,例如(定義)在一個for...of結構中什麼值可以被循環(得到)。以下內置類型都是內置的可迭代對象並且有默認的迭代行爲:

  1. Array
  2. Map
  3. Set
  4. String
  5. TypedArray
  6. 函數的Arguments對象
  7. NodeList對象

注意,Object不符合可迭代協議

爲了變成可迭代對象,一個對象必須實現@@iterator方法,意思是這個對象(或者它原型鏈prototype chain上的某個對象)必須有一個名字是Symbol.iterator的屬性:

屬性

[Symbol.iterator]

返回一個對象的無參函數,被返回對象符合迭代器協議

當一個對象需要被迭代的時候(比如開始用於一個for...of循環中),它的@@iterator方法被調用並且無參數,然後返回一個用於在迭代中獲得值的迭代器。

迭代器協議

迭代器協議定義了一種標準的方式來產生一個有限或無限序列的值。 當一個對象被認爲是一個迭代器時,它實現了一個next()的方法並且擁有以下含義:

屬性

next

返回一個對象的無參函數,被返回對象擁有兩個屬性: 1. done(boolean) - 如果迭代器已經經過了被迭代序列時爲true。這時value可能描述了該迭代器的返回值 - 如果迭代器可以產生序列中的下一個值,則爲false。這等效於連同done屬性也不指定。 2. value - 迭代器返回的任何JavaScript值。done爲true時可以忽略。

使用可迭代協議和迭代器協議的例子:

var str = 'hello';

// 可迭代協議使用for...of訪問
typeof str[Symbol.iterator];    // 'function'

for (var s of str) {
    console.log(s);    // 分別打印 'h'、'e'、'l'、'l'、'o'
}

// 迭代器協議next方法
var iterator = str[Symbol.iterator]();

iterator.next();    // {value: "h", done: false}
iterator.next();    // {value: "e", done: false}
iterator.next();    // {value: "l", done: false}
iterator.next();    // {value: "l", done: false}
iterator.next();    // {value: "o", done: false}
iterator.next();    // {value: undefined, done: true}
複製代碼

我們自己實現一個對象,讓其符合可迭代協議迭代器協議

var something = (function () {
    var nextVal;
    
    return {
        // 可迭代協議,供for...of消費
        [Symbol.iterator]: function () {
            return this;
        },
        
        // 迭代器協議,實現next()方法
        next: function () {
            if (nextVal === undefined) {
                nextVal = 1;
            } else {
                nextVal = (3 * nextVal) + 6;
            }
            
            return {value: nextVal, done: false};
        }
    };
})();

something.next().value;    // 1
something.next().value;    // 9
something.next().value;    // 33
something.next().value;    // 105
複製代碼

用Generator實現異步

如果我們用Generator改寫上面回調嵌套的例子會是什麼樣的呢?見代碼:

function getKey () {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/apiKey',
        success: function (data) {
            key = data;
            it.next(key);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getToken (key) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/getToken',
        data: {
            key: key
        },
        success: function (data) {
            loginData = data;
            it.next(loginData);
        }
        error: function (err) {
            console.log(err);
        }
    });
}

function getData (loginData) {
    $.ajax({
        type: 'get',
        url: 'http://localhost:3000/getData',
        data: {
            token: loginData.token,
            userId: loginData.userId
        },
        success: function (busiData) {
            it.next(busiData);
        }
        error: function (err) {
            console.log(err);
        }
    });
}



function *main () {
    let key = yield getKey();
    let LoginData = yield getToken(key);
    let busiData = yield getData(loginData);
    console.log('業務數據:', busiData);
}

// 生成迭代器實例
var it = main();

// 運行第一步
it.next();
console.log('不影響主線程執行');
複製代碼

我們注意*main()生成器內部的代碼,不看yield關鍵字的話,是完全符合大腦思維習慣的同步書寫形式,把異步的流程封裝到外面,在成功的回調函數裏面調用it.next(),將傳回的數據放到任務隊列裏進行排隊,當JavaScript主線程空閒的時候會從任務隊列裏依次取出回調任務執行。

如果我們一直佔用JavaScript主線程的話,是沒有時間去執行任務隊列中的任務:

// 運行第一步
it.next();

// 持續佔用JavaScript主線程
while(1) {};    // 這裏是拿不到異步數據的,因爲沒有機會去任務隊列裏取任務執行
複製代碼

綜上,生成器Generator解決了回調函數處理異步流程的第一個問題:不符合大腦順序、線性的思維方式。

Step4 - Async/Await

上面我們介紹了Promise和Generator,把這兩者結合起來,就是Async/Await。

Generator的缺點是還需要我們手動控制next()執行,使用Async/Await的時候,只要await後面跟着一個Promise,它會自動等到Promise決議以後的返回值,resolve(...)或者reject(...)都可以。

我們把最開始的例子用Async/Await的方式改寫:

let getKeyPromise = function () {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/apiKey',
            success: function (data) {
               let key = data;
               resolve(key);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getTokenPromise = function (key) {
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getToken',
            data: {
                key: key
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

let getDataPromise = function (data) {
    let token = data.token;
    let userId = data.userId;
    
    return new Promsie(function (resolve, reject) {
        $.ajax({
            type: 'get',
            url: 'http://localhost:3000/getData',
            data: {
                token: token,
                userId: userId
            },
            success: function (data) {
                resolve(data);         
            },
            error: function (err) {
                reject(err);
            }
        });
    });
};

async function main () {
    let key = await getKeyPromise();
    let loginData = await getTokenPromise(key);
    let busiData = await getDataPromise(loginData);
    
    console.log('業務數據:', busiData);
}

main();

console.log('不影響主線程執行');
複製代碼

可以看到,使用Async/Await,完全就是同步的書寫方式,邏輯和數據依賴都非常清楚,只需要把異步的東西用Promise封裝出去,然後使用await調用就可以了,也不需要像Generator一樣需要手動控制next()執行。

Async/Await是Generator和Promise的組合,完全解決了基於回調的異步流程存在的兩個問題,可能是現在最好的JavaScript處理異步的方式了。

總結

本文通過四個階段來講述JavaScript異步編程的發展歷程:

  1. 第一個階段 - 回調函數,但會導致兩個問題:
    • 缺乏順序性: 回調地獄導致的調試困難,和大腦的思維方式不符
    • 缺乏可信任性: 控制反轉導致的一系列信任問題
  2. 第二個階段 - Promise,Promise是基於PromiseA+規範的實現,它很好的解決了控制反轉導致的信任問題,將代碼執行的主動權重新拿了回來。
  3. 第三個階段 - 生成器函數Generator,使用Generator,可以讓我們用同步的方式來書寫代碼,解決了順序性的問題,但是需要手動去控制next(...),將回調成功返回的數據送回JavaScript主流程中。
  4. 第四個階段 - Async/Await,Async/Await結合了Promise和Generator,在await後面跟一個Promise,它會自動等待Promise的決議值,解決了Generator需要手動控制next(...)執行的問題,真正實現了用同步的方式書寫異步代碼

我們可以看到,每項技術的突破都是爲了解決現有技術存在的一些問題,它是循序漸進的,我們在學習的過程中,要真正去理解這項技術解決了哪些痛點,它爲什麼會存在,這樣會有益於我們構建體系化的知識,同時也會更好的去理解這門技術。

最後,希望大家可以通過這篇文章對JavaScript異步編程有一個更宏觀的體系化的瞭解,我們一起進步

參考:

  1. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Iteration_protocols#iterable
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章