我覺得我很懂Promise,直到我開始實現Promise/A+規範

我一度覺得本身很懂Promise,直到前段時間嘗試去實現Promise/A+規範時,才發現本身對Promise的理解還過於淺薄。在我按照Promise/A+規範去寫具體代碼實現的過程當中,我經歷了從「很懂」到「陌生」,再到「領會」的過山車式的認知轉變,對Promise有了更深入的認識!javascript

TL;DR:鑑於不少人不想看長文,這裏直接給出我寫的Promise/A+規範的Javascript實現。html

promises-tests測試用例是所有經過的。前端

Promise源於現實世界

Promise直譯過來就是承諾,最新的紅寶書已經將其翻譯爲期約。固然,這都不重要,程序員之間只要一個眼神就懂了。java

你懂的

許下承諾

做爲打工人,咱們不可避免地會接到各類餅,好比口頭吹捧的餅、升值加薪的餅、股權激勵的餅......ios

有些餅立刻就兌現了,好比口頭褒獎,由於它自己沒有給企業帶來什麼成本;有些餅卻關乎企業實際利益,它們可能將來可期,也可能猴年馬月,或是無疾而終,又或者直接宣告畫餅失敗。git

畫餅這個動做,於Javascript而言,就是建立一個Promise實例:程序員

const bing = new Promise((resolve, reject) => {
  // 祝各位的餅都能圓滿成功
  if ('畫餅成功') {
    resolve('你們happy')
  } else {
    reject('有難同當')
  }
})

Promise跟這些餅很像,分爲三種狀態:github

  • pending: 餅已畫好,坐等實現。
  • fulfilled: 餅真的實現了,走上人生巔峯。
  • rejected: 很差意思,畫餅失敗,emmm...

訂閱承諾

有人畫餅,天然有人接餅。所謂「接餅」,就是對於這張餅的可能性作下設想。若是餅真的實現了,鄙人將別墅靠海;若是餅失敗了,本打工仔以淚洗面。web

轉換成Promise中的概念,這是一種訂閱的模式,成功和失敗的狀況咱們都要訂閱,並做出反應。訂閱是經過thencatch等方法實現的。ajax

// 經過then方法進行訂閱
bing.then(
  // 對畫餅成功的狀況做出反應
  success => {
    console.log('別墅靠海')
  },
  // 對畫餅失敗的狀況做出反應
  fail => {
    console.log('以淚洗面...')
  }
)

鏈式傳播

衆所周知,老闆能夠給高層或領導們畫餅,而領導們拿着老闆畫的餅,也必須給底下員工繼續畫餅,讓打工人們雞血不停,這樣你們的餅才都有可能兌現。

這種自上而下發餅的行爲與Promise的鏈式調用在思路上不謀而合。

bossBing.then(
  success => {
    // leader接過boss的餅,繼續往下面發餅
    return leaderBing
  }
).then(
  success => {
    console.log('leader畫的餅真的實現了,別墅靠海')
  },
  fail => {
    console.log('leader畫的餅炸了,以淚洗面...')
  }
)

整體來講,Promise與現實世界的承諾仍是挺類似的。

而Promise在具體實現上還有不少細節,好比異步處理的細節,Resolution算法,等等,這些在後面都會講到。下面我會從本身對Promise的第一印象講起,繼而過渡到對宏任務與微任務的認識,最終揭開Promise/A+規範的神祕面紗。

初識Promise

還記得最先接觸Promise的時候,我感受能把ajax過程封裝起來就挺「厲害」了。那個時候對Promise的印象大概就是:優雅的異步封裝,再也不須要寫高耦合的callback

這裏臨時手擼一個簡單的ajax封裝做爲示例說明:

function isObject(val) {
  return Object.prototype.toString.call(val) === '[object Object]';
}

function serialize(params) {
    let result = '';
    if (isObject(params)) {
      Object.keys(params).forEach((key) => {
        let val = encodeURIComponent(params[key]);
        result += `${key}=${val}&`;
      });
    }
    return result;
}

const defaultHeaders = {
  "Content-Type": "application/x-www-form-urlencoded"
}

// ajax簡單封裝
function request(options) {
  return new Promise((resolve, reject) => {
    const { method, url, params, headers } = options
    const xhr = new XMLHttpRequest();
    if (method === 'GET' || method === 'DELETE') {
      // GET和DELETE通常用querystring傳參
      const requestURL = url + '?' + serialize(params)
      xhr.open(method, requestURL, true);
    } else {
      xhr.open(method, url, true);
    }
    // 設置請求頭
    const mergedHeaders = Object.assign({}, defaultHeaders, headers)
    Object.keys(mergedHeaders).forEach(key => {
      xhr.setRequestHeader(key, mergedHeaders[key]);
    })
    // 狀態監聽
    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          resolve(xhr.response)
        } else {
          reject(xhr.status)
        }
      }
    }
    xhr.onerror = function(e) {
      reject(e)
    }
    // 處理body數據,發送請求
    const data = method === 'POST' || method === 'PUT' ? serialize(params) : null
    xhr.send(data);
  })
}

