util.promisify 的那些事兒

util.promisify是在node.js 8.x版本中新增的一個工具,用於將老式的Error first callback轉換爲Promise對象,讓老項目改造變得更爲輕鬆。javascript

在官方推出這個工具以前,民間已經有不少相似的工具了,好比es6-promisifythenifybluebird.promisifyhtml

以及不少其餘優秀的工具,都是實現了這樣的功能,幫助咱們在處理老項目的時候,沒必要費神將各類代碼使用Promise再從新實現一遍。java

工具實現的大體思路

首先要解釋一下這種工具大體的實現思路,由於在Node中異步回調有一個約定:Error first,也就是說回調函數中的第一個參數必定要是Error對象,其他參數纔是正確時的數據。node

知道了這樣的規律之後,工具就很好實現了,在匹配到第一個參數有值的狀況下,觸發reject,其他狀況觸發resolve,一個簡單的示例代碼:git

function util (func) {
  return (...arg) => new Promise((resolve, reject) => {
    func(...arg, (err, arg) => {
      if (err) reject(err)
      else resolve(arg)
    })
  })
}
複製代碼
  1. 調用工具函數返回一個匿名函數,匿名函數接收原函數的參數。
  2. 匿名函數被調用後根據這些參數來調用真實的函數,同時拼接一個用來處理結果的callback
  3. 檢測到err有值,觸發reject,其餘狀況觸發resolve

resolve 只能傳入一個參數,因此callback中沒有必要使用...arg獲取全部的返回值es6

常規的使用方式

拿一個官方文檔中的示例github

const { promisify } = require('util')
const fs = require('fs')

const statAsync = promisify(fs.stat)

statAsync('.').then(stats => {
  // 拿到了正確的數據
}, err => {
  // 出現了異常
})
複製代碼

以及由於是Promise,咱們可使用await來進一步簡化代碼:redis

const { promisify } = require('util')
const fs = require('fs')

const statAsync = promisify(fs.stat)

// 假設在 async 函數中
try {
  const stats = await statAsync('.')
  // 拿到正確結果
} catch (e) {
  // 出現異常
}
複製代碼

用法與其餘工具並無太大的區別,咱們能夠很輕易的將回調轉換爲Promise,而後應用於新的項目中。npm

自定義的 Promise 化

有那麼一些場景,是不可以直接使用promisify來進行轉換的,有大概這麼兩種狀況:編程

  1. 沒有遵循Error first callback約定的回調函數
  2. 返回多個參數的回調函數

首先是第一個,若是沒有遵循咱們的約定,極可能致使reject的誤判,得不到正確的反饋。
而第二項呢,則是由於Promise.resolve只能接收一個參數,多餘的參數會被忽略。

因此爲了實現正確的結果,咱們可能須要手動實現對應的Promise函數,可是本身實現了之後並不可以確保使用方不會針對你的函數調用promisify

因此,util.promisify還提供了一個Symbol類型的keyutil.promisify.custom

Symbol類型的你們應該都有了解,是一個惟一的值,這裏是util.prosimify用來指定自定義的Promise化的結果的,使用方式以下:

const { promisify } = require('util')
// 好比咱們有一個對象,提供了一個返回多個參數的回調版本的函數
const obj = {
  getData (callback) {
    callback(null, 'Niko', 18) // 返回兩個參數,姓名和年齡
  }
}

// 這時使用promisify確定是不行的
// 由於Promise.resolve只接收一個參數,因此咱們只會獲得 Niko

promisify(obj.getData)().then(console.log) // Niko

// 因此咱們須要使用 promisify.custom 來自定義處理方式

obj.getData[promisify.custom] = async () => ({ name: 'Niko', age: 18 })

// 固然了,這是一個曲線救國的方式,不管如何 Promise 不會返回多個參數過來的
promisify(obj.getData)().then(console.log) // { name: 'Niko', age: 18 }
複製代碼

關於Promise爲何不能resolve多個值,我有一個大膽的想法,一個沒有通過考證,強行解釋的理由:若是能resolve多個值,你讓async函數怎麼return(當個樂子看這句話就好,不要當真)
不過應該確實跟return有關,由於Promise是能夠鏈式調用的,每一個Promise中執行then之後都會將其返回值做爲一個新的Promise對象resolve的值,在JavaScript中並無辦法return多個參數,因此即使第一個Promise能夠返回多個參數,只要通過return的處理就會丟失

在使用上就是很簡單的針對可能會被調用promisify的函數上添加promisify.custom對應的處理便可。
當後續代碼調用promisify時就會進行判斷:

  1. 若是目標函數存在promisify.custom屬性,則會判斷其類型:
    1. 若是不是一個可執行的函數,拋出異常
    2. 若是是可執行的函數,則直接返回其對應的函數
  2. 若是目標函數不存在對應的屬性,按照Error first callback的約定生成對應的處理函數而後返回

添加了這個custom屬性之後,就不用再擔憂使用方針對你的函數調用promisify了。
並且能夠驗證,賦值給custom的函數與promisify返回的函數地址是一處:

