怎样手写 util.promisify?回调转 Promise 要注意什么?
promisify 接收一个最后一个参数是错误优先回调的函数,返回一个新函数;新函数把调用参数原样传进去,再补上一个回调,回调里 err 有值就 reject,否则用第一个结果 resolve。要注意 this 不会自动绑定、回调多出来的结果默认只保留第一个,以及 util.promisify.custom 可以直接指定转换结果。
简答
比如
fs.stat(path, callback)这种老式 API,结果通过最后一个回调参数(err, value)返回,第一个参数放错误,这叫错误优先回调。util.promisify把它包成一个返回 Promise 的函数,这样就能用await了。手写的思路是返回一个新函数。新函数里
new Promise,把调用时传的参数原样交给原函数,再在最后补一个回调:err有值就reject(err),否则resolve(value)。这样回调式 API 就能接进
async/await和 Promise 组合方法里,错误也统一走catch。如果原函数的回调不是错误优先的格式,可以在函数上挂util.promisify.custom,自己写好 Promise 版本,promisify会直接返回它。要注意三点。第一,
promisify(obj.method)拿到的函数丢了this,要用call或bind补上。第二,按文档描述的(err, value)格式,只有一个结果会被 resolve,回调多给的参数不会自动打包。第三,promisify默认最后一个参数就是回调,原函数不是这种签名时转出来的函数不会按预期工作。
手写一个最小版本
先看核心实现。readUser 是自己写的模拟回调函数,用 Promise.resolve().then 让回调异步执行,模拟真实 I/O。
const custom = Symbol.for('nodejs.util.promisify.custom');
function promisify(original) {
if (typeof original !== 'function') {
throw new TypeError('original 必须是函数');
}
if (original[custom]) return original[custom];
return function (...args) {
return new Promise((resolve, reject) => {
original.call(this, ...args, (err, value) => {
if (err) reject(err);
else resolve(value);
});
});
};
}
function readUser(id, callback) {
Promise.resolve().then(() => {
if (id <= 0) callback(new Error('用户不存在'));
else callback(null, '用户' + id);
});
}
const readUserAsync = promisify(readUser);
readUserAsync(7).then((name) => console.log(name)); // 用户7
readUserAsync(0).catch((err) => console.log(err.message)); // 用户不存在
调用 readUserAsync(7) 时,参数 7 原样传给 readUser,后面再补上一个回调。readUser 回调 (null, '用户7'),err 是 null,于是 resolve;传 0 时回调收到错误对象,于是 reject。
为什么返回的是 function 而不是箭头函数?因为箭头函数没有自己的 this,下面要用 original.call(this, ...) 把调用时的 this 传下去,只有普通函数才拿得到。开头的类型检查对应文档的说法:original 不是函数时 promisify() 会抛错。
为什么 promisify 一个方法会丢 this
Node.js 文档专门提醒:对类方法或其他用到 this 的方法调用 promisify,如果不做特殊处理,可能不会按预期工作。
'use strict';
const custom = Symbol.for('nodejs.util.promisify.custom');
function promisify(original) {
if (original[custom]) return original[custom];
return function (...args) {
return new Promise((resolve, reject) => {
original.call(this, ...args, (err, value) => (err ? reject(err) : resolve(value)));
});
};
}
class Counter {
constructor() {
this.count = 42;
}
read(callback) {
callback(null, this.count);
}
}
function lookup(host, callback) {
callback(null, '10.0.0.1', 4);
}
function ask(question, onYes, onNo) {
if (question.endsWith('?')) onYes('好');
else onNo(new Error('不是问题'));
}
ask[custom] = (question) => new Promise((resolve, reject) => ask(question, resolve, reject));
const counter = new Counter();
const read = promisify(counter.read);
console.log(String(promisify(ask) === ask[custom])); // true
read()
.catch((err) => console.log(err.constructor.name)) // TypeError
.then(() => read.call(counter))
.then((n) => console.log('call: ' + n)) // call: 42
.then(() => read.bind(counter)())
.then((n) => console.log('bind: ' + n)) // bind: 42
.then(() => promisify(lookup)('example.com'))
.then((value) => console.log(value)) // 10.0.0.1
.then(() => promisify(ask)('可以吗?'))
.then((answer) => console.log(answer)); // 好
counter.read 被单独取出来以后,只是一个普通函数,和 counter 没有关系了。示例开头加了 'use strict',和 ES 模块、class 内部的默认行为一致:直接调用 read() 时 this 是 undefined,读 this.count 就抛出 TypeError。非严格模式下,this 会退回到全局对象,this.count 读到 undefined,不报错但结果是错的。几次调用用 then 串起来,是为了让输出顺序固定。因为这个错误发生在 new Promise 的执行函数里,它会变成 Promise 的拒绝,所以用 catch 能接到。改法和文档示例一致:read.call(counter) 或 read.bind(counter)。
lookup 的回调给了两个结果,但转出来的 Promise 只拿到第一个 '10.0.0.1'。这是手写版的行为,也符合文档对 (err, value) 格式的描述。真实的 dns.lookup 在 promisify 后返回 { address, family } 对象,那是 dns 模块文档单独说明的特例,不是通用规则,所以需要多个结果时,最好用下面的 custom 自己决定返回什么。
util.promisify.custom 解决什么问题
ask 有成功和失败两个回调,不是错误优先格式,直接 promisify 会把补上的回调当成 onYes,结果对不上。文档给的办法是在函数上挂 util.promisify.custom 属性,写好它的 Promise 版本,promisify 遇到这个属性会直接返回它的值,所以上面比较结果是 true。
这个符号在全局符号注册表里注册过,任何环境下都能用 Symbol.for('nodejs.util.promisify.custom') 拿到,所以手写版也用它。文档还说明,custom 属性存在但不是函数时,promisify() 会抛错,手写版为了简短没有做这个检查。
常见追问
原函数本身已经返回 Promise,还能 promisify 吗?
不应该这么做。Node.js 从 v20.8.0 起,对返回 Promise 的函数调用
promisify已被标记为弃用。这类函数本来就能直接await,再包一层只会多等一个永远不会被调用的回调。
回调转 Promise 后,原来的错误处理要怎么改?
错误从回调的
err参数挪到了 Promise 的拒绝里,所以要用try/catch包住await,或者在链上加catch。没有处理的拒绝在 Node.js 里默认会让进程退出,详见《Node 进程怎样处理 uncaughtException 和 unhandledRejection?为什么捕获后仍应退出重启?》。
fs 模块还需要自己 promisify 吗?
多数时候不需要。Node.js 自带
fs/promises这类 Promise 版本的 API,直接用就行。promisify主要用在第三方库或老代码里只有回调版本的函数上。