為什么會想寫一個關于JS的異步的一個系列呢?
在不斷的學習之中,越發(fā)的讓我覺得,異步以及原型,可以說是JS最為重要的兩個部分,或者說是,JS最與其他的編程語言不一樣的特性(當然JS還有很多其他的特性,雖然說很多特性本質是都是設計缺陷。。。)
這兩個特性也導致了JS很多令人難以理解的部分,比如原型鏈中一直在扯的繼承。。。
很多人都會說js是一個十多天設計出來的語言,很多東西都沒有考慮到,所以js中存在很多難以理解的東西也就不足為奇了(或者說js這么爛是很有理由的),可是經(jīng)過這么久的發(fā)展,其實js現(xiàn)在也在彌補一些以前的錯誤,特別是在ES6發(fā)展以后,很多缺陷都已經(jīng)被補足,比如塊作用域,我們很多時候已經(jīng)不需要再去使用那些難以理解的東西了
可是,因為JS這門語言的特殊性質(或者說瀏覽器的特殊性質),很多東西其實是在做增量,比如class,本質也就是一個語法糖而已,如果我們不去理解一些底層的東西,不去理解那11天里為什么會做出這樣的設計,那么我們依舊會很難駕馭JS,即使現(xiàn)在的異步已經(jīng)有了Promise以及async這樣的優(yōu)秀的解決方案,我們依舊可以看到很多這樣的代碼
const getPromise1 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1)
}, 1000);
})
}
const getPromise2 = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(1)
}, 1000);
})
}
const test = () => {
getPromise1().then((res) => {
console.log(res);
getPromise2().then(res2 => {
console.log(res2);
})
})
}
依舊是是回調地獄的寫法,并不知道為什么非要使用Promise
或者這樣
const test2 = async () => {
await getPromise1().then(res => {
console.log(res);
})
}
這段代碼當時看的我很驚訝。。。。
或者是
const sleep = () => {
setTimeout(() => {
console.log(1)
}, 1000);
}
const test3 = async () => {
console.log(0)
await sleep()
console.log(2)
}
test3()
期望輸出0 1 2 且認為1會在0輸出后1000ms后才進行輸出的
ES6還可以稱為ES2015,也就是說promise正式成為標準到我寫這篇文章的時候已經(jīng)過去了4年多了,而被稱為異步的終極解決方案的async跟awiat也是在es2017中就被加入了進來,可是依舊有蠻蠻多的學習者對他們的使用方式是錯誤的,這里面不乏已經(jīng)在工作了的人,這也是我為什么突然想寫這樣一個系列的文章的原因。
不過這篇文章對于那些已經(jīng)能夠很好的處理異步流程的同學來說可能沒有什么幫助了,畢竟語言是在不斷的發(fā)展的,如果只是要用得話,其實在條件允許的情況下,async跟awiat,以及promise,已經(jīng)基本上成為了毫無爭議的選擇了,特別是async跟awiat,用起來真的很簡單,而promise,我們更多的是使用它來生成可以給async使用的函數(shù),或者用來做性能優(yōu)化。
而這個系列的文章,主要是在探尋,為什么JavaScript的異步會發(fā)展成現(xiàn)在這樣子,以及那些藏在現(xiàn)代異步手段下的東西,這樣我們才不至于迷迷糊糊的犯下錯誤。
在講解異步的處理的之前,我們需要先問自己一個問題
什么是異步?
一個比較通俗易懂的解釋是
當JS引擎執(zhí)行到同步代碼的時候,同步代碼會立即執(zhí)行
當JS引擎執(zhí)行到異步代碼的時候,異步代碼不會立即執(zhí)行
比如
let a=1
const b=()=>console.log(a)
const run=(fnc)=>fnc()
run(b)
當JS引擎執(zhí)行這句代碼的時候,會立即執(zhí)行里面的代碼,立刻打印出來1
而對于如下的異步代碼
setTimeout(()=>{
console.log(1)
},1000)
console.log(1)
這句代碼并不會立即執(zhí)行,而是等待1000ms后才執(zhí)行
并且
在同步代碼后面的代碼必須等到同步代碼執(zhí)行完畢以后才會執(zhí)行,而異步代碼后面的代碼則不會
常見的異步情況有
- 定時器
- 網(wǎng)絡請求
- 事件
寫在這些情況里面的回調函數(shù)代碼,都不會立即執(zhí)行
接下來我們來看下異步處理的最基本的方式,回調函數(shù)
在看回調函數(shù)之前
我們要先有的概念
1、JS在一開始的時候開發(fā)目的是運行在瀏覽器上面的
2、JS是單線程的(不應該說js是單線程的,這里所屬的js是單線程的其實是說的是js引擎是單線程的)
3、JS支持函數(shù)式,或者說函數(shù)在JS中是一等公民,可以進行傳遞
這幾點使得js與我們平時的時候接觸的一些語言會有所不同,比如在java中不管我們做什么都要先聲明一個類,而且函數(shù)也不能作為值進行傳遞
而在js中這三個內容是相輔相成的,因為js是運行在瀏覽器里面的,所以js必須是單線程的,所以js也就必須要使用異步的方式,因此必須使用回調函數(shù)
為什么呢,因為js創(chuàng)建出來的一個目的就是為了操作DOM,如果js引擎允許多線程得話,那么有可能出現(xiàn)線程a在獲取某個元素的高度后,使用此高度進行計算,而在這兩個步驟之間,線程b修改該元素的高度,因此計算結果將會不正確,也就無法進行正確的渲染,即使是server work這些加入多線程機制的內容里面,也是不允許對dom進行操作的,對dom的操作永遠會是單線程,因此js也就是必須采用回調函數(shù)的方來對異步進行處理(這里可能存在一些誤差,因為我確實不是很了解太多的語言),為什么這樣說呢?假如是初學者,可能對異步以及回調函數(shù)沒有太多的理解,所以接下來我們來聊聊異步。
我相信大部分的人學習js的時候應該很早的時候就接觸到異步或者說回調函數(shù)了。比如settimeout,比如事件綁定,當然這個時候,很多人可能還是迷迷糊糊的,只知道我在這里(settimeout的參數(shù))傳入一個函數(shù),那么過了一段時間以后,就會執(zhí)行這段代碼,我給dom事件綁定一個函數(shù),那么當我點擊按鈕的時候(或者其他)的時候這個函數(shù)就會執(zhí)行。
而當我們開始對異步以及回調函數(shù)產(chǎn)生疑惑的時候,我覺得可能大部分的人都是在第一次使用ajax或者說請求數(shù)據(jù)的時候,我們怎么才能獲取到數(shù)據(jù)呢?
是這樣嗎?
let res=request.get('http://localhost:8888/',{})
console.log(res);
xxxx
這個其實真的蠻形象的,因為他很符合我們的認知,那就是我請求別人給我東西,別人給了我東西,然后我就可以用這個東西了,并且在python之類的代碼中,這樣寫是完全沒有問題的,可是這是JS。
在前面的論述中,我們已經(jīng)根據(jù)1得出了2,JS必須是單線程的,那么我們來看下,假如JS允許這樣的情況發(fā)生,那么會是怎么樣的呢?
單線程的意思是在一段時間內,只能做一個事情,那么假如以上的的做法可以達到獲取數(shù)據(jù)的辦法,那么就會發(fā)生這樣的事情,在我們點擊一個按鈕發(fā)送一個請求之后,這個時候請求正在返回,他很慢,很慢,我們等得無聊了,這個時候我想點一下旁邊的按鈕,剛剛我點了,他會給我一個反饋比如發(fā)射一發(fā)煙火,可是現(xiàn)在不管我怎么點都沒有用,因為JS是單線程的,他現(xiàn)在在做其他的事情--等待。我們可以認為有一個人,這個人一次只能做一個事情,他剛剛送了一封信,為了獲取回信,他現(xiàn)在在一直等著,等著,他沒有辦法來做,其他的事情,哈你說為什么他不能等下再去看看信有沒有回復,現(xiàn)在怎么不去做其他的事情偏偏要等著?拜托,看下你寫的代碼,假如他不等拿到內容以后再去做其他的事情,他怎么能做其他的時候,或者說,他怎么知道xxxx中,那部分的內容是需要信里面的內容才能做得呢,哪些是不需要信里面的內容才能去做的?此外,假如他現(xiàn)在不等了,那么他什么時候才會知道信會到呢?
這種方案在python可信,是因為python有多線程啊,他完全可以讓另外一個人去做這個事情,而可憐的js
只有一個人,那么我們肯定就要思考一下,有沒有其他的辦法來處理這種情況,畢竟傻傻的等著,實在是太過于愚蠢了。
那么應該怎么進行設計呢?
其實在前面的時候我們已經(jīng)說過了怎么進行設計了,在等待的時間去做其他的事情了。那么我們可以這樣做
- 發(fā)出信件(不管怎么說發(fā)信這個操作總是在我們當前的流程里面的)
- 提前安排好信到了以后要做什么(所以我們需要寫一段代碼,對這段代碼進行特殊的處理,以跟我們正常的流程進行區(qū)分,為什么說不是等到信件到來再進行處理,這個我覺得你可以看一下自己的代碼就明白為什么我要這樣沒說了,代碼畢竟不是人)
- 繼續(xù)做接下來安排好的事情
- 當把當前做完的事情做完后去看看是否有回信,如果有回信得話就開始做前面安排好的事情
這里安排好的事情就是回調函數(shù)
當然這里的描寫其實還是有部分的偏差,不過我們已經(jīng)知道了我們大致上要的效果,而實際上,JS的這套機制被稱為事件輪詢(event loop)
那么回歸正題,瀏覽器對異步的處理機制到底是什么樣子的呢?
首先我們要了解的是,js是單線程的,可是瀏覽器并不是單線程的,在這里我們說的js也可以說是js引擎。
不然下面的話說起來可能就會有一些歧義了。
在現(xiàn)代瀏覽器中一般是多進程的。主要有
- 渲染進程
- 網(wǎng)絡進程
- 瀏覽器進程
- 插件進程
而js引擎線程程是屬于渲染進程的一部分
我們可以認為JS單線程是說JS引擎是單線程的。而瀏覽器則是可以負責多個人去做事情的那個人
因為如果觀測我們前面做的設計以及JS是單線程的,很容易就讓人想到一些問題,那就是
1、假如沒有信得話怎么辦?
2、假如在我做事情的時候來了多封信,我應該先做那封信里面的事情,這里我們可以想到給信進行排順序,那么問題就來了,誰來負責這個信的排序?
這個人就是瀏覽器啦
對于渲染進程至少有以下幾個線程
1、GUI渲染線程
2、JS引擎線程
3、事件觸發(fā)線程
4、定時器觸發(fā)線程
5、異步http請求線程
而后面這三個,我們就可以很明顯的發(fā)現(xiàn),他們都是可以產(chǎn)生異步的操作的線程
那么我們來走一下JS執(zhí)行的流程
1、先從整個JS腳本添加到任務隊列中開始順序執(zhí)行、此時是跟GUI渲染線程是互斥的,會阻塞GUI的繪制
2、如果遇到了3、4、5這些情況,則使用異步線程,例如在當JS引擎線程執(zhí)行到setTimeOut\setTimeInterval 關鍵詞時,會把定時器任務添加到定時觸發(fā)器線程中,定時觸發(fā)器線程開始執(zhí)行倒數(shù),當?shù)箶?shù)之間到了后,將回調任務添加到task queue任務隊列中,等待JS引擎線程來執(zhí)行
3、當當前任務隊列中的內容執(zhí)行完畢后,檢查異步任務隊列中是否存在任務,如果有,則依次執(zhí)行異步任務隊列中的內容
4、依次類推
這里又有了一個新的概念為事件輪詢,在看這個概念之前我們可以先看一段代碼
這段代碼來自<<你不知道的js>>
// eventLoop是一個消息隊列
// (先進,先出)
var eventLoop = [ ];
var event;
// “永遠”執(zhí)行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到隊列中的下一個事件
event = eventLoop.shift();
// 現(xiàn)在,執(zhí)行下一個事件
try {
event();
}
catch (err) {
reportError(err);
}
}
}
eventLoop這個數(shù)組里面有什么?我們可以認為有一些函數(shù),而這些函數(shù)里面的代碼,就是我們在事件輪詢中添加進去的。
所以說,事件輪詢其實就是一個永遠不會終止的循環(huán),這個循環(huán)會檢查一個數(shù)組(或者說函數(shù)調用棧)里面是否存在可執(zhí)行的代碼,如果有則進行執(zhí)行
而什么時候這個數(shù)組里面才會被添加代碼進去呢?
最開始的一次添加是在整個JS代碼第一次執(zhí)行的時候,整個JS代碼被添加到了eventLoop隊列中去了,而后當檢測到異步操作的時候,瀏覽器就會存儲好這些異步操作對應的回調函數(shù),當異步操作完成的時候,則會被添加到event loop中去,等到當前的event loop執(zhí)行完成了以后,就會進行下一步的執(zhí)行。
具體流程可以參考圖片(極客時間--瀏覽器原理與實踐)

