1. 程式人生 > >前端入門20-JavaScript進階之非同步回撥的執行時機

前端入門20-JavaScript進階之非同步回撥的執行時機

宣告

本系列文章內容全部梳理自以下幾個來源:

作為一個前端小白,入門跟著這幾個來源學習,感謝作者的分享,在其基礎上,通過自己的理解,梳理出的知識點,或許有遺漏,或許有些理解是錯誤的,如有發現,歡迎指點下。

PS:梳理的內容以《JavaScript權威指南》這本書中的內容為主,因此接下去跟 JavaScript 語法相關的系列文章基本只介紹 ES5 標準規範的內容、ES6 等這系列梳理完再單獨來講講。

正文-非同步回撥的執行時機

本篇會講到一個單執行緒事件迴圈機制,但並不是網路上對於 js 執行引擎介紹中的單執行緒機制,也沒有涉及宿主環境瀏覽器的各種執行緒,如渲染執行緒、js 引擎執行執行緒、後臺執行緒等等這些內容。

嚴謹來講,應該不屬於 JavaScript 自身的單執行緒機制,而是宿主物件,如瀏覽器處理執行 js 程式碼的單執行緒事件迴圈機制。

回到正題,本篇所要講的,就是類比於 Android 中的主執行緒訊息佇列迴圈機制,來講講在 JavaScript 中,如果設定了某個非同步任務後,當非同步任務執行完成需要回調通知時,這個回撥任務的執行時機

如果還不清楚要講的是什麼,那麼先來看個問題:

<script type="text/javascript">
    $.ajax({
        url: "https://easy-mock.com/mock/5b592c01e4e04f38c7a55958/ywb/is/version/checkVersion",
        data: {"key": 122},
        type: "POST",
        success: function (data) {
            console.log("----------success-----------");  //什麼時候會執行回撥
        },
        error: function (e) {
            console.log("----------error-----------");
        }
    });

    //...
</script>

這是用 jQuery 寫的 ajax 網路請求的示例,這條請求自然是非同步進行的,但當請求結果回來後,會去觸發 success 或 error 回撥,那麼,問題來了:

Q:想過沒有,如果請求結果回來後,這個回撥的程式碼是在什麼時機會被執行的?是立馬就執行嗎,不管當前是否正在執行某個函式內的程式碼?還是等當前的函式執行結束?又或者是?

也許你還沒看懂這個問題要問的是什麼,沒關係,下面舉例分析時,會講得更細,到時你就知道這個問題要問的是什麼了。

Android 訊息佇列迴圈機制

先來看看 Android 中的主執行緒訊息佇列迴圈機制,當然如果你不是從 Android 轉前端,那可以跳過這趴:

這張圖來自 Android訊息機制(一):概述設計架構這篇文章中,我懶得自己畫了,借大佬圖片一用,如果不允許使用,麻煩告知下,我再來自己畫。

在 Android 裡有個主執行緒,因為只能在主執行緒中進行 UI 操作,所以也叫 UI 執行緒,這個主執行緒在應用啟動時就進入一個死迴圈中,類似於執行了 while(true){...} 這樣的程式碼,等到應用退出時,退出該死迴圈。而死迴圈之所以不會卡死 CPU,是因為利用了 Linux 的 epoll 機制,通俗的來將,就是,主執行緒會一直迴圈往訊息佇列中取訊息執行,如果佇列中沒有訊息,那麼會進入阻塞狀態,等有新的訊息到來時,喚醒繼續處理。而阻塞和喚醒就是利用了 Linux 的 epoll 機制。

所以,在 Android 中,開啟頁面是一個 message,觸控式螢幕幕也是一個 message,message 中指示著當前應該執行的程式碼段,只有當前的 message 執行結束後,下會輪到下個 message 執行。

所以,在 Android 中的非同步任務的回撥工作,比如同樣非同步發起一個網路請求,請求結果回來後,需要回調到主執行緒中處理,那麼這個回撥工作的程式碼段會被封裝到 message 中,傳送到訊息佇列中排隊,直到輪到它來執行。

