你真的懂異步編程嗎?

為什么要學習異步編程?

在JS 代碼中,異步無處不在,Ajax通信,Node中的文件讀寫等等等,只有搞清楚異步編程的原理和概念,才能在JS的世界中任意馳騁,隨便撒歡;

單線程 JavaScript 異步方案

首先我們需要了解,JavaScript 代碼的運行是單線程,采用單線程模式工作的原因也很簡單,最早就是在頁面中實現 Dom 操作,如果采用多線程,就會造成復雜的線程同步問題,如果一個線程修改了某個元素,另一個線程又刪除了這個元素,瀏覽器渲染就會出現問題;
單線程的含義就是: JS執(zhí)行環(huán)境中負責執(zhí)行代碼的線程只有一個;就類似于只有一個人干活;一次只能做一個任務,有多個任務自然是要排隊的;
優(yōu)點:安全,簡單
缺點:遇到任務量大的操作,會阻塞,后面的任務會長時間等待,出現假死的情況;


image-20201224170055928.gif

為了解決阻塞的問題,Javascript 將任務的執(zhí)行模式分成了兩種,同步模式( Synchronous)和 異步模式( Asynchronous)
后面我們將分以下幾個內容,來詳細講解 JavaScript 的同步與異步:
1、同步模式與異步模式
2、事件循環(huán)與消息隊列
3、異步編程的幾種方式
4、Promise 異步方案、宏任務/微任務隊列
5、Generator 異步方案、 Async / Await語法糖

同步與異步

代碼依次執(zhí)行,后面的任務需要等待前面任務執(zhí)行結束后,才會執(zhí)行,同步并不是同時執(zhí)行,而是排隊執(zhí)行;
先來看一段代碼:

console.log('global begin')
function bar () {
  console.log('bar task')
}
function foo () {
  console.log('foo task')
  bar()
}
foo()
console.log('global end')

動畫形式展現 同步代碼 的執(zhí)行過程:

image-20201224190320238.gif

代碼會按照既定的語法規(guī)則,依次執(zhí)行,如果中間遇到大量復雜任務,后面的代碼則會阻塞等待;

再來看一段異步代碼:

console.log('global begin')

setTimeout(function timer1 () {
  console.log('timer1 invoke')
}, 1800)

setTimeout(function timer2 () {
  console.log('timer2 invoke')
  setTimeout(function inner () {
    console.log('inner invoke')
  }, 1000)
}, 1000)

console.log('global end')

異步代碼的執(zhí)行,要相對復雜一些:


image-20201224190320240.gif

代碼首先按照同步模式執(zhí)行,當遇到異步代碼時,會開啟異步執(zhí)行線程,在上面的代碼中,setTimeout 會開啟環(huán)境運行時的執(zhí)行線程運行相關代碼,代碼運行結束后,會將結果放入到消息隊列,等待 JS 線程結束后,消息隊列的任務再依次執(zhí)行;

流程圖如下:


clipboard.png
回調函數

通過上圖,我們會看到,在整個代碼的執(zhí)行中,JS 本身的執(zhí)行依然是單線程的,異步執(zhí)行的最終結果,依然需要回到 JS 線程上進行處理,在JS中,異步的結果 回到 JS 主線程 的方式采用的是 “ 回調函數 ” 的形式 , 所謂的 回調函數 就是在 JS 主線程上聲明一個函數,然后將函數作為參數傳入異步調用線程,當異步執(zhí)行結束后,調用這個函數,將結果以實參的形式傳入函數的調用(也有可能不傳參,但是函數調用一定會有),前面代碼中 setTimeout 就是一個異步方法,傳入的第一個參數就是 回調函數,這個函數的執(zhí)行就是消息隊列中的 “回調”;

下面我們自己封裝一個 ajax 請求,來進一步說明回調函數與異步的關系

Ajax 的異步請求封裝
function myAjax(url,callback) {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function () {
        if (this.readyState == 4) {
            if (this.status == 200) {
                // 成功的回調
                callback(null,this.responseText)
            } else {
                // 失敗的回調
                callback(new Error(),null);
            }
        }
    }
    xhr.open('get', url)
    xhr.send();
}

