【 js 基礎 】 setTimeout(fn, 0) 的作用

李佳怡發表於2017-09-20
在 zepto 原始碼中,$.fn 物件 有個 ready 函式,其中有這樣一句 setTimeout(fn,0);

$.fn = {
    ready: function(callback){
      // dont use "interactive" on IE <= 10 (it can fired premature)
      //
      // document.readyState:當document文件正在載入時,返回"loading"。當文件結束渲染但在載入內嵌資源時,返回"interactive",並引發DOMContentLoaded事件。當文件載入完成時,返回"complete",並引發load事件。
      // document.documentElement.doScroll:IE有個特有的方法doScroll可以檢測DOM是否載入完成。 當頁面未載入完成時,該方法會報錯,直到doScroll不再報錯時,就代表DOM載入完成了
      if (document.readyState === "complete" ||
          (document.readyState !== "loading" && !document.documentElement.doScroll))
        setTimeout(function(){ callback($) }, 0)   //  重點
      else {
        // 監聽移除事件
        var handler = function() {
          document.removeEventListener("DOMContentLoaded", handler, false)
          window.removeEventListener("load", handler, false)
          callback($)
        }
        document.addEventListener("DOMContentLoaded", handler, false)
        window.addEventListener("load", handler, false)
      }
      return this;
    },
}複製程式碼

時間設為 0 ,就是要立即執行,那為什麼還要特意將 fn 套到 setTimeout 裡面呢?


一、執行緒
1、瀏覽器的核心是多執行緒的,它們在核心控制下相互配合以保持同步,一個瀏覽器通常由以下常駐執行緒組成:GUI 渲染執行緒,javascript 引擎執行緒,瀏覽器事件觸發執行緒,定時觸發器執行緒,非同步 http 請求執行緒。

  • GUI 渲染執行緒:負責渲染瀏覽器介面 HTML 元素,當介面需要重繪(Repaint)或由於某種操作引發迴流(reflow)時,該執行緒就會執行。在 Javascript 引擎執行指令碼期間, GUI 渲染執行緒都是處於掛起狀態的,也就是說被”凍結”。即 GUI 渲染執行緒與 JS 引擎是互斥的,當JS引擎執行時GUI執行緒會被掛起,GUI 更新會被儲存在一個佇列中等到 JS 引擎空閒時立即被執行。
  • javascript 引擎執行緒:也可以稱為 JS 核心,主要負責處理 Javascript 指令碼程式,例如 V8 引擎。Javascript 引擎執行緒理所當然是負責解析 Javascript 指令碼,執行程式碼。瀏覽器無論什麼時候都只有一個 JS 執行緒在執行 JS 程式。
  • 瀏覽器事件觸發執行緒:當一個事件被觸發時該執行緒會把事件新增到待處理佇列的隊尾,等待 JS 引擎的處理。這些事件可以是當前執行的程式碼塊如定時任務、也可來自瀏覽器核心的其他執行緒如滑鼠點選、AJAX 非同步請求等,但由於JS的單執行緒關係所有這些事件都得排隊等待 JS 引擎處理。
  • 定時觸發器執行緒:瀏覽器定時計數器並不是由 JavaScript 引擎計數的, 因為 javaScript 引擎是單執行緒的, 如果處於阻塞執行緒狀態就會影響記計時的準確, 因此通過單獨執行緒來計時並觸發定時是更為合理的方案。
  • 非同步 http 請求執行緒:在 XMLHttpRequest 在連線後是通過瀏覽器新開一個執行緒請求, 將檢測到狀態變更時,如果設定有回撥函式,非同步執行緒就產生狀態變更事件放到 JavaScript 引擎的處理佇列中等待處理。

舉個例子,看看這些執行緒如何配合工作的:

