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)
})
})
}
複製程式碼
- 呼叫工具函式返回一個匿名函式,匿名函式接收原函式的引數。
- 匿名函式被呼叫後根據這些引數來呼叫真實的函式,同時拼接一個用來處理結果的
callback
。 - 檢測到
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
來進行轉換的,有大概這麼兩種情況:
- 沒有遵循
Error first callback
約定的回撥函式 - 返回多個引數的回撥函式
首先是第一個,如果沒有遵循我們的約定,很可能導致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
時就會進行判斷:
- 如果目標函式存在
promisify.custom
屬性,則會判斷其型別:- 如果不是一個可執行的函式,丟擲異常
- 如果是可執行的函式,則直接返回其對應的函式
- 如果目標函式不存在對應的屬性,按照
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 的一些注意事項
- 一定要符合
Error first callback
的約定 - 不能返回多個引數
- 注意進行轉換的函式是否包含
this
的引用
前兩個問題,使用前邊提到的promisify.custom
都可以解決掉。
但是第三項可能會在某些情況下被我們所忽視,這並不是promisify
獨有的問題,就一個很簡單的例子:
const obj = {
name: 'Niko',
getName () {
return this.name
}
}
obj.getName() // Niko
const func = obj.getName
func() // undefined
複製程式碼
類似的,如果我們在進行Promise
轉換的時候,也是類似這樣的操作,那麼可能會導致生成後的函式this
指向出現問題。
修復這樣的問題有兩種途徑:
- 使用箭頭函式,也是推薦的做法
- 在呼叫
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
,遂抓過來研究了一下,感覺還挺有意思,總結了下分享給大家。