前端入門20-JavaScript進階之非同步回撥的執行時機
宣告
本系列文章內容全部梳理自以下幾個來源:
- 《JavaScript權威指南》
- MDN web docs
- Github:smyhvae/web
- Github:goddyZhao/Translation/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),公眾號中有我的聯絡方式,歡迎有事沒事來嘮嗑一下,如果你覺得本篇內容有幫助到你,可以轉載但記得要關注,要標明原文哦,謝謝支援~