例子1:非同步請求是由執行緒 JavaScript 執行執行緒、HTTP 請求執行緒 和 事件觸發執行緒 共同完成的。JavaScript 執行執行緒 執行非同步請求程式碼,這時瀏覽器會開一條新的 HTTP 請求執行緒 來執行請求,JavaScript 執行執行緒則繼續執行 執行佇列 中剩下的其他任務。然後在未來的某一時刻 事件觸發執行緒 監視到之前的發起的 HTTP 請求已完成,它就會把完成事件的回撥程式碼插入到 JavaScript 執行佇列尾部 等待 JavaScript 執行執行緒空閒時來處理。

例子2:定時觸發(setTimeout 和 setInterval)是由瀏覽器的 定時器執行緒 執行的定時計數,然後在定時時間結束時把定時處理函式的執行程式碼插入到 JavaScript 執行佇列的尾端(所以用這兩個函式的時候,實際的執行時間是大於或等於指定時間的,不保證能準確定時的)。

2、javascript 是單執行緒的,同一個時間只能做一件事。

這裡說一下 js呼叫棧(call stack),可以從根本上理解單執行緒的執行過程。
推薦一個神器網站:Loupe 可以用來圖形化呼叫棧的過程,大家可以把例子在網站上執行一下,好用到瘋掉。

js 呼叫棧(call stack):函式被呼叫時,就會被加入到呼叫棧頂部,執行結束之後,就會從呼叫棧頂部移除該函式,這種資料結構的關鍵在於後進先出,即 LIFO(last-in,first-out)。

舉個例子:
來自(併發模型與Event Loop) 

【 js 基礎 】 setTimeout(fn, 0) 的作用

function f(b) {
    var a = 12;
    return a + b + 35;
}
function g(x) {
    var m = 4;
    return f(m * x);
}
g(21);複製程式碼

呼叫 g 函式 的時候,建立了第一個 堆( Heap ) 棧(stack) 幀 ,包含了 g 的引數和區域性變數。當 g 呼叫 f 的時候,第二個 堆疊幀 就被建立、並置於第一個 堆疊幀 之上,包含了 f 的引數和區域性變數。當 f 返回時,最上層的 堆疊幀 就出棧了(剩下 g 函式呼叫的 堆疊幀 )。當 g 返回的時候,棧就空了。

再舉個例子:

function test() {
    setTimeout(function() {
        alert(1)
    },1000);
    alert(2);
}
test();複製程式碼

在執行函式 test 的時候,test 先入棧,如果不給 alert(1)加 setTimeout,那麼 alert(1)第 2 個入棧,最後是 alert(2)。但現在給 alert(1)加上 setTimeout 後,alert(1)就被加入到了一個新的堆疊中等待,並1s後執行,因此實際的執行結果就是先 alert(2),再 alert(1)。

3、任務佇列(訊息佇列):

  • 函式分為兩種:同步和非同步。

    同步函式:如果在函式A返回的時候,呼叫者就能夠得到預期結果(即拿到了預期的返回值或者看到了預期的效果),那麼這個函式就是同步的。

            例子:

