由哪個log先輸出引出的event loop思考

beth_miao發表於2018-05-15

這篇文章就不再聊關於promise的各種好處和用法了,如果不瞭解請自行Google啦!

我相信很多人在面試的時候遇到過這樣一道面試題:

console.log(0)
let p = Promise.resolve()
setTimeout(()=>{
    console.log(4);
    setTimeout(()=>{
        console.log(5);
    },0);
},0);
p.then(data=>{
    console.log(2);
    setTimeout(()=>{
        console.log(3);
    },0);
})
console.log(6)
複製程式碼

那麼你的答案是什麼呢? 貼上到chrome的控制檯裡執行一下,結果如下

// 0
// 6
// 2
// 4
// 3
// 5
複製程式碼

interesting的是,並不是在所有瀏覽器裡都是這樣的列印順序的,例如,在safari 9.1.2中測試,輸出卻這樣的:

// 0
// 6
// 4
// 2
// 5
// 3
複製程式碼

再放到safari 10.0.1中卻又得到了和chrome一樣的結果;

當然,這只是這道面試題的一個簡單版本喲!

那麼這道題到底在考察什麼呢?

其實,我相信很多同學都可以一眼看出0和6會先輸出,但是setTimeout和promise哪個先執行就有一丟丟小糾結了

再也不想為這樣的執行順序所困擾?讓我們先來了解一下js的event loop機制和promises的實現原理吧。

我們都知道promise是用來處理非同步的,也知道js是單執行緒的,那麼js的非同步是什麼呢? 這裡我們先明確一批概念,是的沒看錯,一批

js

ECMAScript + DOM + BOM 我們說js非同步背後的“靠山”就是event loops。 其實這裡的非同步準確的說應該叫瀏覽器的event loops或者說是javaScript執行環境的event loops,因為ECMAScript中沒有event loops, event loops是在HTML Standard定義的。

event loop

event loop也就是我們常說的事件迴圈,可以理解為實現非同步的一種方式,我們來看看event loop在HTML Standard中的定義:

為了協調事件,使用者互動,指令碼,渲染,網路等,使用者代理必須使用本節所述的event loop。

程式和執行緒

我們知道javascript在最初設計時設計成了單執行緒,為什麼不是多執行緒呢? 程式是作業系統分配資源和排程任務的基本單位,執行緒是建立在程式上的一次程式執行單位,一個程式上可以有多個執行緒。

以瀏覽器為例

  1. 使用者介面-包括位址列、前進/後退按鈕、書籤選單等
  2. 瀏覽器引擎-在使用者介面和呈現引擎之間傳送指令(瀏覽器的主程式)
  3. 渲染引擎,也被稱為瀏覽器核心(瀏覽器渲染程式)
  4. 一個外掛對應一個程式(第三方外掛程式)
  5. GPU提高網頁瀏覽的體驗(GPU程式)

由此可見瀏覽器是多程式的,並且從我們的角度來看我們更加關心主程式,也就是瀏覽器渲染引擎

而單獨看渲染引擎,內部又是多執行緒的,包含兩個最為重要的執行緒,即ui執行緒和js執行緒。而且ui執行緒和js執行緒是互斥的,因為JS執行結果會影響到ui執行緒的結果。

這裡也就回答了javascript為什麼是單執行緒得問題,試想一下,如果多個執行緒同時操作DOM那豈不會很混亂?

當然,這裡所謂的單執行緒指的是主執行緒,也就是渲染引擎是單執行緒的,同樣的,在Node中主執行緒也是單執行緒的。

既然說js單執行緒指的是主執行緒是單執行緒的,那麼還有哪些其他的執行緒呢?

  1. 瀏覽器事件觸發執行緒(用來控制事件迴圈,存放setTimeout、瀏覽器事件、ajax的回撥函式)
  2. 定時觸發器執行緒(setTimeout定時器所線上程)
  3. 非同步HTTP請求執行緒(ajax請求執行緒)

其他執行緒

單執行緒特點是節約了記憶體,並且不需要在切換執行上下文。而且單執行緒不需要管其他語言如java裡鎖的問題;

ps:這裡簡單說下鎖的概念。例如下課了大家都要去上廁所,廁所就一個,相當於所有人都要訪問同一個資源。那麼先進去的就要上鎖。而對於node來說。 下課了就一個人去廁所,所以免除了鎖的問題!

task (macrotask)

一個event loop有一個或者多個task佇列。

當使用者代理安排一個任務,必須將該任務增加到相應的event loop的一個tsak佇列中。