const options = {
  method: 'GET',
  url: '/user/page',
  params: {
    pageNo: 1,
    pageSize: 10
  }
}
// 經過Promise的形式調用接口
request(options).then(res => {
  // 請求成功
}, fail => {
  // 請求失敗
})

以上代碼封裝了ajax的主要過程,而其餘不少細節和各類場景覆蓋就不是幾十行代碼能說完的。不過咱們能夠看到,Promise封裝的核心就是:

  • 封裝一個函數,將包含異步過程的代碼包裹在構造Promise的executor中,所封裝的函數最後須要return這個Promise實例。
  • Promise有三種狀態,Pending, Fulfilled, Rejected。而resolve(), reject()是狀態轉移的觸發器。
  • 肯定狀態轉移的條件,在本例中,咱們認爲ajax響應且狀態碼爲200時,請求成功(執行resolve()),不然請求失敗(執行reject())。

ps: 實際業務中,除了判斷HTTP狀態碼,咱們還會另外判斷內部錯誤碼(業務系統中先後端約定的狀態code)。

實際上如今有了axios這類的解決方案,咱們也不會輕易選擇自行封裝ajax,不鼓勵重複造這種基礎且重要的輪子,更別說有些場景咱們每每難以考慮周全。固然,在時間容許的狀況下,能夠學習其源碼實現。

宏任務與微任務

要理解Promise/A+規範,必須先溯本求源,Promise與微任務息息相關,因此咱們有必要先對宏任務和微任務有個基本認識。

在很長一段時間裏,我都沒有太多去關注宏任務(Task)與微任務(Microtask)。甚至有一段時間,我以爲setTimeout(fn, 0)在操做動態生成的DOM元素時很是好用,然而並不知道其背後的原理,實質上這跟Task聯繫緊密。

var button = document.createElement('button');
button.innerText = '新增輸入框'
document.body.append(button)

button.onmousedown = function() {
  var input = document.createElement('input');
  document.body.appendChild(input);
  setTimeout(function() {
    input.focus();
  }, 0)
}

若是不使用setTimeout 0focus()會沒有效果。

那麼,什麼是宏任務和微任務呢?咱們慢慢來揭開答案。

現代瀏覽器採用多進程架構,這一點能夠參考Inside look at modern web browser。而和咱們前端關係最緊密的就是其中的Renderer Process,Javascript即是運行在Renderer Process的Main Thread中。

Renderer: Controls anything inside of the tab where a website is displayed.

渲染進程控制了展現在Tab頁中的網頁的一切事情。能夠理解爲渲染進程就是專門爲具體的某個網頁服務的。

咱們知道,Javascript能夠直接與界面交互。假想一下,若是Javascript採用多線程策略,各個線程都能操做DOM,那最終的界面呈現到底以誰爲準呢?這顯然是存在矛盾的。所以,Javascript選擇使用單線程模型的一個重要緣由就是:爲了保證用戶界面的強一致性

爲了保證界面交互的連貫性和平滑度,Main Thread中,Javascript的執行和頁面的渲染會交替執行(出於性能考慮,某些狀況下,瀏覽器判斷不須要執行界面渲染,會略過渲染的步驟)。目前大多數設備的屏幕刷新率爲60次/秒,1幀大約是16.67ms,在這1幀的週期內,既要完成Javascript的執行,還要完成界面的渲染(if necessary),利用人眼的殘影效應,讓用戶以爲界面交互是很是流暢的。

用一張圖看看1幀的基本過程,引用自https://aerotwist.com/blog/the-anatomy-of-a-frame/

解剖1幀

PS:requestIdleCallback是空閒回調,在1幀的末尾,若是還有時間富餘,就會調用requestIdleCallback。注意不要在requestIdleCallback中修改DOM,或者讀取佈局信息致使觸發Forced Synchronized Layout,不然會引起性能和體驗問題。具體見Using requestIdleCallback

咱們知道,一個網頁中的Render Process只有一個Main Thread,本質上來講,Javascript的任務在執行階段都是按順序執行,可是JS引擎在解析Javascript代碼時,會把代碼分爲同步任務和異步任務。同步任務直接進入Main Thread執行;異步任務進入任務隊列,並關聯着一個異步回調。

