你真的懂非同步程式設計嗎?

為什麼要學習非同步程式設計?

在JS 程式碼中,非同步無處不在,Ajax通訊,Node中的檔案讀寫等等等,只有搞清楚非同步程式設計的原理和概念,才能在JS的世界中任意馳騁,隨便撒歡;

單執行緒 JavaScript 非同步方案

首先我們需要了解,JavaScript 程式碼的執行是單執行緒,採用單執行緒模式工作的原因也很簡單,最早就是在頁面中實現 Dom 操作,如果採用多執行緒,就會造成複雜的執行緒同步問題,如果一個執行緒修改了某個元素,另一個執行緒又刪除了這個元素,瀏覽器渲染就會出現問題;

單執行緒的含義就是: JS執行環境中負責執行程式碼的執行緒只有一個;就類似於只有一個人幹活;一次只能做一個任務,有多個任務自然是要排隊的;

優點:安全,簡單

缺點:遇到任務量大的操作,會阻塞,後面的任務會長時間等待,出現假死的情況;

你真的懂非同步程式設計嗎?

為了解決阻塞的問題,Javascript 將任務的執行模式分成了兩種,同步模式( Synchronous)和 非同步模式( Asynchronous)

後面我們將分以下幾個內容,來詳細講解 JavaScript 的同步與非同步:

1、同步模式與非同步模式 2、事件迴圈與訊息佇列 3、非同步程式設計的幾種方式 4、Promise 非同步方案、宏任務/微任務佇列 5、Generator 非同步方案、 Async / Await語法糖

同步與非同步

程式碼依次執行,後面的任務需要等待前面任務執行結束後,才會執行,同步並不是同時執行,而是排隊執行;

先來看一段程式碼:

console。log(‘global begin’)function bar () {  console。log(‘bar task’)}function foo () {  console。log(‘foo task’)  bar()}foo()console。log(‘global end’)

動畫形式展現 同步程式碼 的執行過程:

你真的懂非同步程式設計嗎?

程式碼會按照既定的語法規則,依次執行,如果中間遇到大量複雜任務,後面的程式碼則會阻塞等待;

再來看一段非同步程式碼:

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’)

非同步程式碼的執行,要相對複雜一些:

你真的懂非同步程式設計嗎?

程式碼首先按照同步模式執行,當遇到非同步程式碼時,會開啟非同步執行執行緒,在上面的程式碼中,setTimeout 會開啟環境執行時的執行執行緒執行相關程式碼,程式碼執行結束後,會將結果放入到訊息佇列,等待 JS 執行緒結束後,訊息佇列的任務再依次執行;

流程圖如下:

你真的懂非同步程式設計嗎?

回撥函式

透過上圖,我們會看到,在整個程式碼的執行中,JS 本身的執行依然是單執行緒的,非同步執行的最終結果,依然需要回到 JS 執行緒上進行處理,在JS中,非同步的結果 回到 JS 主執行緒 的方式採用的是 “ 回撥函式 ” 的形式 , 所謂的 回撥函式 就是在 JS 主執行緒上宣告一個函式,然後將函式作為引數傳入非同步呼叫執行緒,當非同步執行結束後,呼叫這個函式,將結果以實參的形式傳入函式的呼叫(也有可能不傳參,但是函式呼叫一定會有),前面程式碼中 setTimeout 就是一個非同步方法,傳入的第一個引數就是 回撥函式,這個函式的執行就是訊息佇列中的 “回撥”;

下面我們自己封裝一個 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 的函式,用於傳送非同步的 ajax 請求,函式呼叫時,程式碼實際是按照同步模式執行的,當執行到 xhr。send() 時,就會開啟非同步的網路請求,向指定的 url 地址傳送網路請求,從建立網路連結到斷開網路連線的整個過程是非同步執行緒在執行的;換個說法就是 myAjax 函式執行到 xhr。send() 後,函式的呼叫執行就已經結束了,如果 myAjax 函式呼叫的後面有程式碼,則會繼續執行,不會等待 ajax 的請求結果;

但是,myAjax 函式呼叫結束後,ajax 的網路請求卻依然在進行著,如果想要獲取到 ajax 網路請求的結果,我們就需要在結果返回後,呼叫一個 JS 執行緒的函式,將結果以實參的形式傳入:

myAjax(‘。/d1。json’,function(err,data){ console。log(data);})

回撥函式讓我們輕鬆處理非同步的結果,但是,如果程式碼是非同步執行的,而邏輯是同步的; 就會出現 “回撥地獄”,舉個栗子:

程式碼B需要等待程式碼A執行結束才能執行,而程式碼C又需要等待程式碼B,程式碼D又需要等待程式碼C,而程式碼 A、B、C都是非同步執行的;

// 回撥函式 回撥地獄 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); }) } }) }})

沒錯,程式碼執行是非同步的,但是非同步的結果,是需要有強前後順序的,著名的“

回撥地獄

”就是這麼誕生的;

相對來說,程式碼邏輯是固定的,但是,這個編碼體驗,要差很多,尤其在後期維護的時候,層級巢狀太深,讓人頭皮發麻;

如何讓我們的程式碼不在地獄中受苦呢?

有請 Promise 出山,拯救程式設計師的頭髮;