console.log('Hi’);   //函式返回時,就看到了預期的效果:在控制檯列印了一個字串複製程式碼

          非同步函式:即如果在函式A返回的時候,呼叫者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那麼這個函式就是非同步的。
    例子:

setTimeout(fn, 1000);//setTimeout是非同步過程的發起函式,fn是回撥函式。
複製程式碼

  • 任務也分為兩種:同步任務和非同步任務。

    同步任務:在主執行緒上排隊執行的任務,只有前一個任務執行完畢,才能執行後一個任務。
    非同步任務:主執行緒發起一個非同步請求(即執行非同步函式),相應的工作執行緒(瀏覽器事件觸發執行緒、非同步http請求執行緒等)接收請求並告知主執行緒已收到(非同步函式返回);主執行緒可以繼續執行後面的程式碼,同時工作執行緒執行非同步任務;工作執行緒完成工作後,將完成訊息放到任務(訊息)佇列,主執行緒通過事件迴圈過程去取任務(訊息),然後執行一定的動作(呼叫回撥函式)。

    圖中主執行緒即 Stack,任務佇列即 Queue。

【 js 基礎 】 setTimeout(fn, 0) 的作用

  • 任務佇列:任務(訊息)佇列是一個先進先出的佇列,它裡面存放著各種任務(訊息)。
  • 事件迴圈(event loop):事件迴圈是指主執行緒重複從任務(訊息)佇列中取任務(訊息)、執行的過程。取一個任務(訊息)並執行的過程叫做一次迴圈。

    事件迴圈中有事件兩個字的原因:任務(訊息)佇列中的每條訊息實際上都對應著一個事件——dom事件。
    例子:

var button = document.getElement('#btn');
button.addEventListener('click',function(e) {
      console.log(1);
});複製程式碼

從非同步過程的角度看,addEventListener 函式就是非同步過程的發起函式,事件監聽器函式就是非同步過程的回撥函式。事件觸發時,表示非同步任務完成,會將事件監聽器函式封裝成一條訊息放到訊息佇列中,等待主執行緒執行。


那麼 任務(訊息)到底是什麼呢? 任務(訊息)就是註冊非同步任務時新增的回撥函式。如果 一個非同步函式沒有回撥,那麼他就不會放到任務(訊息)佇列裡。


總結一下過程:主執行緒在執行完當前迴圈中的所有程式碼後,就會到任務(訊息)佇列取出一條訊息,並執行它。到此為止,就完成了工作執行緒對主執行緒的通知,回撥函式也就得到了執行。如果一開始主執行緒就沒有提供回撥函式,工作執行緒就沒必要通知主執行緒,從而也沒必要往訊息佇列放訊息。

例子: 工作執行緒為非同步 http 請求執行緒即 Ajax 執行緒

【 js 基礎 】 setTimeout(fn, 0) 的作用

最後注意非同步過程的回撥函式,一定不在當前這一輪事件迴圈中執行。而是當 這一輪執行完了,主執行緒空了,再從任務(訊息)佇列中取。

再來看一下這張圖

【 js 基礎 】 setTimeout(fn, 0) 的作用

主執行緒執行的時候,產生堆(heap)和棧(stack),棧中的程式碼呼叫各種外部API,它們在"任務佇列"中加入各種事件(click,load,done)。只要棧中的程式碼執行完畢,主執行緒就會去讀取"任務佇列",依次執行那些事件所對應的回撥函式。


三、setTimeout(fn, 0) 的作用

呼叫 setTimeout 函式會在一個時間段過去後在佇列中新增一個訊息。這個時間段作為函式的第二個引數被傳入。如果佇列中沒有其它訊息,訊息會被馬上處理。但是,如果有其它訊息,setTimeout 訊息必須等待其它訊息處理完。因此第二個引數僅僅表示最少的時間,而非確切的時間。

零延遲 (Zero delay) 並不是意味著回撥會立即執行。在零延遲呼叫 setTimeout 時,其並不是過了給定的時間間隔後就馬上執行回撥函式。其等待的時間基於佇列里正在等待的訊息數量。也就是說,setTimeout()只是將事件插入了任務佇列,必須等到當前程式碼(執行棧)執行完,主執行緒才會去執行它指定的回撥函式。要是當前程式碼耗時很長,有可能要等很久,所以並沒有辦法保證回撥函式一定會在setTimeout()指定的時間執行。

例子

setTimeout(function() {
     console.log(1);
 },0);
 console.log(2)複製程式碼

執行結果2,1。因為只有在執行完第二行以後,主執行緒空了,才會去任務佇列中取任務執行回撥函式。

總結:setTimeout(fn,0)的含義是,指定某個任務在主執行緒最早可得的空閒時間執行,也就是說,儘可能早得執行。它在"任務佇列"的尾部新增一個事件,因此要等到主執行緒把同步任務和"任務佇列"現有的事件都處理完,才會得到執行。
在某種程度上,我們可以利用setTimeout(fn,0)的特性,修正瀏覽器的任務順序。



catch me:

知乎:李佳怡

知乎專欄:李佳怡的紙糊


相關文章