在一個web app中,咱們會寫一些Javascript代碼或者引用一些腳本,用做應用的初始化工做。在這些初始代碼中,會按照順序執行其中的同步代碼。而在這些同步代碼執行的過程當中,會陸陸續續監聽一些事件或者註冊一些異步API(網絡相關,IO相關,等等...)的回調,這些事件處理程序和回調就是異步任務,異步任務會進入任務隊列,而且在接下來的Event Loop中被處理。

異步任務又分爲TaskMicrotask,各自有單獨的數據結構和內存來維護。

用一個簡單的例子來感覺下:

var a = 1;
console.log('a:', a)
var b = 2;
console.log('b:', b)
setTimeout(function task1(){
  console.log('task1:', 5)
  Promise.resolve(6).then(function microtask2(res){
    console.log('microtask2:', res)
  })
}, 0)
Promise.resolve(4).then(function microtask1(res){
  console.log('microtask1:', res)
})
var b = 3;
console.log('c:', c)

以上代碼執行後,依次在控制檯輸出:

a: 1
b: 2
c: 3
microtask1: 4
task1: 5
microtask2: 6

仔細一看也沒什麼難的,可是這背後發生的細節,仍是有必要探究下。咱們不妨先問本身幾個問題,一塊兒來看下吧。

Task和Microtask都有哪些?

  • Tasks:
    • setTimeout
    • setInterval
    • MessageChannel
    • I/0(文件,網絡)相關API
    • DOM事件監聽:瀏覽器環境
    • setImmediate:Node環境,IE好像也支持(見caniuse數據)
  • Microtasks:
    • requestAnimationFrame:瀏覽器環境
    • MutationObserver:瀏覽器環境
    • Promise.prototype.then, Promise.prototype.catch, Promise.prototype.finally
    • process.nextTick:Node環境
    • queueMicrotask

requestAnimationFrame是否是微任務?

requestAnimationFrame簡稱rAF,常常被咱們用來作動畫效果,由於其回調函數執行頻率與瀏覽器屏幕刷新頻率保持一致,也就是咱們一般說的它能實現60FPS的效果。在rAF被大範圍應用前,咱們常用setTimeout來處理動畫。可是setTimeout在主線程繁忙時,不必定能及時地被調度,從而出現卡頓現象。

那麼rAF屬於宏任務或者微任務嗎?其實不少網站都沒有給出定義,包括MDN上也描述得很是簡單。

咱們不妨本身問問本身,rAF是宏任務嗎?我想了一下,顯然不是,rAF能夠用來代替定時器動畫,怎麼能和定時器任務同樣被Event Loop調度呢?

我又問了問本身,rAF是微任務嗎?rAF的調用時機是在下一次瀏覽器重繪以前,這看起來和微任務的調用時機差很少,曾讓我一度認爲rAF是微任務,而實際上rAF也不是微任務。爲何這麼說呢?請運行下這段代碼。

function recursionRaf() {
	requestAnimationFrame(() => {
        console.log('raf回調')
        recursionRaf()
    })
}
recursionRaf();

你會發現,在無限遞歸的狀況下,rAF回調正常執行,瀏覽器也可正常交互,沒有出現阻塞的現象。

遞歸rAF並無阻塞

而若是rAF是微任務的話,則不會有這種待遇。不信你能夠翻到後面一節內容「若是Microtask執行時又建立了Microtask,怎麼處理?」。

因此,rAF的任務級別是很高的,擁有單獨的隊列維護。在瀏覽器1幀的週期內,rAF與Javascript執行,瀏覽器重繪是同一個Level的。(其實,你們在前面那張「解剖1幀」的圖中也能看出來了。)

Task和Microtask各有1個隊列?

最初,我認爲既然瀏覽器區分了Task和Microtask,那就只要各自安排一個隊列存儲任務便可。事實上,Task根據task source的不一樣,安排了獨立的隊列。好比Dom事件屬於Task,可是Dom事件有不少種類型,爲了方便user agent細分Task並精細化地安排各類不一樣類型Task的處理優先級,甚至作一些優化工做,必須有一個task source來區分。同理,Microtask也有本身的microtask task source。

具體解釋見HTML標準中的一段話:

Essentially, task sources are used within standards to separate logically-different types of tasks, which a user agent might wish to distinguish between. Task queues *are used by user agents to coalesce task sources within a given event loop。

Task和Microtask的消費機制是怎樣的?

An event loop has one or more task queues. A task queue is a set of tasks.

javascript是事件驅動的,因此Event Loop是異步任務調度的核心。雖然咱們一直說任務隊列,可是Tasks在數據結構上不是隊列(Queue),而是集合(Set)。在每一輪Event Loop中,會取出第一個runnable的Task(第一個可執行的Task,並不必定是順序上的第一個Task)進入Main Thread執行,而後再檢查Microtask隊列並執行隊列中全部Microtask。