上面的代碼,封裝了一個 myAjax 的函數,用于發(fā)送異步的 ajax 請求,函數調用時,代碼實際是按照同步模式執(zhí)行的,當執(zhí)行到 xhr.send() 時,就會開啟異步的網絡請求,向指定的 url 地址發(fā)送網絡請求,從建立網絡鏈接到斷開網絡連接的整個過程是異步線程在執(zhí)行的;換個說法就是 myAjax 函數執(zhí)行到 xhr.send() 后,函數的調用執(zhí)行就已經結束了,如果 myAjax 函數調用的后面有代碼,則會繼續(xù)執(zhí)行,不會等待 ajax 的請求結果;
但是,myAjax 函數調用結束后,ajax 的網絡請求卻依然在進行著,如果想要獲取到 ajax 網絡請求的結果,我們就需要在結果返回后,調用一個 JS 線程的函數,將結果以實參的形式傳入:

myAjax('./d1.json',function(err,data){
    console.log(data);
})

回調函數讓我們輕松處理異步的結果,但是,如果代碼是異步執(zhí)行的,而邏輯是同步的; 就會出現 “回調地獄”,舉個栗子:
代碼B需要等待代碼A執(zhí)行結束才能執(zhí)行,而代碼C又需要等待代碼B,代碼D又需要等待代碼C,而代碼 A、B、C都是異步執(zhí)行的;

// 回調函數 回調地獄 
myAjax('./d1.json',function(err,data){
    console.log(data);
    if(!err){
        myAjax('./d2.json',function(err,data){
            console.log(data);
            if(!err){
                myAjax('./d3.json',function(){
                    console.log(data);
                })
            }
        })
    }
})

沒錯,代碼執(zhí)行是異步的,但是異步的結果,是需要有強前后順序的,著名的"回調地獄"就是這么誕生的;

相對來說,代碼邏輯是固定的,但是,這個編碼體驗,要差很多,尤其在后期維護的時候,層級嵌套太深,讓人頭皮發(fā)麻;
如何讓我們的代碼不在地獄中受苦呢?

有請 Promise 出山,拯救程序員的頭發(fā);

Promise
Snipaste_2020-11-20_14-00-99.gif

Promise 譯為 承諾、許諾、希望,意思就是異步任務交給我來做,一定(承諾、許諾)給你個結果;在執(zhí)行的過程中,Promise 的狀態(tài)會修改為 pending ,一旦有了結果,就會再次更改狀態(tài),異步執(zhí)行成功的狀態(tài)是 Fulfilled , 這就是承諾給你的結果,狀態(tài)修改后,會調用成功的回調函數 onFulfilled 來將異步結果返回;異步執(zhí)行成功的狀態(tài)是 Rejected, 這就是承諾給你的結果,然后調用 onRejected 說明失敗的原因(異常接管);

將前面對 ajax 函數的封裝,改為 Promise 的方式;

Promise 重構 Ajax 的異步請求封裝
function myAjax(url) {
    return new Promise(function (resolve, reject) {
        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function () {
            if (this.readyState == 4) {
                if (this.status == 200) {
                    // 成功的回調
                    resolve(this.responseText)
                } else {
                    // 失敗的回調
                    reject(new Error());
                }
            }
        }

        xhr.open('get', url)
        xhr.send();
    })
}

還是前面提到的邏輯,如果返回的結果中,又有 ajax 請求需要發(fā)送,可一定記得使用鏈式調用,不要在then中直接發(fā)起下一次請求,否則,又是地獄見了:

 //  ==== Promise 誤區(qū)====
myAjax('./d1.json').then(data=>{
    console.log(data);
    myAjax('./d2.json').then(data=>{
        console.log(data)
        // ……回調地獄……
    })
})

鏈式的意思就是在上一次 then 中,返回下一次調用的 Promise 對象,我們的代碼,就不會進地獄了;

myAjax('./d1.json')
    .then(data=>{
    console.log(data);
    return myAjax('./d2.json')
})
    .then(data=>{
    console.log(data)
    return myAjax('./d3.json')
})
    .then(data=>{
    console.log(data);
})
    .catch(err=>{
    console.log(err);
})

雖然我們脫離了回調地獄,但是 .then 的鏈式調用依然不太友好,頻繁的 .then 并不符合自然的運行邏輯,Promise 的寫法只是回調函數的改進,使用then方法以后,異步任務的兩段執(zhí)行看得更清楚了,除此以外,并無新意。Promise 的最大問題是代碼冗余,原來的任務被 Promise 包裝了一下,不管什么操作,一眼看去都是一堆 then,原來的語義變得很不清楚。于是,在 Promise 的基礎上,Async 函數來了;

