动态

详情 返回 返回

我以為我很懂Promise,直到我開始實現Promise/A+規範 - 动态 详情

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

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

  • github倉庫:promises-aplus-robin(順手點個star就更好了)
  • 源碼
  • 源碼註釋版

promises-tests測試用例是全部通過的。

Promise源於現實世界

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

你懂的

許下承諾

作為打工人,我們不可避免地會接到各種餅,比如口頭吹捧的餅、升值加薪的餅、股權激勵的餅......

有些餅馬上就兑現了,比如口頭褒獎,因為它本身沒有給企業帶來什麼成本;有些餅卻關乎企業實際利益,它們可能未來可期,也可能猴年馬月,或是無疾而終,又或者直接宣告畫餅失敗。

畫餅這個動作,於Javascript而言,就是創建一個Promise實例:

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

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

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

訂閲承諾

有人畫餅,自然有人接餅。所謂“接餅”,就是對於這張餅的可能性做下設想。如果餅真的實現了,鄙人將別墅靠海;如果餅失敗了,本打工仔以淚洗面。

轉換成Promise中的概念,這是一種訂閲的模式,成功和失敗的情況我們都要訂閲,並作出反應。訂閲是通過thencatch等方法實現的。

// 通過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 c = 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實現,供大家參考。後面如果有時間,會考慮詳細分析下。

  • github倉庫:promises-aplus-robin(順手點個star就更好了)
  • 源碼
  • 源碼註釋版

缺陷

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

hack

後面我又想了一下,要想解決這個問題也不是沒有辦法,利用兩個nextTick就可以解決。具體見promises-aplus-robin-hack.js。

// 用兩個nextTick hack一下,保證execution context stack為空再安排微任務
nextTick(() => {
    // 第一個nextTick,能保證execution context stack為空
    nextTick(() => {
        // 第二個nextTick,保證以微任務的形式安排新的任務
        transition(promise, x.state, x.state === PROMISE_STATES.FULFILLED ? x.value : x.reason)
    })
})

方法論

不管是手寫實現Promise/A+規範,還是實現其他Native Code,其本質上繞不開以下幾點:

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

總結

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

這裏還有一些規範和文章可以參考:

  • Promises/A+規範
  • Event Loop Processing Model
  • tasks-microtasks-queues-and-schedules
  • Jobs and Host Operations to Enqueue Jobs

如果您覺得這篇文章還不錯,歡迎點個贊,加個關注(程序員白彬),真誠感謝您的支持。也歡迎和我直接交流,期待與您共同進步!

user avatar dingtongya 头像 alibabawenyujishu 头像 smalike 头像 linlinma 头像 nihaojob 头像 qingzhan 头像 aqiongbei 头像 littlelyon 头像 zourongle 头像 razyliang 头像 longlong688 头像 linx 头像
点赞 239 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.