說再多,都不如一張圖直觀,請看!

event loop

Task和Microtask何時進入相應隊列?

回過頭來看,咱們一直在提這個概念「異步任務進入隊列」,那麼就有個疑問,Task和Microtask究竟是何時進入相應的隊列?咱們從新來捋捋。異步任務有註冊進隊列回調被執行這三個關鍵行爲。註冊很好理解,表明這個任務被建立了;而回調被執行則表明着這個任務已經被主線程撈起並執行了。可是,在進隊列這一行爲上,宏任務和微任務的表現是不同的。

宏任務進隊列

對於Task而言,任務註冊時就會進入隊列,只是任務的狀態還不是runnable,不具有被Event Loop撈起的條件。

咱們先用Dom事件爲例舉個例子。

document.body.addEventListener('click', function(e) {
    console.log('被點擊了', e)
})

addEventListener這行代碼被執行時,任務就註冊了,表明有一個用戶點擊事件相關的Task進入任務隊列。那麼這個宏任務何時才變成runnable呢?固然是用戶點擊發生而且信號傳遞到瀏覽器Render Process的Main Thread後,此時宏任務變成runnable狀態,才能夠被Event Loop撈起,進入Main Thread執行。

這裏再舉個例子,順便解釋下爲何setTimeout 0會有延遲。

setTimeout(function() {
	console.log('我是setTimeout註冊的宏任務')
}, 0)

執行setTimeout這行代碼時,相應的宏任務就被註冊了,而且Main Thread會告知定時器線程,「你定時0毫秒後給我一個消息」。定時器線程收到消息,發現只要等待0毫秒,立馬就給Main Thread一個消息,「我這邊已通過了0毫秒了」。Main Thread收到這個回覆消息後,就把相應宏任務的狀態置爲runnable,這個宏任務就能夠被Event Loop撈起了。

能夠看到,通過這樣一個線程間通訊的過程,即使是延時0毫秒的定時器,其回調也並非在真正意義上的0毫秒以後執行,由於通訊過程就須要耗費時間。網上有個觀點說setTimeout 0的響應時間最少是4ms,其實也是有依據的,不過也是有條件的。

HTML Living Standard: If nesting level is greater than 5, and timeout is less than 4, then set timeout to 4.

對於這種說法,我以爲本身有個概念就行,不一樣瀏覽器在實現規範的細節上確定不同,具體通訊過程也不詳,是否是4ms也很差說,關鍵是你有沒有搞清楚這背後經歷了什麼。

微任務進隊列

前面咱們提到一個觀點,執行完一個Task後,若是Microtask隊列不爲空,會把Microtask隊列中全部的Microtask都取出來執行。我認爲,Microtask不是在註冊時就進入Microtask隊列,由於Event Loop處理Microtask隊列時,並不會判斷Microtask的狀態。反過來想,若是Microtask在註冊時就進入Microtask隊列,就會存在Microtask還未變爲runnable狀態就被執行的狀況,這顯然是不合理的。個人觀點是,Microtask在變爲runnable狀態時才進入Microtask隊列。

那麼咱們來分析下Microtask何時變成runnable狀態,首先來看看Promise。

var promise1 = new Promise((resolve, reject) => {
    resolve(1);
})
promise1.then(res => {
    console.log('promise1微任務被執行了')
})

讀者們,個人第一個問題是,Promise的微任務何時被註冊?new Promise的時候?仍是何時?不妨來猜一猜!

答案是.then被執行的時候。(固然,還有.catch的狀況,這裏只是就這個例子說)。

那麼Promise微任務的狀態何時變成runnable呢?相信很多讀者已經有了頭緒了,沒錯,就是Promise狀態發生轉移的時候,在本例中也就是resolve(1)被執行的時候,Promise狀態由pending轉移爲fulfilled。在resolve(1)執行後,這個Promise微任務就進入Microtask隊列了,而且將在本次Event Loop中被執行。

基於這個例子,咱們再來加深下難度。

var promise1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(1);
    }, 0);
});
promise1.then(res => {
    console.log('promise1微任務被執行了');
});

在這個例子中,Promise微任務的註冊進隊列並不在同一次Event Loop。怎麼說呢?在第一個Event Loop中,經過.then註冊了微任務,可是咱們能夠發現,new Promise時,執行了一個setTimeout,這是至關於註冊了一個宏任務。而resolve(1)必須在宏任務被執行時纔會執行。很明顯,二者中間隔了至少一次Event Loop。

若是能分析Promise微任務的過程,你天然就知道怎麼分析ObserverMutation微任務的過程了,這裏再也不贅述。