而 message 傳送到訊息佇列是基於 Handler 來傳輸,所以,在 Android 中,如果想要檢視 message 是以什麼為粒度,查詢在哪裡通過 Handler 傳送了 message 即可。

JavaScript 中的單執行緒事件迴圈機制

那麼,在 JavaScript 中,又是如何處理非同步工作的回撥任務的呢?

查了一些相關的資料,發現講的都是 JavaScript 的單執行緒,事件迴圈機制等之類理論,但卻沒看到,事件的粒度是什麼?

看完我能理解,JavaScript 也是類似 Android,一樣執行了某段類似 while(true){...} 的程式碼來迴圈處理事件,但看完我仍舊無法理解,這個事件的粒度是什麼,怎麼檢視事件的粒度?

再舉個例子來說明我的疑問好了:

<script type="text/javascript">
    console.log("----------1-----------");
    $.ajax({
        url: "https://easy-mock.com/mock/5b592c01e4e04f38c7a55958/ywb/is/version/checkVersion",
        data: {"key": 122},
        type: "POST",
        success: function (data) {
            console.log("----------success-----------");  //什麼時候會執行回撥
        },
        error: function (e) {
            console.log("----------error-----------");
        }
    });

    console.log("----------2-----------");
    alert("2");  //第一個卡點
    console.log("----------2.1-----------");
    function A() {
        console.log("------------2.2-----------");
        alert("A");  //第二個卡點
    }
    A();
    console.log("---------------2.3---------------")
</script>

<script type="text/javascript">
    console.log("----------3-----------");
</script>

alert() 會阻塞當前程式,當 js 執行到 alert() 的程式碼時卡在這裡,後續程式碼不會被執行,直到取消彈窗。所以,我們可以通過註釋上例中相對應的 alert() 來模擬非同步請求的結果在什麼時候接收到,而這個回撥任務又是在哪個時機被執行的。

好,那麼疑問來了:

假設,程式卡在 alert("2") 這裡,這時候,非同步的請求結果回來了,那麼回撥任務是會被接到哪個時機執行?等我取消 alert 的彈窗後就先執行回撥任務然後再繼續處理 alert("2") 後的程式碼嗎?

我們將 alert("A") 註釋掉,執行一下,測試看看:

當前程式確實卡在 alert("2"),而且我們等到請求結果回來了,這時,我們把 alert 彈窗取消掉,看看日誌:

回撥任務中輸出的 success 在 alert("2") 後續程式碼輸出的 2.1 下面,那麼就是先繼續執行 alert("2") 後面的程式碼,然後才會執行回撥任務的程式碼了,那麼這個後面的程式碼究竟包括哪些程式碼?

好,這個時候,我們把 alert("2") 程式碼註釋掉,讓程式卡在 alert("A") 這行程式碼。

假設,當前程式正在執行某個函式內的程式碼,這個時候非同步請求的結果回來了,那麼這個回撥任務會接在這個函式執行結束後嗎?也就是,我們現在來驗證下事件的粒度是否是以函式為粒度?

程式確實卡在函式 A 內部的程式碼 alert("A"),輸出的日誌上也能看到現在已經輸出到 2.2,且非同步請求的結果也回來了,那麼這個回撥任務的程式碼會在函式呼叫執行結束後,就被處理嗎?如果是的話,那麼日誌 2.2 接下去應該要輸出 success 才對,如果不是,那麼就會輸出 2.3,看看日誌:

也就是說,即使非同步請求結果回來了,回撥任務也不能在當前函式執行完後立馬被處理,它還是得繼續等待,等到函式後面的程式碼也執行完了,那這個後面的程式碼到底是什麼呢?也就是事件的粒度到底是什麼呢?

我們試過了以每行程式碼為粒度做測試,也試過了以函式為粒度做測試,那還能以什麼作為粒度呢?或者是以 <script> 為粒度,只有等當前 <script> 標籤內的程式碼都執行完,才輪到下個程式碼段執行?

