這篇手寫async函式及過程分析

語言: CN / TW / HK

前言

你盼世界,我盼望你無bug。Hello 大家好!我是霖呆呆!

其實本文怎麼說呢,算不上是呆呆的純原創吧,因為呆呆也是參考晨曦老哥的手寫async await的最簡實現(20行)來寫的,包括案例啥的也是一樣,哈哈不過大家請放心我也是經過原作者授權的,而且參考的這篇文章,對手寫async函式說的也很清楚了。不過呆呆主要是在其中加上了一些自己的理解以及更加詳細的轉換過程,也算是自己的一個學習筆記吧。

所以如果您在看完呆呆寫的這篇文章後,還希望可以再看一遍晨曦哥的原創,這樣對您的幫助應該會更大。

(另外如果您覺得呆呆寫的還不錯的話還希望可以給本篇和原創都點一個贊,畢竟呆呆也是借鑑的晨曦哥的,內心有愧...啊啊啊...為什麼感覺我是真的臭不要臉,哪有求人家贊還帶送一個讚的😂)

正文

ES8推出的async/await允許我們用更加優雅的方式來實現非同步程式碼,從某種角度上來說,它是Generator函式的語法糖,就像我們經常說class是建構函式的語法糖一樣。

async案例

基本用法啥的我就不說了,這裡直接上一個簡單的案例,後面一步步的轉換過程都是以此案例作為基礎。

const getData = () => new Promise(resolve => setTimeout(() => { resolve('data') }, 1000))

async function test () {
  const data = await getData();
  console.log('data: ', data);
  const data2 = await getData();
  console.log('data2: ', data2);
  return 'success';
}
test().then(res => console.log(res));
複製程式碼

針對於上面的這段程式碼,相信大家都沒有什麼疑問,很快我們就能說出答案:

// 1s 後打印出
'data ' 'data'
// 1s 後打印出
'data2: ' 'data'
'success'
複製程式碼

Generator案例

讓我們先來回顧一下Generator最基本的一些概念:

  • function後面加上*表示這是一個Generator函式,如function* testG(){}
  • 函式內部可以使用yield來中斷函式的執行,即當每次執行到yield語句的時候,函式暫停執行
  • 暫停執行之後,需要呼叫next()才會繼續執行Generator函式,直到碰到函式內下一個yield又會暫停
  • 以此迴圈,直到函式內有return或者函式內程式碼全部執行完

其中還有很重要的一個知識點,就是每次呼叫next()的返回值,它是一個物件,這個物件會有兩個屬性:

  • valueyield語句後的表示式的結果
  • done:當前的Generator物件的邏輯塊是否執行完成

如果說我們把上面的案例轉換為Generator來實現的話,我們想的可能會是這樣來寫:

案例一:

const getData = () => new Promise(resolve => setTimeout(() => { resolve('data') }, 1000))

function* testG () {
  const data = yield getData();
  console.log('data: ', data);
  const data2 = yield getData();
  console.log('data2: ', data2);
  return 'success';
}
var gen = testG();
// 之後手動呼叫3次
gen.next();
gen.next();
gen.next();
複製程式碼

可是上面👆的程式碼真的會和async案例中的執行結果一樣嗎?

當我開啟控制檯的時候,結果卻出乎我的意料:

data: undefined
data2: undefined
複製程式碼

What...?

你的suceess沒有列印就算了,你的datadata2竟然都還是undefined?這就有點難以理解了。

難道說是我的呼叫姿勢不對嗎...

本著良好的職業素養,我對Generator研究了一波,然後修改了一下上面的程式碼:

案例二:

const getData = () => new Promise(resolve => setTimeout(() => { resolve('data') }, 1000))

function* testG () {
  const data = yield getData();
  console.log('data: ', data);
  const data2 = yield getData();
  console.log('data2: ', data2);
  return 'success';
}
var gen = testG();
// 手動呼叫3次且把每次的返回值打印出來看看
var dataPromise= gen.next();
console.log(dataPromise);
var dataPromise2 = gen.next('這個引數才會被賦給data變數');
console.log(dataPromise2);
var dataPromise3 = gen.next('這個引數才會被賦給data2變數');
console.log(dataPromise3);
複製程式碼