若是Microtask執行時又建立了Microtask,怎麼處理?

咱們知道,一次Event Loop最多隻執行一個runnable的Task,可是會執行Microtask隊列中的全部Microtask。若是在執行Microtask時,又建立了新的Microtask,這個新的Microtask是在下次Event Loop中被執行嗎?答案是否認的。微任務能夠添加新的微任務到隊列中,並在下一個任務開始執行以前且當前Event Loop結束以前執行完全部的微任務。請注意不要遞歸地建立微任務,不然會陷入死循環。

下面就是一個糟糕的示例。

// bad case
function recursionMicrotask() {
	Promise.resolve().then(() => {
		recursionMicrotask()
	})
}
recursionMicrotask();

請不要輕易嘗試,不然頁面會卡死哦!(由於Microtask佔着Main Thread不釋放,瀏覽器渲染都沒辦法進行了)

爲何要區分Task和Microtask?

這是一個很是重要的問題。爲何不在執行完Task後,直接進行瀏覽器渲染這一步驟,而要再加上執行Microtask這一步呢?其實在前面的問題中已經解答過了。一次Event Loop只會消費一個宏任務,而微任務隊列在被消費時有「繼續上車」的機制,這就讓開發者有了更多的想象力,對代碼的控制力會更強。

作幾道題熱熱身?

在衝擊Promise/A+規範前,不妨先用幾個習題來測試下本身對Promise的理解程度。

基本操做

function mutationCallback(mutationRecords, observer) {
    console.log('mt1')
}

const observer = new MutationObserver(mutationCallback)
observer.observe(document.body, { attributes: true })

Promise.resolve().then(() => {
    console.log('mt2')
    setTimeout(() => {
        console.log('t1')
    }, 0)
    document.body.setAttribute('test', "a")
}).then(() => {
    console.log('mt3')
})

setTimeout(() => {
    console.log('t2')
}, 0)

這道題就不分析了,答案:mt2 mt1 mt3 t2 t1

瀏覽器不講武德?

Promise.resolve().then(() => {
    console.log(0);
    return Promise.resolve(4);
}).then((res) => {
    console.log(res)
})

Promise.resolve().then(() => {
    console.log(1);
}).then(() => {
    console.log(2);
}).then(() => {
    console.log(3);
}).then(() => {
    console.log(5);
}).then(() =>{
    console.log(6);
})

這道題聽說是字節內部流出的一道題,說實話我剛看到的時候也是一頭霧水。通過我在Chrome測試,獲得的答案確實頗有規律,就是:0 1 2 3 4 5 6

先輸出0,再輸出1,我還能理解,爲何輸出2和3後又忽然跳到4呢,瀏覽器你不講武德啊!

emm...我被戴上了痛苦面具!

那麼這背後的執行順序究竟是怎樣的呢?仔細分析下,你會發現仍是有跡可循的。

老規矩,第一個問題,這道題的代碼執行過程當中,產生了多少個微任務?可能不少人認爲是7個,但實際上應該是8個。

編號 註冊時機 異步回調
mt1 .then() console.log(0);return Promise.resolve(4);
mt2 .then(res) console.log(res)
mt3 .then() console.log(1);
mt4 .then() console.log(2);
mt5 .then() console.log(3);
mt6 .then() console.log(5);
mt7 .then() console.log(6);
mt8 return Promise.resolve(4)執行而且execution context stack清空後,隱式註冊 隱式回調(未體如今代碼中),目的是讓mt2變成runnable狀態
  • 同步任務執行,註冊mt1~mt7七個微任務,此時execution context stack爲空,而且mt1和mt3的狀態變爲runnable。JS引擎安排mt1和mt3進入Microtask隊列(經過HostEnqueuePromiseJob實現)。
  • Perform a microtask checkpoint,因爲mt1和mt3是在同一次JS call中變爲runnable的,因此mt1和mt3的回調前後進入execution context stack執行。
  • mt1回調進入execution context stack執行,輸出0,返回Promise.resolve(4)。mt1出隊列。因爲mt1回調返回的是一個狀態爲fulfilled的Promise,因此以後JS引擎會安排一個job(job是ecma中的概念,等同於微任務的概念,這裏先給它編號mt8),其回調目的是讓mt2的狀態變爲fulfilled(前提是當前execution context stack is empty)。因此緊接着仍是先執行mt3的回調。
  • mt3回調進入execution context stack執行,輸出1,mt4變爲runnable狀態,execution context stack is empty,mt3出隊列。
  • 因爲此時mt4已是runnable狀態,JS引擎安排mt4進隊列,接着JS引擎會安排mt8進隊列。
  • 接着,mt4回調進入execution context stack執行,輸出2,mt5變爲runnable,mt4出隊列。JS引擎安排mt5進入Microtask隊列。
  • mt8回調執行,目的是讓mt2變成runnable狀態,mt8出隊列。mt2進隊列。
  • mt5回調執行,輸出3,mt6變爲runnable,mt5出隊列。mt6進隊列。
  • mt2回調執行,輸出4,mt2出隊列。
  • mt6回調執行,輸出5,mt7變爲runnable,mt6出隊列。mt7進隊列。
  • mt7回調執行,輸出6,mt7出隊列。執行完畢!整體來看,輸出結果依次爲:0 1 2 3 4 5 6