Promise

你真的懂非同步程式設計嗎?

Promise 譯為 承諾、許諾、希望,意思就是非同步任務交給我來做,一定(承諾、許諾)給你個結果;在執行的過程中,Promise 的狀態會修改為 pending ,一旦有了結果,就會再次更改狀態,非同步執行成功的狀態是 Fulfilled , 這就是承諾給你的結果,狀態修改後,會呼叫成功的回撥函式 onFulfilled 來將非同步結果返回;非同步執行成功的狀態是 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 請求需要傳送,可一定記得使用鏈式呼叫,不要在then中直接發起下一次請求,否則,又是地獄見了:

// ==== Promise 誤區====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方法以後,非同步任務的兩段執行看得更清楚了,除此以外,並無新意。Promise 的最大問題是程式碼冗餘,原來的任務被 Promise 包裝了一下,不管什麼操作,一眼看去都是一堆 then,原來的語義變得很不清楚。於是,在 Promise 的基礎上,Async 函式來了;

終極非同步解決方案,千呼萬喚的在 ES2017中釋出了;

Async/Await 語法糖

Async 函式使用起來,也是很簡單,將呼叫非同步的邏輯全部寫進一個函式中,函式前面使用 async 關鍵字,在函式中非同步呼叫邏輯的前面使用 await ,非同步呼叫會在 await 的地方等待結果,然後進入下一行程式碼的執行,這就保證了,程式碼的後續邏輯,可以等待非同步的 ajax 呼叫結果了,而程式碼看起來的執行邏輯,和同步程式碼幾乎一樣;

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 函式內部使用

因為使用簡單,很多人也不會探究其使用的原理,無非就是兩個 單詞,加到前面,用就好了,雖然會用,日常開發看起來也沒什麼問題,但是一遇到 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’); // 暫停執行並向外返回值 yield ‘yyy’; // 呼叫 next 後,返回物件值 console。log(33);}// 呼叫函式 不會立即執行,返回 生成器物件const generator = foo();// 呼叫 next 方法,才會 *開始* 執行 // 返回 包含 yield 內容的物件 const yieldData = generator。next();console。log(yieldData) //=> {value: “yyy”, done: false}// 物件中 done ,表示生成器是否已經執行完畢// 函式中的程式碼並沒有執行結束// 下一次的 next 方法呼叫,會從前面函式的 yeild 後的程式碼開始執行console。log(generator。next()); //=> {value: undefined, done: true}

你會發現,在函式宣告的地方,函式名前面多了 * 星號,函式體中的程式碼有個 yield ,用於函式執行的暫停;簡單點說就是,這個函式不是個普通函式,呼叫後不會立即執行全部程式碼,而是在執行到 yield 的地方暫停函式的執行,並給呼叫者返回一個遍歷器物件,yield 後面的資料,就是遍歷器物件的 value 屬性值,如果要繼續執行後面的程式碼,需要使用 遍歷器物件中的 next() 方法,程式碼會從上一次暫停的地方繼續往下執行;

是不是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 後面,每次的非同步執行,都要在 yield 中暫停,呼叫的返回結果是一個 Promise 物件,我們可以從 迭代器物件的 value 屬性獲取到Promise 物件,然後使用 。then 進行鏈式呼叫處理非同步結果,結果處理的程式碼叫做 執行器,就是具體負責執行邏輯的程式碼;

function ajax(url) { ……}// 宣告一個生成器函式function * fun(){ yield myAjax(‘。/d1。json’) yield myAjax(‘。/d2。json’) yield myAjax(‘。/d3。json’)}// 返回 遍歷器物件 var f = fun();// 生成器函式的執行器 // 呼叫 next 方法,執行非同步程式碼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……。 })})

而執行器的邏輯中,是相同巢狀的,因此可以寫成遞迴的方式對執行器進行改造:

// 宣告一個生成器函式function * fun(){ yield myAjax(‘。/d1。json’) yield myAjax(‘。/d2。json’) yield myAjax(‘。/d3。json’)}// 返回 遍歷器物件 var f = fun();// 遞迴方式 封裝// 生成器函式的執行器function handle(res){ if(res。done) return; res。value。then(data=>{ console。log(data) handle(f。next()) })}handle(f。next());

然後,再將執行的邏輯,進行封裝複用,形成獨立的函式模組;

function co(fun) { // 返回 遍歷器物件 var f = fun(); // 遞迴方式 封裝 // 生成器函式的執行器 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’)}

此時你會發現,使用 Generator 封裝後,非同步的呼叫就變得非常簡單了,但是,這個封裝還是有點麻煩,有大神幫我們做了這個封裝,相當強大:

https://github。com/tj/co

,感興趣看一研究一下,而隨著 JS 語言的發展,更多的人希望類似 co 模組的封裝,能夠寫進語言標準中,我們直接使用這個語法規則就行了;

其實你也可以對比一下,使用 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’)}

你應該也發現了,async 函式就是 Generator 語法糖,不需要自己再去實現 co 執行器函式或者安裝 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);

程式碼執行結果:

你真的懂非同步程式設計嗎?

帶著 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’)

執行結果:

你真的懂非同步程式設計嗎?

是不是恍然大明白呢……