可以看到不同之處在於我把呼叫三次的返回值用了一個變數來盛放,並且在後面兩次呼叫gen.next()的時候傳遞了引數進去。

現在的輸出結果為:

{value: Promise, done: false}
'data:', '這個引數才會被賦給data變數'
{value: Promise, done: false}
'data2:', '這個引數才會被賦給data2變數'
{value: Promise, done: false}
複製程式碼

What X 2...?

哈哈哈,是不是有點摸不著頭腦了,這datadata2的返回值難道不是yield getData()的結果嗎?

我這裡的程式碼明明就是const data = yield getData()呀,可是怎麼會變成了'這個引數才會被賦給data變數'呢?

原來有些的你以為並不是真的你以為,呆呆這裡詳細把每一步都分析一下:

OK👌,相信聰明的你現在一定弄懂Generator的執行機制了,它和我們的async函式是有一些區別的。

async函式中,const data = await getData(),這個datagetData()resolve()的結果。

generator函式中,const data = yield getData(),其實只執行了yield getData()函式而已並且會把這個值作為第一次呼叫gen.next()的返回值,也就是被dataPromise所接收。

data需要等到下一次呼叫gen.next()時才會被賦值,這也就是為什麼我們在案例一中data會是undefined,因為那時候我們呼叫gen.next()是沒有傳遞任何引數的。

Generator案例轉換async案例

顯然,上面👆兩個案例的執行結果和async案例中的結果是不一樣的,我只是將其做了一些拆分以便讓你更好的理解接下來我要做的事情。

嘻嘻😁,那麼如何讓案例二能打印出和async案例一樣的結果呢?

細心的小夥伴可能已經有了一些想法,dataPromise中會有呼叫返回的Promise物件,那麼我們也就能拿到這個Promise的返回值'data'了,只需要使用.then()來進行一個鏈式呼叫,就像下面這樣:

const getData = () => new Promise(resolve => setTimeout(() => { resolve('data') }, 1000))

function* testG () {
  const data = yield getData();
  console.log('data: ', data);
  const data2 = yield getData();
  console.log('data2: ', data2);
  return 'success';
}
var gen = testG();
var dataPromise = gen.next();
dataPromise.value.then((value1) => {
  var dataPromise2 = gen.next(value1);
  dataPromise2.value.then((value2) => {
    var dataPromise3 = gen.next(value2)
    console.log(dataPromise3.value)
  })
})
複製程式碼

現在的結果就是和async案例的結果一樣咯:

(如果你對這道題的結果還是比較模糊的話請再仔細看一下我在案例二的那一大坨程式碼註釋哦)

// 1s 後打印出
'data ' 'data'
// 1s 後打印出
'data2: ' 'data'
'success'
複製程式碼

(注意⚠️,在每次呼叫gen.next()的時候,它的返回值是一個{ value: {}, done: false }這樣的物件,所以我們想要使用返回的Promise的時候,需要用dataPromise.value來獲取)

throw()

在正式講解之前,讓我們再來認識一下Generator的另一個例項方法throw(),之所以說到它,是因為我們最終的程式碼需要用到它。

它和next()一樣,都是屬於Generator.prototype上的方法,且返回值也是和next()一樣。

讓我們來看一個簡單的案例瞭解一下它是怎樣使用的哈:

(利用while(true){}迴圈,我們建立了一個可以無限呼叫的Generator函式)

function* gen () {
  while (true) {
    try {
      yield 'LinDaiDai'
    } catch (e) {
      console.log(e)
    }
  }
}
var g = gen();
g.next(); // { value: 'LinDaiDai', done: false }
g.next(); // { value: 'LinDaiDai', done: false }
g.throw(new Error('錯誤')); // Error: '錯誤'
複製程式碼

而且你會發現,並不是throw中一定要傳一個new Error()才會被裡面的catch捕獲,你就算是傳遞一個別的型別的值進去,也會,例如我直接傳遞字串'錯誤':

g.throw('錯誤'); // '錯誤'
複製程式碼

它也會被捕獲。

Generator實現async?

我們在搞懂了Generator的執行機制之後,就可以來看看,async是怎樣用Generator來實現的了。

首先,讓我們確定一下我們要做的事情:

const getData = () => new Promise(resolve => setTimeout(() => { resolve('data') }, 1000))