以上的部分是如何實現(xiàn)的呢?
下面我們以setTimeout的實現(xiàn)為例子來看具體的實現(xiàn)方案.
如果要實現(xiàn)settimeout,我們不能僅需要一個消息隊列,還需要一個隊列,這個隊列維護了需要延遲執(zhí)行的任務列表??上攵?在每次循環(huán)執(zhí)行完畢以后,我們需要檢查一下這個延遲隊列中是否有任務達到了執(zhí)行的條件,如果達到了,那么就應該把該任務放到任務隊列中去,那么在以后任務隊列中前面的任務都執(zhí)行完畢了以后,那么這個任務就會執(zhí)行。
一般來說,這個延遲隊列中的任務應該包括
- 回調函數(shù)
- 當前發(fā)起時間
- 延遲執(zhí)行時間
- id 用于取消執(zhí)行等
參考代碼
// eventLoop是一個消息隊列
// (先進,先出)
var eventLoop = [ ];
var delayLoop=[];
var event;
// “永遠”執(zhí)行
while (true) {
// 一次tick
if (eventLoop.length > 0) {
// 拿到隊列中的下一個事件
event = eventLoop.shift();
// 現(xiàn)在,執(zhí)行下一個事件
try {
event();
if delayLoop中存在任務可以執(zhí)行了
for
event.loop.push(task)
}
catch (err) {
reportError(err);
}
}
}
從這里我們也可以看出來,即使你設置了settimeout的時間是1000ms,實際上最后的執(zhí)行時間很可能會大于1000ms,一是因為即使輪到這個任務了但是因為前面還有任務在執(zhí)行,需要等到前面的任務執(zhí)行完畢了才會添加進任務隊列,二是在添加到任務隊列以后,瀏覽器也會執(zhí)行一些自己的操作,導致最后的世界控制并不會很準確。
最后我們再來回顧一下這篇文章的內容
- 一些場景的異步的錯誤
- 為什么JS引擎是單線程了
- 事件輪詢是怎么回事,是怎么實現(xiàn)的
文章參考
極客時間--瀏覽器工作原理與實踐
「前端進階」從多線程到Event Loop全面梳理
《你不知道的JS》