淺聊異步--回調函數(shù)

為什么會想寫一個關于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í)行,而異步代碼后面的代碼則不會

常見的異步情況有

  1. 定時器
  2. 網(wǎng)絡請求
  3. 事件

寫在這些情況里面的回調函數(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)說過了怎么進行設計了,在等待的時間去做其他的事情了。那么我們可以這樣做

  1. 發(fā)出信件(不管怎么說發(fā)信這個操作總是在我們當前的流程里面的)
  2. 提前安排好信到了以后要做什么(所以我們需要寫一段代碼,對這段代碼進行特殊的處理,以跟我們正常的流程進行區(qū)分,為什么說不是等到信件到來再進行處理,這個我覺得你可以看一下自己的代碼就明白為什么我要這樣沒說了,代碼畢竟不是人)
  3. 繼續(xù)做接下來安排好的事情
  4. 當把當前做完的事情做完后去看看是否有回信,如果有回信得話就開始做前面安排好的事情

這里安排好的事情就是回調函數(shù)
當然這里的描寫其實還是有部分的偏差,不過我們已經(jīng)知道了我們大致上要的效果,而實際上,JS的這套機制被稱為事件輪詢(event loop)

那么回歸正題,瀏覽器對異步的處理機制到底是什么樣子的呢?
首先我們要了解的是,js是單線程的,可是瀏覽器并不是單線程的,在這里我們說的js也可以說是js引擎。
不然下面的話說起來可能就會有一些歧義了。

在現(xiàn)代瀏覽器中一般是多進程的。主要有

  1. 渲染進程
  2. 網(wǎng)絡進程
  3. 瀏覽器進程
  4. 插件進程
    而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í)行。
一般來說,這個延遲隊列中的任務應該包括

  1. 回調函數(shù)
  2. 當前發(fā)起時間
  3. 延遲執(zhí)行時間
  4. 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í)行一些自己的操作,導致最后的世界控制并不會很準確。

最后我們再來回顧一下這篇文章的內容

  1. 一些場景的異步的錯誤
  2. 為什么JS引擎是單線程了
  3. 事件輪詢是怎么回事,是怎么實現(xiàn)的

文章參考
極客時間--瀏覽器工作原理與實踐
「前端進階」從多線程到Event Loop全面梳理
《你不知道的JS》

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

相關閱讀更多精彩內容

  • 一、單線程 主線程:JavaScript是單線程的,所謂單線程,是指在JS引擎中負責解釋和執(zhí)行JavaScript...
    puxiaotaoc閱讀 21,303評論 7 32
  • Swift1> Swift和OC的區(qū)別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴謹 對...
    cosWriter閱讀 11,684評論 1 32
  • 弄懂js異步 講異步之前,我們必須掌握一個基礎知識-event-loop。 我們知道JavaScript的一大特點...
    DCbryant閱讀 2,889評論 0 5
  • 最近本人對于js的運行機制,特別是異步,還有回調函數(shù)感覺很亂,于是參考了很多有用的博客(博客原文地址會在文末給出)...
    一包閱讀 1,112評論 0 2
  • 大學是個音樂劇 我在大一大二的時候對他幾乎沒有印象,他是屬于很靦腆很害羞的那種男孩子,幾乎是不和其他人說話聊天,除...
    黑色ET尾戒閱讀 338評論 0 0

友情鏈接更多精彩內容