終極異步解決方案,千呼萬喚的在 ES2017中發(fā)布了;

Async/Await 語法糖

Async 函數使用起來,也是很簡單,將調用異步的邏輯全部寫進一個函數中,函數前面使用 async 關鍵字,在函數中異步調用邏輯的前面使用 await ,異步調用會在 await 的地方等待結果,然后進入下一行代碼的執(zhí)行,這就保證了,代碼的后續(xù)邏輯,可以等待異步的 ajax 調用結果了,而代碼看起來的執(zhí)行邏輯,和同步代碼幾乎一樣;

async function callAjax(){
     var a = await myAjax('./d1.json')
     console.log(a);
     var b = await myAjax('./d2.json');
     console.log(b)
     var c = await myAjax('./d3.json');
     console.log(c)
 }
callAjax();

注意:await 關鍵詞 只能在 async 函數內部使用

因為使用簡單,很多人也不會探究其使用的原理,無非就是兩個 單詞,加到前面,用就好了,雖然會用,日常開發(fā)看起來也沒什么問題,但是一遇到 Bug 調試,就涼涼,面試的時候也總是知其然不知其所以然,咱們先來一個面試題試試,你看你能運行出正確的結果嗎?

async 面試題

請寫出以下代碼的運行結果:

setTimeout(function () {
    console.log('setTimeout')
}, 0)

async function async1() {
    console.log('async1 start')
    await async2();
    console.log('async1 end')
}

async function async2() {
    console.log('async2')
}

console.log('script start')

async1();

console.log('script end')

答案我放在最后面,你也可以自己寫出來運行一下;
想要把結果搞清楚,我們需要引入另一個內容:Generator 生成器函數;
Generator 生成器函數,返回 遍歷器對象,先看一段代碼:

Generator 基礎用法
function * foo(){
    console.log('test');
    // 暫停執(zhí)行并向外返回值 
    yield 'yyy'; // 調用 next 后,返回對象值
    console.log(33);
}

// 調用函數 不會立即執(zhí)行,返回 生成器對象
const generator =  foo();

// 調用 next 方法,才會 *開始* 執(zhí)行 
// 返回 包含 yield 內容的對象 
const yieldData = generator.next();

console.log(yieldData) //=> {value: "yyy", done: false}
// 對象中 done ,表示生成器是否已經執(zhí)行完畢
// 函數中的代碼并沒有執(zhí)行結束

// 下一次的 next 方法調用,會從前面函數的 yeild 后的代碼開始執(zhí)行
console.log(generator.next()); //=> {value: undefined, done: true}

你會發(fā)現,在函數聲明的地方,函數名前面多了 * 星號,函數體中的代碼有個 yield ,用于函數執(zhí)行的暫停;簡單點說就是,這個函數不是個普通函數,調用后不會立即執(zhí)行全部代碼,而是在執(zhí)行到 yield 的地方暫停函數的執(zhí)行,并給調用者返回一個遍歷器對象,yield 后面的數據,就是遍歷器對象的 value 屬性值,如果要繼續(xù)執(zhí)行后面的代碼,需要使用 遍歷器對象中的 next() 方法,代碼會從上一次暫停的地方繼續(xù)往下執(zhí)行;
是不是so easy ??;
同時,在調用next 的時候,還可以傳遞參數,函數中上一次停止的 yeild 就會接受到當前傳入的參數;

function * foo(){
    console.log('test');
    // 下次 next 調用傳參接受
    const res = yield 'yyy'; 
    console.log(res);
}

const generator =  foo();

// next 傳值 
const yieldData = generator.next();
console.log(yieldData) 

// 下次 next 調用傳參,可以在 yield 接受返回值
generator.next('test123');

Generator 的最大特點就是讓函數的運行,可以暫停,不要小看他,有了這個暫停,我們能做的事情就太多,在調用異步代碼時,就可以先 yield 停一下,停下來我們就可以等待異步的結果了;那么如何把 Generator 寫到異步中呢?

Generator 異步方案