對這塊執行過程尚有疑問的朋友,能夠先往下看看Promise/A+規範和ECMAScript262規範中關於Promise的約定,再回過頭來思考,也歡迎留言與我交流!

通過我在Edge瀏覽器測試,結果是:0 1 2 4 3 5 6。能夠看到,不一樣瀏覽器在實現Promise的主流程上是吻合的,可是在一些細枝末節上還有不一致的地方。實際應用中,咱們只要注意規避這種問題便可。

實現Promise/A+

熱身完畢,接下來就是直面大boss Promise/A+規範。Promise/A+規範列舉了大大小小三十餘條細則,一眼看過去仍是挺暈的。

Promise/A+

仔細閱讀多遍規範以後,我有了一個基本認識,要實現Promise/A+規範,關鍵是要理清其中幾個核心點。

關係鏈路

原本寫了大幾千字有點以爲疲倦了,因而想着最後這部分就用文字講解快速收尾,可是最後這節寫到一半時,我以爲我寫不下去了,純文字的東西太乾了,幹得無法吸取,這對那些對Promise掌握程度不夠的讀者來講是至關不友好的。因此,我以爲仍是先用一張圖來描述一下Promise的關係鏈路。

首先,Promise它是一個對象,而Promise/A+規範則是圍繞着Promise的原型方法.then()展開的。

  • .then()的特殊性在於,它會返回一個新的Promise實例,在這種連續調用.then()的狀況下,就會串起一個Promise鏈,這與原型鏈又有一些類似之處。「恬不知恥」地再推薦一篇「思惟導圖學前端 」6k字一文搞懂Javascript對象,原型,繼承,哈哈哈。
  • 另外一個靈活的地方在於,p1.then(onFulfilled, onRejected)返回的新Promise實例p2,其狀態轉移的發生是在p1的狀態轉移發生以後(這裏的以後指的是異步的以後)。而且,p2的狀態轉移爲Fulfilled仍是Rejected,這一點取決於onFulfilledonRejected的返回值,這裏有一個較爲複雜的分析過程,也就是後面所述的Promise Resolution Procedure算法。

我這裏畫了一個簡單的時序圖,畫圖水平不好,只是爲了讓讀者們先有個基本印象。

其中還有不少細節是沒提到的(由於細節真的太多了,所有畫出來就至關複雜,具體過程請看我文末附的源碼)。

nextTick

看了前面內容,相信你們都有一個概念,微任務是一個異步任務,而咱們要實現Promise的整套異步機制,必然要具有模擬微任務異步回調的能力。在規範中也提到了這麼一條信息:

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微任務隊列執行過程當中就可能會穿插宏任務,這就不太符合微任務隊列的調度邏輯了。這裏還對Node環境和瀏覽器環境作了兼容,Node環境中可使用process.nextTick回調來模擬微任務的執行,而在瀏覽器環境中咱們能夠選擇MutationObserver

function nextTick(callback) {
  if (typeof process !== 'undefined' && typeof process.nextTick === 'function') {
    process.nextTick(callback)
  } else {
    const observer = new MutationObserver(callback)
    const textNode = document.createTextNode('1')
    observer.observe(textNode, {
      characterData: true
    })
    textNode.data = '2'
  }
}