function* testG () { // 這個就是上面的那個案例
  const data = yield getData();
  console.log('data: ', data);
  const data2 = yield getData();
  console.log('data2: ', data2);
  return 'success';
}

// 我們需要設計一個轉換函式
function asyncToGenerator (genFunc) {}

var test = asyncToGenerator(testG);
test().then(res => console.log(res));
複製程式碼

那麼可以看到,現在的關鍵就是在於實現一個asyncToGenerator轉換函式,它有以下特點:

  • 接收一個Generator函式
  • 返回一個Promise

Generator你們也看到了,它是很懶的,需要我們每呼叫一次next()函式它才走一步,所以如何讓它自動的執行成為了我們需要思考的點。

呆呆這裡也不再小氣了,直接上晨曦老哥的程式碼再進行講解吧:

function asyncToGenerator (genFunc) {
  return function () {
    const gen = genFunc.apply(this, arguments);
    return new Promise((resolve, reject) => {
      function step (key, arg) {
        let generatorResult;
        try {
          generatorResult = gen[key](arg);
        } catch (err) {
          return reject(err);
        }
        const { value, done } = generatorResult;
        if (done) {
          return resolve(value);
        } else {
          return Promise.resolve(value).then(val => {
            step("next", val)
          }, err => {
            step("throw", err)
          })
        }
      }
      step("next")
    })
  }
}
var gen = asyncToGenerator(testG)
gen().then(res => console.log(res))
複製程式碼

怎麼樣?是不是覺得晨曦老哥很短啊,呸,寫的很精簡呀 😄。

如果覺得有點吃力的話沒關係,呆呆會將每一步仔細拆分著說。

1. 整體結構

讓我們來設想一下asyncToGenerator的大概樣子了,它也許是長這樣的:

function asyncToGenerator (genFunc) {
  return function () {
    return new Promise((resolve, reject) => {})
  }
}
複製程式碼

依照要求,接收一個Generator函式,返回一個Promise,上面這種結構是完全滿足的。可是如果是想要返回Promise的話,為什麼還要把它包到一個函式裡面呢,不應該是這樣寫嗎:

function asyncToGenerator (genFunc) {
  return new Promise((resolve, reject) => {})
}
複製程式碼

唔...這樣寫固然也可以,不過別忘了我們的呼叫方式:

var test = asyncToGenerator(testG);
test().then(res => console.log(res));
複製程式碼

我們一般是會把asyncToGenerator的返回值用一個變數來盛放的,所以如果你不包到函式裡的話,就只能這樣呼叫了:

asyncToGenerator(testG).then(res => console.log(res))
複製程式碼

這顯然不是我們想要的。

2. 如何保證自動呼叫

有了asyncToGenerator函式的整體結構之後我們就要開始考慮如何讓它自動呼叫這一個個的next()呢?

咦~你是不是想到了什麼?

遞迴?

嘻嘻😁,確實像這種需要迴圈呼叫的時候確實容易讓人想到遞迴,這裡其實也是可以的。

所以現在我們就得先確定遞迴的終止條件是什麼。

Generator函式中,什麼情況才算是該物件的邏輯程式碼執行完了呢?這個其實前面也已經提到了,當返回的done屬性為true時就可以確定已經執行完了,遞迴也該結束了。

知道了終止的條件之後,我們就需要把整個遞迴結束,結合上面👆我們已經設計好的整體結構,這其實很簡單,直接return一個resolve()或者reject()就可以做到:

function asyncToGenerator (genFunc) {
	return function () {
		return new Promise((resolve, reject) => {
      // 終止條件滿足時,直接呼叫reject()來退出, value為最終的值(後面會說到)
      return resolve(value);
      // 或者 return reject(error);
    })
	}
}
複製程式碼

OK👌,終止條件和怎麼終止都已經知道了,讓我們接著往下看。

在這裡,我們可以寫一個step函式,然後配合Promise.resolve()來實現迴圈呼叫,類似於這樣:

function asyncToGenerator (genFunc) {
  return function () {
    return new Promise((resolve, reject) => {
      function step () {
        return Promise.resolve(value).then(val => {
          step()
        })
      }
      step();
    })
  }
}
複製程式碼