將調用ajax的代碼寫到 生成器函數的 yield 后面,每次的異步執(zhí)行,都要在 yield 中暫停,調用的返回結果是一個 Promise 對象,我們可以從 迭代器對象的 value 屬性獲取到Promise 對象,然后使用 .then 進行鏈式調用處理異步結果,結果處理的代碼叫做 執(zhí)行器,就是具體負責運行邏輯的代碼;

function ajax(url) {
    ……
}

// 聲明一個生成器函數
function * fun(){
    yield myAjax('./d1.json')
    yield myAjax('./d2.json')
    yield myAjax('./d3.json')
}

// 返回 遍歷器對象 
var f = fun();
// 生成器函數的執(zhí)行器 
// 調用 next 方法,執(zhí)行異步代碼
var g = f.next();
g.value.then(data=>{
    console.log(data);
    // console.log(f.next());
    g = f.next();
    g.value.then(data=>{
        console.log(data)
        // g.......
    })
})

而執(zhí)行器的邏輯中,是相同嵌套的,因此可以寫成遞歸的方式對執(zhí)行器進行改造:

// 聲明一個生成器函數
function * fun(){
    yield myAjax('./d1.json')
    yield myAjax('./d2.json')
    yield myAjax('./d3.json')
}

// 返回 遍歷器對象 
var f = fun();
// 遞歸方式 封裝
// 生成器函數的執(zhí)行器
function handle(res){
    if(res.done) return;
    res.value.then(data=>{
        console.log(data)
        handle(f.next())
    })
}
handle(f.next());

然后,再將執(zhí)行的邏輯,進行封裝復用,形成獨立的函數模塊;

function co(fun) {
    // 返回 遍歷器對象 
    var f = fun();
    // 遞歸方式 封裝
    // 生成器函數的執(zhí)行器
    function handle(res) {
        if (res.done) return;
        res.value.then(data => {
            console.log(data)
            handle(f.next())
        })
    }
    handle(f.next());
}

co(fun);

封裝完成后,我們再使用時,只需要關注 Generator 中的 yield 部分就行了

function co(fun) {
    ……
}

function * fun(){
    yield myAjax('./d1.json')
    yield myAjax('./d2.json')
    yield myAjax('./d3.json')
}

此時你會發(fā)現,使用 Generator 封裝后,異步的調用就變的非常簡單了,但是,這個封裝還是有點麻煩,有大神幫我們做了這個封裝,相當強大:https://github.com/tj/co ,感興趣看一研究一下,而隨著 JS 語言的發(fā)展,更多的人希望類似 co 模塊的封裝,能夠寫進語言標準中,我們直接使用這個語法規(guī)則就行了;

其實你也可以對比一下,使用 co 模塊后的 Generator 和 async 這兩段代碼:

//  async / await 
async function callAjax(){
     var a = await myAjax('./d1.json')
     console.log(a);
     var b = await myAjax('./d2.json');
     console.log(b)
     var c = await myAjax('./d3.json');
     console.log(c)
 }
 
 // 使用 co 模塊后的 Generator
 function * fun(){
    yield myAjax('./d1.json')
    yield myAjax('./d2.json')
    yield myAjax('./d3.json')
}

你應該也發(fā)現了,async 函數就是 Generator 語法糖,不需要自己再去實現 co 執(zhí)行器函數或者安裝 co 模塊,寫法上將 * 星號 去掉換成放在函數前面的 async ,把函數體的 yield 去掉,換成 await; 完美……

 async function callAjax(){
     var a = await myAjax('./d1.json')
     console.log(a);
     var b = await myAjax('./d2.json');
     console.log(b)
     var c = await myAjax('./d3.json');
     console.log(c)
 }
callAjax();

我們再來看一下 Generator ,相信下面的代碼,你能很輕松的閱讀;

function * f1(){
    console.log(11)
    yield 2;
    console.log('333')
    yield 4;
    console.log('555')
}

var g = f1();
g.next();
console.log(666);
g.next();
console.log(777);

代碼運行結果:


image-20201230193712942.png

帶著 Generator 的思路,我們再回頭看看那個 async 的面試題;
請寫出以下代碼的運行結果:

setTimeout(function () {
    console.log('setTimeout')
}, 0)

async function async1() {
    console.log('async1 start')
    await async2();
    console.log('async1 end')
}

async function async2() {
    console.log('async2')
}

console.log('script start')

async1();

console.log('script end')

運行結果:


image-20201230193446596.png

是不是恍然大明白呢……

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容