從上面兩種場景下,所得到的日誌來看,似乎確實也是這麼個結論,success 的日誌都是在 2.3 和 3 之間輸出,2.3 表示當前 <script> 標籤裡的最後一行程式碼,而 3 表示下個 <script> 標籤內的第一行程式碼。

既然這樣,我們再來做個測試:

<script type="text/javascript">
    console.log("----------1-----------");
    $.ajax({
        url: "https://easy-mock.com/mock/5b592c01e4e04f38c7a55958/ywb/is/version/checkVersion",
        data: {"key": 122},
        type: "POST",
        success: function (data) {
            console.log("----------success-----------");  //什麼時候會執行回撥
        },
        error: function (e) {
            console.log("----------error-----------");
        }
    });
    /*
    console.log("----------2-----------");
    alert("2");  //第一個卡點
    console.log("----------2.1-----------");
    function A() {
        console.log("------------2.2-----------");
        alert("A");  //第二個卡點
    }
    A();
    console.log("---------------2.3---------------") */
</script>

<script type="text/javascript">
    console.log("----------3-----------");
    alert("3"); //第三個卡點
    console.log("----------3.1---------")
</script>

我們把第一個 <script> 標籤內那些用於上面兩種場景測試的程式碼註釋掉,只留一個非同步請求的程式碼,然後在第二個 <script> 標籤內,加個 alert("3") 來模擬程式是在第一個 <script> 中發起非同步請求,但直到程式執行到第二個 <script> 時,非同步請求結果才回來,這種場景下回調任務的執行時機會是在哪?

如果當程式卡在 alert("3"),非同步請求結果回來了,這時候還沒有取消 alert 彈窗,或者一取消的時候,就先輸出 success,再輸出 3.1,則表示,回撥任務的程式碼塊是被安排到發起非同步請求的這個 <script> 裡程式碼都執行結束就去處理。

如果 success 是在 3.1 之後才輸出,那麼,就可以說明,瀏覽器處理 js 程式碼,是以 <script> 作為事件粒度,放入事件迴圈佇列中去處理。看看日誌:

好了,現在可以確認了,success 是在 3.1 之後才輸出的,那麼來整理下結論吧。

結論

看到這裡的話,你一定要繼續看最後的一小節的內容,一定!

之後問了一些前端同學,然後我基於對 Android 那邊的類似理解,我自行梳理了下面的這些結論,因為涉及底層執行機制、瀏覽器行為的這些知識我還沒開始去看,所以下面結論不保證正確,只能說是,基於我目前的能力,針對於做實驗所得到的現象,我梳理出一些可以解釋得通的結論。

  • 瀏覽器解析 html 文件時,是按順序一行一行進行解析,當處理到 <script> 標籤時,會暫停當前頁面的渲染,進入 js 程式碼的執行。
  • 在執行當前 <script> 標籤內的程式碼時,是以整個標籤內的程式碼塊作為事件粒度,放入事件佇列中進行處理。
  • 如果在當前 <script> 標籤裡的程式碼發起了某些非同步工作,如非同步網路請求,並設定了回撥,那麼回撥任務的程式碼塊會被單獨作為一個事件,等到非同步工作結束後,插入當前事件佇列中。
  • 所以,如果回撥任務在執行當前 <script> 標籤內的程式碼時就已經加入隊列了,那麼等到當前 <script> 裡的程式碼都執行結束後,就可以輪到回撥任務的執行。
  • 如果回撥任務直到當前 <script> 裡的程式碼都執行結束也還沒被加入事件佇列,那麼這時瀏覽器會接著去解析 html 文件,如果又碰到下個 <script> 標籤,那麼會將這個 <script> 標籤內的程式碼塊放入事件佇列中處理。
  • 所以,如果這時候第一個 <script> 標籤內的程式碼發起的非同步任務才結束,才將回撥工作加入事件佇列中,那麼這個回撥工作的程式碼只能等到第二個 <script> 標籤內的程式碼都執行結束後才會被處理。