每一個task都來源於指定的任務源,比如可以為滑鼠、鍵盤事件提供一個task佇列,其他事件又是一個單獨的佇列。可以為滑鼠、鍵盤事件分配更多的時間,保證互動的流暢。

task也被稱為macrotask,task佇列還是比較好理解的,就是一個先進先出的佇列,由指定的任務源去提供任務。

哪些是task任務源呢?

規範在Generic task sources中有提及:

DOM操作任務源: 此任務源被用來相應dom操作,例如一個元素以非阻塞的方式插入文件。

使用者互動任務源: 此任務源用於對使用者互動作出反應,例如鍵盤或滑鼠輸入。響應使用者操作的事件(例如click)必須使用task佇列。

網路任務源: 網路任務源被用來響應網路活動。

history traversal任務源: 當呼叫history.back()等類似的api時,將任務插進task佇列。

總之,task任務源非常寬泛,比如ajax的onload,click事件,基本上我們經常繫結的各種事件都是task任務源,還有資料庫操作(IndexedDB ),需要注意的是setTimeout、setInterval、setImmediate也是task任務源。總結來說task任務源:

  1. setTimeout
  2. setInterval
  3. setImmediate (這是什麼東東?沒用過吧?沒用過很正常,因為它只相容ie)
  4. MessageChannel
  5. I/O
  6. UI rendering

microtask

每一個event loop都有一個microtask佇列,一個microtask會被排進microtask佇列而不是task佇列。

有兩種microtasks:分別是solitary callback microtasks和compound microtasks。規範值只覆蓋solitary callback microtasks。

如果在初期執行時,spin the event loop,microtasks有可能被移動到常規的task佇列,在這種情況下,microtasks任務源會被task任務源所用。通常情況,task任務源和microtasks是不相關的。

microtask 佇列和task 佇列有些相似,都是先進先出的佇列,由指定的任務源去提供任務,不同的是一個 event loop裡只有一個microtask 佇列。

HTML Standard沒有具體指明哪些是microtask任務源,通常認為是microtask任務源有:

  1. process.nextTick
  2. promises.then
  3. Object.observe
  4. MutationObserver

執行棧

task和microtask都是推入棧中執行的 來看下面一段程式碼:

  function bar() {
    console.log('bar');
  }

  function foo() {
    console.log('foo');
    bar();
  }

  foo();
複製程式碼

在規範的Processing model定義了event loop的迴圈過程: 一個event loop只要存在,就會不斷執行下邊的步驟:

  1. 在tasks佇列中選擇最老的一個task,使用者代理可以選擇任何task佇列,如果沒有可選的任務,則跳到下邊的microtasks步驟。
  2. 將上邊選擇的task設定為正在執行的task。
  3. Run: 執行被選擇的task。
  4. 將event loop的currently running task變為null。
  5. 從task佇列裡移除前邊執行的task。
  6. Microtasks: 執行microtasks任務檢查點。(也就是執行microtasks佇列裡的任務)
  7. 更新渲染(Update the rendering)...
  8. 如果這是一個worker event loop,但是沒有任務在task佇列中,並且WorkerGlobalScope物件的closing標識為true,則銷燬event loop,中止這些步驟,然後進行定義在Web workers章節的run a worker。
  9. 返回到1

主執行緒之外,還存在一個任務佇列,用來放置microtask。

簡單來說,event loop會不斷迴圈的去取tasks佇列的中最老的一個任務推入棧中執行,當次迴圈同步任務執行結束之後檢查是否存在microtasks佇列,如果有microtasks則先執行microtasks,執行結束清空microtasks棧,把下一個task放入執行棧內,如此迴圈。

說了這麼多關於event loop的東西,好像跟開篇的面試題並沒有什麼關係啊?

彆著急,下面我們聊一下promise的實現; 我們知道,promise是屬於es6的,在以前瀏覽器並不支援,也就衍生了各家諸如bluebird,q,when等promise庫,這些promise庫的實現方式不盡相同,但都遵循Promises/A+規範;

其中2.2.4就是:

onFulfilled or onRejected must not be called until the execution context stack contains only platform code. [3.1].

這就意味著,在實現promise時,onFulfilled和onRejected要在新的執行上下文裡才能執行;

而在3.1中提及了

This can be implemented with either a “macro-task” mechanism such as setTimeout or setImmediate, or with a “micro-task” mechanism such as MutationObserver or process.nextTick.