所以這時候我們就得看看Promise.resolve(value)中的value是哪來的了,它實際上是我們在每次呼叫gen.next()返回值中的value,對應著案例二的程式碼:

yield getData()

// 也就是 getData()的返回值
// 也就是 Promise{<resolve>, 'data'}
複製程式碼

所以此時我們的程式碼就變成了這樣:

function asyncToGenerator (genFunc) {
    return function () {
+     const gen = genFunc.apply(this, arguments);
      return new Promise((resolve, reject) => {
      function step () {
+       let generatorResult = gen.next();
+       const { value, done } = generatorResult;
+       if (done) {
+          return resolve(value);
+       } else {
          return Promise.resolve(value).then(val => {
             step()
           })
+        }
       }
      step();
    })
  }
}
複製程式碼

這段程式碼我加上了兩個功能:

  1. const gen = getFunc.apply(this, arguments)來實現類似const gen = testG()這樣的程式碼,是為了先呼叫generator函式來生成迭代器。
  2. setp中呼叫了gen.next(),並儲存結果到generatorResult上,同時判斷終止條件done

3. 傳值以及異常處理

OK👌,完成了上述步驟後,顯然還是不夠的,我們至少還有兩點沒有考慮到:

  • 沒有把呼叫gen.next()的結果傳遞給後面的gen.next()
  • 沒有對異常情況作處理

首先,前面也提到了,next()throw()呼叫時的返回值都是這樣的格式:

{ value: {}, done: false }
複製程式碼

那我們是不是就可以把這兩個方法名當成一個引數傳遞到下一個step中呢?

而每次Promise.resolve(value)這裡的結果我們也可以把它當成引數傳遞到下一個step中。

所以現在再讓我們來看看最終的程式碼:

function asyncToGenerator (genFunc) {
  return function () {
    const gen = genFunc.apply(this, arguments);
    return new Promise((resolve, reject) => {
      function step (key, arg) {
        let generatorResult;
        try {
          generatorResult = gen[key](arg);
        } catch (err) {
          return reject(err);
        }
        const { value, done } = generatorResult;
        if (done) {
          return resolve(value);
        } else {
          return Promise.resolve(value).then(val => {
            step("next", val)
          }, err => {
            step("throw", err)
          })
        }
      }
      step("next")
    })
  }
}
var gen = asyncToGenerator(testG)
gen().then(res => console.log(res))
複製程式碼

新加的程式碼主要做了這麼幾件事:

  1. step函式增加兩個欄位:key為方法名(next或者throw);arg為上一次gen[key]()
  2. 使用try/catch來捕獲執行gen[key](arg)時的異常
  3. .then()中增加第二個引數來捕獲異常

這裡需要注意的一點是,Promise.resolve(value)中的value它是一個Promise,為什麼呢?

讓我們來看看generatorResult

{ value: Promise{<resolve>, 'data'}, done: false }
複製程式碼

對應著案例二,其實也就是yield getData()這段語句中getData()返回的那個Promise

而下一個step("next")必須等value這個Promiseresolve的時候才會被呼叫。

參考文章

後語

你盼世界,我盼望你無bug。這篇文章就介紹到這裡。

OK👌,情況就是這麼一個情況,在我們弄懂這一步一步的原理之後再來記就不難了 😄。

喜歡霖呆呆的小夥還希望可以關注霖呆呆的公眾號 LinDaiDai 或者掃一掃下面的二維碼👇👇👇.

我會不定時的更新一些前端方面的知識內容以及自己的原創文章🎉

你的鼓勵就是我持續創作的主要動力 😊.

相關推薦:

《全網最詳bpmn.js教材》

《【建議改成】讀完這篇你還不懂Babel我給你寄口罩》

《【建議星星】要就來45道Promise面試題一次爽到底(1.1w字用心整理)》

《【建議👍】再來40道this面試題酸爽繼續(1.2w字用手整理)》

《【何不三連】比繼承家業還要簡單的JS繼承題-封裝篇(牛刀小試)》

《【何不三連】做完這48道題徹底弄懂JS繼承(1.7w字含辛整理-返璞歸真)》

《【精】從206個console.log()完全弄懂資料型別轉換的前世今生(上)》

《霖呆呆的近期面試128題彙總(含超詳細答案) | 掘金技術徵文》