天天看點

util.promisify 的那些事兒

util.promisify

是在

node.js 8.x

版本中新增的一個工具,用于将老式的

Error first callback

轉換為

Promise

對象,讓老項目改造變得更為輕松。

在官方推出這個工具之前,民間已經有很多類似的工具了,比如es6-promisify、thenify、bluebird.promisify。

以及很多其他優秀的工具,都是實作了這樣的功能,幫助我們在處理老項目的時候,不必費神将各種代碼使用

Promise

再重新實作一遍。

工具實作的大緻思路

首先要解釋一下這種工具大緻的實作思路,因為在

Node

中異步回調有一個約定:

Error first

,也就是說回調函數中的第一個參數一定要是

Error

對象,其餘參數才是正确時的資料。

知道了這樣的規律以後,工具就很好實作了,在比對到第一個參數有值的情況下,觸發

reject

,其餘情況觸發

resolve

,一個簡單的示例代碼:

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

擷取所有的傳回值

正常的使用方式

拿一個官方文檔中的示例
const { promisify } = require('util')
const fs = require('fs')

const statAsync = promisify(fs.stat)

statAsync('.').then(stats => {
  // 拿到了正确的資料
}, err => {
  // 出現了異常
})
           

以及因為是

Promise

,我們可以使用

await

來進一步簡化代碼:

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

const statAsync = promisify(fs.stat)

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

用法與其他工具并沒有太大的差別,我們可以很輕易的将回調轉換為

Promise

,然後應用于新的項目中。

自定義的 Promise 化

有那麼一些場景,是不能夠直接使用

promisify

來進行轉換的,有大概這麼兩種情況:

  1. 沒有遵循

    Error first callback

    約定的回調函數
  2. 傳回多個參數的回調函數

首先是第一個,如果沒有遵循我們的約定,很可能導緻

reject

的誤判,得不到正确的回報。

而第二項呢,則是因為

Promise.resolve

隻能接收一個參數,多餘的參數會被忽略。

是以為了實作正确的結果,我們可能需要手動實作對應的

Promise

函數,但是自己實作了以後并不能夠確定使用方不會針對你的函數調用

promisify

是以,

util.promisify

還提供了一個

Symbol

類型的

key

util.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

以外會包含兩個,

stdout

stderr

,一個是指令執行後的正确輸出,一個是指令執行後的錯誤輸出:

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' }
           

包括像

setTimeout

setImmediate

也都實作了對應的

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

,遂抓過來研究了一下,感覺還挺有意思,總結了下分享給大家。

參考資料

  • util.promisify
  • child_process.exec
  • fs.promises

繼續閱讀