即promise的then方法可以採用“巨集任務(macro-task)”機制或者“微任務(micro-task)”機制來實現。有的瀏覽器將then放入了macro-task佇列,有的放入了micro-task 佇列。開頭列印順序不同也正是源於此,不過一個普遍的共識是promises屬於microtasks佇列。

那麼我們就來簡單看一下promise的“巨集任務(macro-task)”機制實現:

class Promise {
  constructor(executor) {
    this.status = 'pending';
    this.value = undefined;
    this.reason = undefined;
    this.onResolvedCallbacks = [];
    this.onRejectedCallbacks = [];
    let resolve = (data) => {
      if (this.status === 'pending') {
        this.value = data;
        this.status = 'resolved';
        this.onResolvedCallbacks.forEach(fn => fn());
      }
    }
    let reject = (reason) => {
      if (this.status === 'pending') {
        this.reason = reason;
        this.status = 'rejected';
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    }
    try {
      executor(resolve, reject);
    } catch (e) {
      reject(e);
    }
  }
  then(onFulFilled, onRejected) {
    onFulFilled = typeof onFulFilled === 'function' ? onFulFilled : y => y;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };
    let promise2;
    if (this.status === 'resolved') {
      promise2 = new Promise((resolve, reject) => {
        setTimeout(() => {  //“巨集任務(macro-task)”機制實現
          try {
            let x = onFulFilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      });
    }
    if (this.status === 'rejected') {
      promise2 = new Promise((resolve, reject) => {
        setTimeout(() => {  //“巨集任務(macro-task)”機制實現
          try {
            let x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject)
          } catch (e) {
            reject(e);
          }
        }, 0);
      });
    }
    if (this.status === 'pending') {
      promise2 = new Promise((resolve, reject) => {
        this.onResolvedCallbacks.push(() => {
          setTimeout(() => {
            try {
              let x = onFulFilled(this.value);
              resolvePromise(promise2, x, resolve, reject)
            } catch (e) {
              reject(e);
            }
          }, 0)
        });
        // 存放失敗的回撥
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              let x = onRejected(this.reason);
              resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
      })
    }
    return promise2; // 呼叫then後返回一個新的promise
  }
  // catch接收的引數 只用錯誤
  catch(onRejected) {
    // catch就是then的沒有成功的簡寫
    return this.then(null, onRejected);
  }
}
複製程式碼

沒錯我們看到了setTimeout; 這種就是通過macro-task機制實現的,列印出來的順序就是如在safari 9.1.2中一樣了。 測試了一下bluebird的promise的實現,輸出的結果又和上面的都不一樣:

// 0
// 6
// 4
// 2
// 5
// 3
複製程式碼

所以到底哪個先輸出,要看你所使用的promise的實現方式;

當然正如上面提到的一個普遍的共識是promises屬於microtasks佇列,所以一般情況下,promise.then並不是上面的這種實現,而是mic-task機制;

那麼再來看開篇的題目

console.log(0)      // 同步
let p = Promise.resolve();
setTimeout(()=>{    // 非同步 macrotask
    console.log(4);
    setTimeout(()=>{
        console.log(5); // 非同步 macrotask
    },0);
},0);
p.then(data=>{      // 非同步 (通過macro-task實現則為macrotask,通過micro-task實現則為microtask)
    console.log(2);
    setTimeout(()=>{      // 非同步 macrotask
        console.log(3);
    },0);
})
console.log(6)  // 同步
複製程式碼

這樣就很清晰了對吧

上面有列出microtask有

  1. process.nextTick
  2. promises
  3. Object.observe
  4. MutationObserver

不知道用過vue1.0的同學有沒有了解過vue1.0的nextTick是如何實現的呢?

有興趣可以看一下原始碼,就是通過MutationObserver實現的,只是因為相容問題已經被取代了;

沒用過MutationObserver?沒關係,我們舉一個簡單的例子 假如我們要往一個id為parent的dom中新增元素,我們期望所有的新增操作都完成才執行我們的回撥 如下

    let observe = new MutationObserver(function () {
          console.log('dom全部塞進去了');
    });
    // 一個微任務
    observe.observe(parent,{childList:true});
    for (let i = 0; i < 100; i++) {
      let p = document.createElement('p');
      div.appendChild(p);
    }
    console.log(1);
    let img = document.createElement('p');
    div.appendChild(img);
複製程式碼

That's all ,如上;

references

從event loop規範探究javaScript非同步及瀏覽器更新渲染時機

Promises/A+

webappapis L

相關文章