
讀完本文章,你會對JS異步有更深刻的理解,對于開發(fā)中各種異步方式的處理將更加的頭腦清晰,那么,本文章的開始,先來為大家介紹JS異步的相關(guān)基礎(chǔ)理論。
為什么JS是單線程的?
JavaScript語言的一大特點(diǎn)就是單線程,也就是說,同一個時(shí)間只能做一件事。那么,為什么JavaScript不能有多個線程呢?這樣能提高效率啊。
JavaScript的單線程,與它的用途有關(guān)。作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復(fù)雜的同步問題。比如,假定JavaScript同時(shí)有兩個線程,一個線程在某個DOM節(jié)點(diǎn)上添加內(nèi)容,另一個線程刪除了這個節(jié)點(diǎn),這時(shí)瀏覽器應(yīng)該以哪個線程為準(zhǔn)?
所以,為了避免復(fù)雜性,從一誕生,JavaScript就是單線程,這已經(jīng)成了這門語言的核心特征,將來也不會改變。
為了利用多核CPU的計(jì)算能力,HTML5提出Web Worker標(biāo)準(zhǔn),允許JavaScript腳本創(chuàng)建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標(biāo)準(zhǔn)并沒有改變JavaScript單線程的本質(zhì)。
上面是摘自阮一峰老師博客中的一段話,很清楚的為大家解釋了JS為什么是單線程的。那么問題來了,JS的單線程和其異步有什么關(guān)系呢?JS的單線程為什么和異步不產(chǎn)生矛盾呢?下面是小編根據(jù)上面引用段落帶給大家的解釋正是因?yàn)樵试SJS創(chuàng)建多個線程,所以我們子線程就可以用來進(jìn)行異步的操作,而主線程用來干它本身的工作,且可以控制子線程,至于為什么JS作為單線程語言還可以進(jìn)行多線程任務(wù)這個古老的問題,上面引用段落說“為了利用多核CPU的計(jì)算能力”,也就是所JS的宿主環(huán)境是多線程的比如瀏覽器,NodeJS。讀到這里我想大家應(yīng)該認(rèn)識到了,為什么我會在文章一開始解釋JS單線程了,大家是不是知道了我們常說的異步的來歷和原因了吧。下面會做更詳細(xì)的介紹。
什么是任務(wù)隊(duì)列
我們經(jīng)常在一些面試題,或者實(shí)際操作中,對各種同步異步方法混合在一起后的執(zhí)行順序不知所措,有的人甚至是完全憑感覺去感受它們的執(zhí)行順序,這是因?yàn)槟悴涣私釰S的任務(wù)隊(duì)列,讀完本段落,相信你可以對不同方法的執(zhí)行順序有清晰的理解。
畢竟JS是單線程的,即使是JS腳本可以創(chuàng)建多個子線程,但完全受控于主線程,那么真正執(zhí)行起來,依然是要排隊(duì)的。這就以為著只有前一個任務(wù)執(zhí)行完畢,后面的任務(wù)才可以執(zhí)行。如果前一個任務(wù)執(zhí)行的很慢(比如Ajax請求),那么就會在CPU空閑的情況下,等待其執(zhí)行完畢才能接著執(zhí)行后面的任務(wù)。這不是浪費(fèi)嗎?所以JS的設(shè)計(jì)者意識到,我們主線程完全可以不去管這些IO設(shè)備的等待,可以先將等待掛起,先執(zhí)行后面的任務(wù),等IO設(shè)備等待完畢,運(yùn)行出了結(jié)果,再去回過頭執(zhí)行剛才被掛起的任務(wù),可能這段話有些難懂,本人自己的理解為,讓那些執(zhí)行很慢的IO設(shè)備去一邊等待,先讓后面的任務(wù)執(zhí)行,等IO設(shè)備緩慢的執(zhí)行完畢有了結(jié)果,再過來排隊(duì),就像是在食堂打飯,如果你排著隊(duì),輪到了你,而你不知道吃什么,那你就去一邊想,讓后面的人先打飯,等你想好吃什么了,再過來打飯。
于是,我們JS運(yùn)行的任務(wù),被分成了兩種,一種是同步任務(wù)(synchronous)一種是異步任務(wù)(asynchronous),JS引擎在執(zhí)行JS過程中,會按照不同的任務(wù)類別去執(zhí)行,同步任務(wù)會被放在主線程執(zhí)行,也就是,必須等到上一個任務(wù)執(zhí)行完畢才會執(zhí)行下一個任務(wù),這一點(diǎn)同樣不違背JS的單線程。異步任務(wù)會進(jìn)入子線程,并在其有結(jié)果之后,向“任務(wù)隊(duì)列”發(fā)一個信號(后文稱事件),只有任務(wù)隊(duì)列告訴主線程,某一個異步任務(wù)有結(jié)果了,可以執(zhí)行了,那么這個異步任務(wù)才會進(jìn)入主線程執(zhí)行。所以我們有如下JS運(yùn)行機(jī)制
(1)所有同步任務(wù)都在主線程上執(zhí)行,形成一個執(zhí)行棧(execution context stack)。
(2)主線程之外,還存在一個"任務(wù)隊(duì)列"(task queue)。只要異步任務(wù)有了運(yùn)行結(jié)果,就在"任務(wù)隊(duì)列"之中放置一個事件。
(3)一旦"執(zhí)行棧"中的所有同步任務(wù)執(zhí)行完畢,系統(tǒng)就會讀取"任務(wù)隊(duì)列",看看里面有哪些事件。那些對應(yīng)的異步任務(wù),于是結(jié)束等待狀態(tài),進(jìn)入執(zhí)行棧,開始執(zhí)行。
(4)主線程不斷重復(fù)上面的第三步。
上面的機(jī)制概括為,同步任務(wù)會在主線程中排隊(duì)執(zhí)行,異步任務(wù)會在子線程中執(zhí)行,并當(dāng)其有結(jié)果之后,在“任務(wù)隊(duì)列”中放一個事件,只要主線程空了,就去讀取"任務(wù)隊(duì)列"
事件和回調(diào)函數(shù)
在上面的段落中,我們提到了異步任務(wù)會在“任務(wù)隊(duì)列”中放置一個事件。這里的事件就是我們常說的事件比如(點(diǎn)擊事件,Input事件等),一般的這些事件都會指定一個回調(diào)函數(shù)。那么事件和回調(diào)函數(shù)的運(yùn)行機(jī)制到底是什么呢?
"任務(wù)隊(duì)列"是一個事件的隊(duì)列(也可以理解成消息的隊(duì)列),IO設(shè)備完成一項(xiàng)任務(wù),就在"任務(wù)隊(duì)列"中添加一個事件,表示相關(guān)的異步任務(wù)可以進(jìn)入"執(zhí)行棧"了。主線程讀取"任務(wù)隊(duì)列",就是讀取里面有哪些事件。
"任務(wù)隊(duì)列"中的事件,除了IO設(shè)備的事件以外,還包括一些用戶產(chǎn)生的事件(比如鼠標(biāo)點(diǎn)擊、頁面滾動等等)。只要指定過回調(diào)函數(shù),這些事件發(fā)生時(shí)就會進(jìn)入"任務(wù)隊(duì)列",等待主線程讀取。
所謂"回調(diào)函數(shù)"(callback),就是那些會被主線程掛起來的代碼。異步任務(wù)必須指定回調(diào)函數(shù),當(dāng)主線程開始執(zhí)行異步任務(wù),就是執(zhí)行對應(yīng)的回調(diào)函數(shù)。
"任務(wù)隊(duì)列"是一個先進(jìn)先出的數(shù)據(jù)結(jié)構(gòu),排在前面的事件,優(yōu)先被主線程讀取。主線程的讀取過程基本上是自動的,只要執(zhí)行棧一清空,"任務(wù)隊(duì)列"上第一位的事件就自動進(jìn)入主線程。但是,由于存在后文提到的"定時(shí)器"功能,主線程首先要檢查一下執(zhí)行時(shí)間,某些事件只有到了規(guī)定的時(shí)間,才能返回主線程。
以上引用段落中的事件就是我們的異步任務(wù),回調(diào)函數(shù)就是我們最前面說的,被主線程掛起來的代碼。當(dāng)異步任務(wù)進(jìn)入執(zhí)行棧執(zhí)行的時(shí)候,才會被調(diào)用。
Event Loop
這里回到了我們的標(biāo)題——事件循環(huán),來歷就是主線程從"任務(wù)隊(duì)列"中讀取事件,這個過程是循環(huán)不斷的。
定時(shí)器
上文提到了“定時(shí)器”,因?yàn)樵谑录h(huán)中,"任務(wù)隊(duì)列"還可以放置定時(shí)事件,即指定某些代碼在多少時(shí)間之后執(zhí)行。這叫做"定時(shí)器"(timer)功能,也就是定時(shí)執(zhí)行的代碼。
這里關(guān)于定時(shí)器的基本語法將不再贅述,大家都知道定時(shí)器是一個異步任務(wù),s所以對于下面的執(zhí)行代碼結(jié)果應(yīng)該是沒有異議的。
console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);
上面代碼輸出結(jié)果為1,3,2。
對于這個結(jié)果,我想大多數(shù)同學(xué)不用讀我的文章都知道,當(dāng)然,當(dāng)你讀了這篇文章后,會對其原理有了解。其中console.log(1)和console.log(3)是同步代碼,中間的定時(shí)器為異步代碼,所以要等到執(zhí)行棧執(zhí)行完同步任務(wù),再去執(zhí)行任務(wù)隊(duì)列中的異步任務(wù),但是這個任務(wù)被指定在1秒之后執(zhí)行。
我們在看下面代碼
setTimeout(function(){console.log(1);}, 0);
console.log(2);
通過上面的都講解,不難得到其執(zhí)行結(jié)果為2,1。
但HTML5標(biāo)準(zhǔn)規(guī)定了setTimeout()的第二個參數(shù)的最小值(最短間隔),不得低于4毫秒,如果低于這個值,就會自動增加。那么我們?yōu)槭裁匆獙懗?code>setTimeout(fn,0)這樣呢?
setTimeout(fn,0)的含義是,指定某個任務(wù)在主線程最早可得的空閑時(shí)間執(zhí)行,也就是說,盡可能早得執(zhí)行。但它在"任務(wù)隊(duì)列"的尾部添加一個事件,因此要等到同步任務(wù)和"任務(wù)隊(duì)列"現(xiàn)有的事件都處理完,才會得到執(zhí)行。
也就是說setTimeout(fn,0)會在主線程得到空閑時(shí)最早執(zhí)行,但主線程的空閑對于setTimeout(fn,0)來說是執(zhí)行完所有同步任務(wù),處理完所有任務(wù)隊(duì)列中的事件。這時(shí)到執(zhí)行棧讀取任務(wù)隊(duì)列事件開始執(zhí)行任務(wù)隊(duì)列中的事件的時(shí)候,setTimeout(fn,0)會最先執(zhí)行。
需要注意的是,setTimeout()只是將事件插入了"任務(wù)隊(duì)列",必須等到當(dāng)前代碼(執(zhí)行棧)執(zhí)行完,主線程才會去執(zhí)行它指定的回調(diào)函數(shù)。要是當(dāng)前代碼耗時(shí)很長,有可能要等很久,所以并沒有辦法保證,回調(diào)函數(shù)一定會在setTimeout()指定的時(shí)間執(zhí)行。
微任務(wù)與宏任務(wù)
以上是關(guān)于事件循環(huán)中,同步任務(wù)和異步任務(wù)的運(yùn)行機(jī)制,我們已經(jīng)有了很深刻的了解,關(guān)于異步任務(wù),其中又分為了“宏任務(wù)”和“微任務(wù)”。關(guān)于宏任務(wù)和微任務(wù),更多的詳細(xì)內(nèi)容,大家自行上網(wǎng)參考, 或期待小編后續(xù)文章,本段落只做應(yīng)用層簡單介紹。
- 宏任務(wù)一般是:包括整體代碼script,setTimeout,setInterval、setImmediate。
- 微任務(wù):原生Promise(有些實(shí)現(xiàn)的promise將then方法放到了宏任務(wù)中)、process.nextTick。
那么微任務(wù)和宏任務(wù)是干嘛的呢?看下面代碼
setTimeout(()=>{
console.log('setTimeout1')
},0)
let p = new Promise((resolve,reject)=>{
console.log('Promise1')
resolve()
})
p.then(()=>{
console.log('Promise2')
})
親手做實(shí)驗(yàn)的同學(xué)會發(fā)現(xiàn)輸出結(jié)果為Promise1、Promise2、setTimeout1。
有同學(xué)一定會問,上面不是說setTimeout(fn,0)會在任務(wù)隊(duì)列的最早執(zhí)行嗎?這里不免有矛盾,在剛才講定時(shí)器的時(shí)候,我們還沒有接觸Promise這樣的異步,實(shí)際上,對于異步又會分為微任務(wù)和宏任務(wù),微任務(wù)要先于宏任務(wù)執(zhí)行,要比宏任務(wù)更早的進(jìn)入執(zhí)行棧,setTimeout(fn,0)的優(yōu)先可以理解為,微任務(wù)先于宏任務(wù),而宏任務(wù)中又以setTimeout(fn,0)最先??聪旅媸纠龍D
這里,關(guān)于微任務(wù)和宏任務(wù)的介紹就到這里,其實(shí)微任務(wù)和宏任務(wù)的知識遠(yuǎn)不止這些,感興趣的同學(xué)可以加深研究,本文只做拋磚引玉,讓大家知道異步任務(wù)也分先后執(zhí)行即可,這樣滿足一般開發(fā)已沒有問題。
本文到此介紹,對文章內(nèi)容有異議或者有作者講解錯誤之處,望評論留言。
本文參考阮一峰網(wǎng)絡(luò)日志《JavaScript 運(yùn)行機(jī)制詳解:再談Event Loop》