學習js開發,無論是前端開發還是node.js,都避免不了要接觸非同步程式設計這個問題,就和其它大多數以多執行緒同步為主的程式語言不同,js的主要設計是單執行緒非同步模型。正因為js天生的與眾不同,才使得它擁有一種獨特的魅力,也給學習者帶來了很多探索的道路。本文就從js的最初設計開始,整理一下js非同步程式設計的發展歷程。
什麼是非同步
在研究js非同步之前,先弄清楚非同步是什麼。非同步是和同步相對的概念,同步,指的是一個呼叫發起後要等待結果返回,返回時候必須拿到返回結果。而非同步的呼叫,發起之後直接返回,返回的時候還沒有結果,也不用等待結果,而呼叫結果是產生結果後通過被呼叫者通知呼叫者來傳遞的。
舉個例子,A想找C,但是不知道C的電話號碼,但是他有B的電話號碼,於是A給B打電話詢問C的電話號碼,B需要查詢才能知道C的電話號碼,之後會出現兩種場景看下面兩個場景:
- A不掛電話,等到B找到號碼之後直接告訴A
- A掛電話,B找到後再給A打電話告訴A
能感受到這兩種情況是不同的吧,前一種就是同步,後一種就是非同步。
為什麼是非同步的
先來看js的誕生,JavaScript誕生於1995年,由Brendan Eich設計,最早是在Netscape公司的瀏覽器上實現,用來實現在瀏覽器中處理簡單的表單驗證等使用者互動。至於後來提交到ECMA,形成規範,種種歷史不是這篇文章的重點,提到這些就是想說一點,js的最初設計就是為了瀏覽器的GUI互動。對於圖形化介面處理,引入多執行緒勢必會帶來各種各樣的同步問題,因此瀏覽器中的js被設計成單執行緒,還是很容易理解的。但是單執行緒有一個問題:一旦這個唯一的執行緒被阻塞就沒辦法工作了--這肯定是不行的。由於非同步程式設計可以實現“非阻塞”的呼叫效果,引入非同步程式設計自然就是順理成章的事情了。
現在,js的執行環境不限於瀏覽器,還有node.js,node.js設計的最初想法就是設計一個完全由事件驅動,非阻塞式IO實現的伺服器執行環境,因為網路IO請求是一個非常大的效能瓶頸,前期使用其他程式語言都失敗了,就是因為人們固有的同步程式設計思想,人們更傾向於使用同步設計的API。而js由於最初設計就是全非同步的,人們不會有很多不適應,加上V8高效能引擎的出現,才造就了node.js技術的產生。node.js擅長處理IO密集型業務,就得益於事件驅動,非阻塞IO的設計,而這一切都與非同步程式設計密不可分。
js非同步原理
這是一張簡化的瀏覽器js執行流程圖,nodejs和它不太一樣,但是都有一個佇列
這個佇列就是非同步佇列,它是處理非同步事件的核心,整個js呼叫時候,同步任務和其他程式語言一樣,在棧中呼叫,一旦遇上非同步任務,不立刻執行,直接把它放到非同步佇列裡面,這樣就形成了兩種不同的任務。由於主執行緒中沒有阻塞,很快就完成,棧中任務邊空之後,就會有一個事件迴圈,把佇列裡面的任務一個一個取出來執行。只要主執行緒空閒,非同步佇列有任務,事件迴圈就會從佇列中取出任務執行。
說的比較簡單,js執行引擎設計比這複雜的多得多,但是在js的非同步實現原理中,事件迴圈和非同步佇列是核心的內容。
非同步程式設計實現
非同步程式設計的程式碼實現,隨著時間的推移也在逐漸完善,不止是在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路上最大的障礙了。
尊重原創,轉載分享前請先知悉作者,也歡迎指出錯誤不足共同交流,更多內容歡迎關注作者部落格點選這裡