前端知库

怎样手写 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。

JavaScript
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,如果不做特殊处理,可能不会按预期工作。

JavaScript
'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 主要用在第三方库或老代码里只有回调版本的函数上。