這篇文章就不再聊關於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在最初設計時設計成了單執行緒,為什麼不是多執行緒呢? 程式是作業系統分配資源和排程任務的基本單位,執行緒是建立在程式上的一次程式執行單位,一個程式上可以有多個執行緒。
以瀏覽器為例
- 使用者介面-包括位址列、前進/後退按鈕、書籤選單等
- 瀏覽器引擎-在使用者介面和呈現引擎之間傳送指令(瀏覽器的主程式)
- 渲染引擎,也被稱為瀏覽器核心(瀏覽器渲染程式)
- 一個外掛對應一個程式(第三方外掛程式)
- GPU提高網頁瀏覽的體驗(GPU程式)
由此可見瀏覽器是多程式的,並且從我們的角度來看我們更加關心主程式,也就是瀏覽器渲染引擎
而單獨看渲染引擎,內部又是多執行緒的,包含兩個最為重要的執行緒,即ui執行緒和js執行緒。而且ui執行緒和js執行緒是互斥的,因為JS執行結果會影響到ui執行緒的結果。
這裡也就回答了javascript為什麼是單執行緒得問題,試想一下,如果多個執行緒同時操作DOM那豈不會很混亂?
當然,這裡所謂的單執行緒指的是主執行緒,也就是渲染引擎是單執行緒的,同樣的,在Node中主執行緒也是單執行緒的。
既然說js單執行緒指的是主執行緒是單執行緒的,那麼還有哪些其他的執行緒呢?
- 瀏覽器事件觸發執行緒(用來控制事件迴圈,存放setTimeout、瀏覽器事件、ajax的回撥函式)
- 定時觸發器執行緒(setTimeout定時器所線上程)
- 非同步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任務源:
- setTimeout
- setInterval
- setImmediate (這是什麼東東?沒用過吧?沒用過很正常,因為它只相容ie)
- MessageChannel
- I/O
- 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任務源有:
- process.nextTick
- promises.then
- Object.observe
- MutationObserver
執行棧
task和microtask都是推入棧中執行的 來看下面一段程式碼:
function bar() {
console.log('bar');
}
function foo() {
console.log('foo');
bar();
}
foo();
複製程式碼
在規範的Processing model定義了event loop的迴圈過程: 一個event loop只要存在,就會不斷執行下邊的步驟:
- 在tasks佇列中選擇最老的一個task,使用者代理可以選擇任何task佇列,如果沒有可選的任務,則跳到下邊的microtasks步驟。
- 將上邊選擇的task設定為正在執行的task。
- Run: 執行被選擇的task。
- 將event loop的currently running task變為null。
- 從task佇列裡移除前邊執行的task。
- Microtasks: 執行microtasks任務檢查點。(也就是執行microtasks佇列裡的任務)
- 更新渲染(Update the rendering)...
- 如果這是一個worker event loop,但是沒有任務在task佇列中,並且WorkerGlobalScope物件的closing標識為true,則銷燬event loop,中止這些步驟,然後進行定義在Web workers章節的run a worker。
- 返回到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有
- process.nextTick
- promises
- Object.observe
- 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 ,如上;