碰到的問題

為啥會想要梳理這個結論呢,是因為我碰到這麼一種場景:

<script type="text/javascript">
    document.location.href = "http://www.baidu.com"
    //...
</script>

之前有個 h5 專案中,有類似的程式碼,就是滿足一定條件下,需要將頁面跳轉至其他頁面。

修改 location.href 貌似不是同步操作,我猜測應該是這行跳轉程式碼會告訴瀏覽器,當前頁面準備跳轉,這時候,瀏覽器再生成一個跳轉事件,接入事件佇列中等待執行的吧。

因為,最初我以為這是個同步操作,所以我認為當程式執行到 document.location.href = xx 這行程式碼之後,頁面就會發生跳轉,然後這行程式碼下面的那些程式碼都不會被執行,但最後實際執行時,卻發現,這行程式碼下面的程式碼也都被執行了。

後來經過測試,發現,跳轉語句這行程式碼所在的 <script> 裡的程式碼會被全部執行完,然後才發起頁面跳轉,下個 <script> 裡的程式碼不會被執行,所以,那個時候,就有個疑惑了,在 js 中發起一個非同步操作的話,這個非同步工作的回撥任務的執行時機到底在哪裡?

後來稍微查了相關資料,發現了個詞說 JavaScript 是單執行緒機制,聯想到 Android 中的主執行緒訊息迴圈機制,這才想來理一理。

臥槽

臥槽,臥槽,臥槽~

不要怪我連罵粗話,這篇文章是挺早之前就寫好的了,只是一直還沒發表,待在草稿箱中。而最後這一小節,是等到我差不多要發表時才新增的內容。

為什麼要罵粗話,因為我發現,我上面所梳理的結論,好像全部都是錯誤的了,但也不能說全部錯誤,我實在不想把辛辛苦苦寫好的都刪掉,也不想直接就發出來誤導大夥,所以我在最後加了這一小節,來說明情況,大夥看這篇的結論時,看看就好,討論討論一下就好,不要太當真哈。

事情是這樣的,我一些前端同學覺得我的理解有誤,所以嘗試將我上文中的例子在他的電腦上執行測試了下,結果你們看一下:

這是對應上文中第一個測試,即讓程式卡在 alert("2") 這裡,然後等到請求結果回來後,取消 alert 彈窗,這種場景,按照我們上面梳理的結論,回撥任務在當前 <script> 執行結束之前就被插入事件佇列中了,所以回撥任務應該會在第二個 <script> 程式碼之前先被處理,但我同學的情況卻是,回撥任務等到所有 <script> 都處理完才被執行???

一臉懵逼???

然後,我懷疑是不是不同瀏覽器會有不同的行為,所以同樣的測試步驟我在 IE 瀏覽器上測試了一下:

是不是更懵逼,明明程式卡在 alert("2") 這行程式碼這裡,但非同步請求回來後,回撥任務居然直接被處理了,不等當前 <script> 程式碼塊執行結束就先行處理了回撥任務?

最後,我讓我一些同事幫忙測試了一下,在 chrome 上測試、在 jsfiddle 上測試,測試結果,基本上全部都是我上文中梳理的結論。

只有個別情況,行為比較特異,對前端我才剛入門,為什麼會有這種情況發生,有兩個猜想:

  • 不同瀏覽器對於執行 js 程式碼塊的行為不一致?
  • 不同瀏覽器對於 alert() 的處理不一致?

總之,最後,我還是覺得我本篇梳理出的結論比較符合大多數情況下的解釋,當然,沒有能力保證結論是正確的,大夥當個例子看就好,後續等能力有了,搞懂了相關的原理,再來重新梳理。

最後,如果你有不同的看法,歡迎指點一下哈~


大家好,我是 dasu,歡迎關注我的公眾號(dasuAndroidTv),公眾號中有我的聯絡方式,歡迎有事沒事來嘮嗑一下,如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~
dasuAndroidTv2.png