obj.getData[promisify.custom] = async () => ({ name: 'Niko', age: 18 })

// 上邊的賦值爲 async 函數也能夠改成普通函數,只要保證這個普通函數會返回 Promise 實例便可
// 這兩種方式與上邊的 async 都是徹底相等的

obj.getData[promisify.custom] = () => Promise.resolve({ name: 'Niko', age: 18 })
obj.getData[promisify.custom] = () => new Promise(resolve({ name: 'Niko', age: 18 }))

console.log(obj.getData[promisify.custom] === promisify(obj.getData)) // true
複製代碼

一些內置的 custom 處理

在一些內置包中,也可以找到promisify.custom的蹤影,好比說最經常使用的child_process.exec就內置了promisify.custom的處理:

const { exec } = require('child_process')
const { promisify } = require('util')

console.log(typeof exec[promisify.custom]) // function
複製代碼

由於就像前邊示例中所提到的曲線救國的方案,官方的作法也是將函數簽名中的參數名做爲key,將其全部參數存放到一個Object對象中進行返回,好比child_process.exec的返回值拋開error之外會包含兩個,stdoutstderr,一個是命令執行後的正確輸出,一個是命令執行後的錯誤輸出:

promisify(exec)('ls').then(console.log)
// -> { stdout: 'XXX', stderr: '' }
複製代碼

或者咱們故意輸入一些錯誤的命令,固然了,這個只能在catch模塊下才可以捕捉到,通常命令正常執行stderr都會是一個空字符串:

promisify(exec)('lss').then(console.log, console.error)
// -> { ..., stdout: '', stderr: 'lss: command not found' }
複製代碼

包括像setTimeoutsetImmediate也都實現了對應的promisify.custom
以前爲了實現sleep的操做,還手動使用Promise封裝了setTimeout

const sleep = promisify(setTimeout)

console.log(new Date())

await sleep(1000)

console.log(new Date())
複製代碼

內置的 promisify 轉換後函數

若是你的Node版本使用10.x以上的,還能夠從不少內置的模塊中找到相似.promises的子模塊,這裏邊包含了該模塊中經常使用的回調函數的Promise版本(都是async函數),無需再手動進行promisify轉換了。

並且我本人以爲這是一個很好的指引方向,由於以前的工具實現,有的選擇直接覆蓋原有函數,有的則是在原有函數名後邊增長Async進行區分,官方的這種在模塊中單獨引入一個子模塊,在裏邊實現Promise版本的函數,其實這個在使用上是很方便的,就拿fs模塊進行舉例:

// 以前引入一些 fs 相關的 API 是這樣作的
const { readFile, stat } = require('fs')

// 而如今能夠很簡單的改成
const { readFile, stat } = require('fs').promises
// 或者
const { promises: { readFile, stat } } = require('fs')
複製代碼

後邊要作的就是將調用promisify相關的代碼刪掉便可,對於其餘使用API的代碼來說,這個改動是無感知的。
因此若是你的node版本夠高的話,能夠在使用內置模塊以前先去翻看文檔,有沒有對應的promises支持,若是有實現的話,就能夠直接使用。

promisify 的一些注意事項

  1. 必定要符合Error first callback的約定
  2. 不能返回多個參數
  3. 注意進行轉換的函數是否包含this的引用

前兩個問題,使用前邊提到的promisify.custom均可以解決掉。
可是第三項可能會在某些狀況下被咱們所忽視,這並非promisify獨有的問題,就一個很簡單的例子:

const obj = {
  name: 'Niko',
  getName () {
    return this.name
  }
}

obj.getName() // Niko

const func = obj.getName

func() // undefined
複製代碼

相似的,若是咱們在進行Promise轉換的時候,也是相似這樣的操做,那麼可能會致使生成後的函數this指向出現問題。
修復這樣的問題有兩種途徑:

  1. 使用箭頭函數,也是推薦的作法
  2. 在調用promisify以前使用bind綁定對應的this

不過這樣的問題也是創建在promisify轉換後的函數被賦值給其餘變量的狀況下會發生。
若是是相似這樣的代碼,那麼徹底沒必要擔憂this指向的問題:

const obj = {
  name: 'Niko',
  getName (callback) {
    callback(null, this.name)
  }
}

// 這樣的操做是不須要擔憂 this 指向問題的
obj.XXX = promisify(obj.getName)

// 若是賦值給了其餘變量,那麼這裏就須要注意 this 的指向了
const func = promisify(obj.getName) // 錯誤的 this
複製代碼

小結

我的認爲Promise做爲當代javaScript異步編程中最核心的一部分,瞭解如何將老舊代碼轉換爲Promise是一件頗有意思的事兒。
而我去了解官方的這個工具,緣由是在搜索Redis相關的Promise版本時看到了這個readme

This package is no longer maintained. node_redis now includes support for promises in core, so this is no longer needed.

而後跳到了node_redis裏邊的實現方案,裏邊提到了util.promisify,遂抓過來研究了一下,感受還挺有意思,總結了下分享給你們。

參考資料

相關文章
相關標籤/搜索