狀態轉移

  • Promise實例一共有三種狀態,分別是Pending, Fulfilled, Rejected,初始狀態是Pending。

    const PROMISE_STATES = {
      PENDING: 'pending',
      FULFILLED: 'fulfilled',
      REJECTED: 'rejected'
    }
    
    class MyPromise {
      constructor(executor) {
        this.state = PROMISE_STATES.PENDING;
      }
      // ...其餘代碼
    }
  • 一旦Promise的狀態發生轉移,就不可再轉移爲其餘狀態。

    /**
     * 封裝Promise狀態轉移的過程
     * @param {MyPromise} promise 發生狀態轉移的Promise實例
     * @param {*} targetState 目標狀態
     * @param {*} value 伴隨狀態轉移的值,多是fulfilled的值,也多是rejected的緣由
     */
    function transition(promise, targetState, value) {
      if (promise.state === PROMISE_STATES.PENDING && targetState !== PROMISE_STATES.PENDING) {
        // 2.1: state只能由pending轉爲其餘態,狀態轉移後,state和value的值再也不變化
        Object.defineProperty(promise, 'state', {
          configurable: false,
          writable: false,
          enumerable: true,
          value: targetState
        })
        // ...其餘代碼
      }
    }
  • 觸發狀態轉移是靠調用resolve()reject()實現的。當resolve()被調用時,當前Promise也不必定會當即變爲Fulfilled狀態,由於傳入resolve(value)方法的value有可能也是一個Promise,這個時候,當前Promise必須追蹤傳入的這個Promise的狀態,整個肯定Promise狀態的過程是經過Promise Resolution Procedure算法實現的,具體細節封裝到了下面代碼中的resolvePromiseWithValue函數中。當reject()被調用時,當前Promise的狀態就是肯定的,必定是Rejected,此時能夠經過transition函數(封裝了狀態轉移的細節)將Promise的狀態進行轉移,並執行後續動做。

    // resolve的執行,是一個觸發信號,基於此進行下一步的操做
    function resolve(value) {
      resolvePromiseWithValue(this, value)
    }
    // reject的執行,是狀態能夠變爲Rejected的信號
    function reject(reason) {
      transition(this, PROMISE_STATES.REJECTED, reason)
    }
    
    class MyPromise {
      constructor(executor) {
        this.state = PROMISE_STATES.PENDING;
        this.fulfillQueue = [];
        this.rejectQueue = [];
        // 構造Promise實例後,馬上調用executor
        executor(resolve.bind(this), reject.bind(this))
      }
    }

鏈式追蹤

假設如今有一個Promise實例,咱們稱之爲p1。因爲promise1.then(onFulfilled, onRejected)會返回一個新的Promise(咱們稱之爲p2),與此同時,也會註冊一個微任務mt1,這個新的p2會追蹤其關聯的p1的狀態變化。

當p1的狀態發生轉移時,微任務mt1回調會在接下來被執行,若是狀態是Fulfilled,則onFulfilled會被執行,不然onRejected會被執行。微任務mt1回調執行的結果將做爲決定p2狀態的依據。如下是Fulfilled狀況下的部分關鍵代碼,其中promise指的是p1,而chainedPromise指的是p2。

// 回調應異步執行,因此用到了nextTick
nextTick(() => {
  // then可能會被調用屢次,因此異步回調應該用數組來維護
  promise.fulfillQueue.forEach(({ handler, chainedPromise }) => {
    try {
      if (typeof handler === 'function') {
        const adoptedValue = handler(value)
        // 異步回調返回的值將決定衍生的Promise的狀態
        resolvePromiseWithValue(chainedPromise, adoptedValue)
      } else {
        // 存在調用了then,可是沒傳回調做爲參數的可能,此時衍生的Promise的狀態直接採納其關聯的Promise的狀態。
        transition(chainedPromise, PROMISE_STATES.FULFILLED, promise.value)
      }
    } catch (error) {
      // 若是回調拋出了異常,此時直接將衍生的Promise的狀態轉移爲rejected,並用異常error做爲reason
      transition(chainedPromise, PROMISE_STATES.REJECTED, error)
    }
  })
  // 最後清空該Promise關聯的回調隊列
  promise.fulfillQueue = [];
})

Promise Resolution Procedure算法

Promise Resolution Procedure算法是一種抽象的執行過程,它的語法形式是[[Resolve]](promise, x),接受的參數是一個Promise實例和一個值x,經過值x的可能性,來決定這個Promise實例的狀態走向。若是直接硬看規範,會有點吃力,這裏直接說人話解釋一些細節。

2.3.1

若是promise和值x引用同一個對象,應該直接將promise的狀態置爲Rejected,而且用一個TypeError做爲reject的緣由。

If promise and x refer to the same object, reject promise with a TypeError as the reason.

【說人話】舉個例子,老闆說只要今年業績超過10億,業績就超過10億。這顯然是個病句,你不能拿預期自己做爲條件。正確的玩法是,老闆說只要今年業績超過10億,就發1000萬獎金(嘿嘿,這種事期待一下就行了)。

代碼實現:

if (promise === x) {
    // 2.3.1 因爲Promise採納狀態的機制,這裏必須進行全等判斷,防止出現死循環
    transition(promise, PROMISE_STATES.REJECTED, new TypeError('promise and x cannot refer to a same object.'))
}

2.3.2

若是x是一個Promise實例,promise應該採納x的狀態。

2.3.2 If x is a promise, adopt its state [3.4]:

2.3.2.1 If x is pending, promise must remain pending until x is fulfilled or rejected.

