ES6 Promise 應用: 回撥函式方法封裝成 Promise + async/await 同步化

超悠閒發表於2020-12-29

ES6 Promise 應用: 回撥函式方法封裝成 Promise + async/await 同步化

簡介

前一篇ES6特性:Promise非同步函式介紹了 ES6 的新特性 Promise 物件,而ES6 實戰: 手寫 Promise則是嘗試使用 ES5 以下的語法手擼一個 Promise 出來。

本篇則是要來介紹一種 Promise 物件的應用:將接受回撥函式的方法包裝成 Promise 物件,同時透過 async/await 關鍵字同步化。屬於實際開發時非常常見的需求,也是非常實用的技能。

參考

完整示例程式碼

https://github.com/superfreeeee/Blog-code/tree/main/front_end/es6/es6_promise_encapsulation_callback

正文

什麼是"接受回撥函式的方法"?

我們在開發時使用一些 ES5 以前的庫或是不支援 ES6 以上的版本環境時,常常需要用到接受回撥函式的方法呼叫,常見的例子有以下幾個:

示例一:http 請求

  • http 請求(使用 request 第三方庫)
const request = require('request')
request.get('http://localhost:3000', (err, res, body) => {/* http 請求結果處理 */})

request.get 方法會發起一個 http 請求,當請求返回時回撥用第二個引數的處理函式對返回結果進行處理

示例二:mysql 命令

  • mysql 連線、查詢(使用 mysql 庫)
const mysql = require('mysql')
const connection = mysql.createConnection({/* 配置資訊省略 */})
connection.query('select * from a_table', (err, data) => {/* sql 查詢結果處理 */})

類似的,透過 mysql 庫能夠執行 SQL 命令,並且在命令執行完畢後呼叫第二個引數的回撥函式進行處理

示例三:同步方法

這邊我們要解開一個常見的迷思:接受回撥函式的方法並不一定要是非同步函式,前兩個例子確實會進行非同步呼叫後才執行回撥函式處理非同步呼叫返回的結果,但是像下面這個函式也是一種回撥函式方法

  • 同步方法接受回撥函式
const syncFunction = (x, cb) => {
    console.log(`param x = ${x}`)
    cb()
}
syncFunction(1, () => {/* do something */})

面臨問題:回撥地獄

乍看之下,其實回撥函式好像還挺合理的,透過接受回撥函式的方法將方法結果處理的邏輯交給呼叫者決定(藉由回撥函式 callback)。然而這樣會引發回撥地獄的慘劇,試想如果我們呼叫 sql 查詢時想要根據不同的結果再進行二次甚至三次的查詢會發生什麼事:

connection.query('' /* SQL命令1 */, (err, data1) => {
  /* judge or do something else */
  connection.query('' /* SQL命令2 */, (err, data2) => {
    /* judge or do something else */
    connection.query('' /* SQL命令3 */, (err, data3) => {
      /* judge or do something else */
      console.log(data3)
    })
  })
})

這樣一來不僅程式碼結構會變得異常複雜而且醜陋,同時業務的邏輯也會顯得混亂不堪,容易忘記哪些指令生處於哪一層的查詢之中,第三個問題是查詢結果的處理也異常困難。由於 data 都是屬於回撥函數的區域性變數,所以透過 return 返回結果是沒有意義的,而回撥函式以外的部分也沒辦法獲取到查詢的結果(因為呼叫邏輯可能是非同步的,而查詢語句前後的上下文為同步呼叫的環境)。

為此,我們就能夠利用 ES6 提供的 Promise 物件來進行回撥函式的封裝,將層層包裹的 callback 函式解放,變為使用 then 方法的鏈式呼叫形式

封裝開始

接下來我們就要來介紹如何將回撥函式方法封裝成 Promise 物件,並透過 async/await 將非同步方法同步化

回撥函式方法準備(接受回撥函式的方法)

這邊我們使用 request 第三方庫來發起 http 請求作為非同步方法的代表

const request = require('request')

// callback function
function normalCallback (test, cb) {
  console.log(`invoke function f from test ${test}`)
  request.get('http://localhost:3000', (err, res, body) => {
    cb(err, body)
  })
}

normalCallback 方法主要功能是向 3000 埠的預設路由請求服務,收到結果後執行回撥函式處理結果

一般情況下我們使用上面這種回撥函式方法最直白的方式就是按引數傳入回撥函式(結果處理函式),如下

const test1 = () => {
  // 最原始的回撥函式使用方式
  normalCallback('test1', (err, data) => {
    if (err) {
      console.log('err occur')
    } else {
      console.log(`receive data ${data}`)
    }
  })
}

test1()

輸出

invoke function f from test test1
receive data Hello World

使用 Promise 進行封裝

第二步我們要將回撥函式方法封裝成 Promise 物件

const generatePromise = (test) => {
  return new Promise(function (resolve, reject) {
    normalCallback(test, (err, data) => {
      if (err) {
        reject(err)
      } else {
        resolve(data)
      }
    })
  })
}

透過將原方法作為 Promise 任務的主體,並在回撥函式中呼叫 resolve/reject 方法改變 Promise 的狀態。

如此一來我們就可以像下面這樣透過 then 函式來依序傳入結果處理函式

const test2 = () => {
  // 呼叫封裝好的方法返回 Promise 物件
  generatePromise('test2')
    .then(res => {
      console.log(`receive data ${res}`)
    })
    .catch(err => {
      console.log('err occur')
    })
}
test2()

輸出

invoke function f from test test2
receive data Hello World

使用 async/await 關鍵字同步化

到此我們還不滿足,Proimse 的鏈式呼叫是比 callback 的使用形式好看不少,但是與一般的同步方法還是存在一定的差異,接下來我們使用 async/await 關鍵字進行方法同步化

async function asyncUsage () {
  const res = await generatePromise('test3')
  console.log(`receive data ${res}`)
}

透過在外再包裹一層非同步方法(async 關鍵字),非同步方法內部就能夠使用 await 方法進行同步化,直接獲取 Promise 物件的返回值 res,如此一來這樣的呼叫形式就跟一般的同步方法一樣,這就是我們想要的最終形態。

最後看一下呼叫結果

const test3 = () => {
  asyncUsage()
}
test3()

輸出

invoke function f from test test3
receive data Hello World

注意點:如果非同步方法報錯,await 關鍵字是沒辦法處理會直接再向外丟擲異常,所以通常需要在某個層次上使用 try...catch 塊來捕捉異常,避免程式退出

結語

本篇的核心目標在於將 callback 的呼叫形式封裝成 Promise 的鏈式呼叫(then、catch),最後同步化,是實戰開發時不可或缺的能力。

相關文章