2.3.2.2 If/when x is fulfilled, fulfill promise with the same value.

2.3.2.3 If/when x is rejected, reject promise with the same reason.

【說人話】小王問領導:「今年會發年終獎嗎?發多少?」領導聽了內心想,「這個事我以前也在打聽,不過還沒定下來,得看老闆的意思。」,因而領導對小王說:「會發的,不過要等消息!」。

注意,這個時候,領導對小王許下了承諾,可是這個承諾p2的狀態仍是pending,須要看老闆給的承諾p1的狀態。

  • 可能性1:過了幾天,老闆對領導說:「今年業務作得能夠,年終獎發1000萬」。這裏至關於p1已是fulfilled狀態了,value是1000萬。領導拿了這個準信了,天然能夠跟小王兌現承諾p2了,因而對小王說:「年終獎能夠下來了,是1000萬!」。這時,承諾p2的狀態就是fulfilled了,value也是1000萬。小王這個時候就「別墅靠海」了。

  • 可能性2:過了幾天,老闆有點發愁,對領導說:「今年業績不太行啊,年終獎就不發了吧,明年,我們明年多發點。」顯然,這裏p1就是rejected了,領導一看這狀況不對啊,但也沒辦法,只能對小王說:「小王啊,今年公司狀況特殊,年終獎就不發了。」這p2也隨之rejected了,小王心裏有點炸裂......

注意,Promise A/+規範2.3.2小節這裏有兩個大的方向,一個是x的狀態未定,一個是x的狀態已定。在代碼實現上,這裏有個技巧,對於狀態未定的狀況,必須用訂閱的方式來實現,而.then就是訂閱的絕佳途徑。

else if (isPromise(x)) {
    // 2.3.2 若是x是一個Promise實例,則追蹤並採納其狀態
    if (x.state !== PROMISE_STATES.PENDING) {
      // 假設x的狀態已經發生轉移,則直接採納其狀態
      transition(promise, x.state, x.state === PROMISE_STATES.FULFILLED ? x.value : x.reason)
    } else {
      // 假設x的狀態仍是pending,則只需等待x狀態肯定後再進行promise的狀態轉移
      // 而x的狀態轉移結果是不定的,因此兩種狀況咱們都須要進行訂閱
      // 這裏用一個.then很巧妙地完成了訂閱動做
      x.then(value => {
        // x狀態轉移爲fulfilled,因爲callback傳過來的value是不肯定的類型,因此須要繼續應用Promise Resolution Procedure算法
        resolvePromiseWithValue(promise, value, thenableValues)
      }, reason => {
        // x狀態轉移爲rejected
        transition(promise, PROMISE_STATES.REJECTED, reason)
      })
    }
}

多的細節咱這篇文章就不一一分析了,寫着寫着快1萬字了,就先結束掉吧,感興趣的讀者能夠直接打開源碼看(往下看)。

這是跑測試用例的效果圖,能夠看到,872個case是所有經過的。

完整代碼

這裏直接給出我寫的Promise/A+規範的Javascript實現,供你們參考。後面若是有時間,會考慮詳細分析下。

缺陷

我這個版本的Promise/A+規範實現,不具有檢測execution context stack爲空的能力,因此在細節上會有一點問題(execution context stack還未清空就插入了微任務),沒法適配上面那道「瀏覽器不講武德?」的題目所述場景。

方法論

無論是手寫實現Promise/A+規範,仍是實現其餘Native Code,其本質上繞不開如下幾點:

  • 準確理解Native Code實現的能力,就像你理解一個需求要實現哪些功能點同樣,並肯定實現上的優先級。
  • 針對每一個功能點或者功能描述,逐一用代碼實現,優先打通主幹流程。
  • 設計足夠豐富的測試用例,迴歸測試,不斷迭代,保證場景的覆蓋率,最終打造一段優質的代碼。

總結

看到結尾,相信你們也累了,感謝各位讀者的閱讀!但願本文對宏任務和微任務的解讀能給各位讀者帶來一點啓發。Promise/A+規範整體來講仍是比較晦澀難懂的,這對新手來講是不太友好的,所以我建議有必定程度的Promise實際使用經驗後再深刻學習Promise/A+規範。經過學習和理解Promise/A+規範的實現機制,你會更懂Promise的一些內部細節,對於設計一些複雜的異步過程會有極大的幫助,再不濟也能提高你的異步調試和排錯能力。

這裏還有一些規範和文章能夠參考:

若是您以爲這篇文章還不錯,歡迎點個贊,加個關注(前端司南),真誠感謝您的支持。也歡迎和我直接交流,我是laobaife,期待與您共同進步!

相關文章
相關標籤/搜索