一个叫木头,一个叫马尾

JavaScript: 如何编写一个既支持Callback又支持Promise风格的函数

Node.js 发布之初,对于异步操作,都是以 error first callback 风格提供执行结果。

fs.readFile('/foo.txt', (err, data) => {
  // err非空时,表示出现异常
  // data是操作的结果,这里是读取文件的内容
  console.log(data);
});

对于简单的任务还好,如果一项任务涉及多个异步操作,容易出现代码多层嵌套;而并行的异步操作协同起来也不方便。

Callback Hell
Callback Hell

于是,Promise 被引入了。为了方便,Node官方的fs包,甚至提供了promise版本的各项函数:

fs.promises
  .readFile("t.js")
  .then((text) => {})
  .catch((err) => {});

对于其它的已经存在的 error first callback 函数,Node 也提供了util.promisify函数,以帮助转换:

const util = require("util");

util.promisify(fs.readFile);

以上是 callback风格Promise风格 的一点简单历史。再回到主题上来:

如何让一个函数同时支持 Callback 和 Promise 风格?


先说说我们要的效果,它是这样的:

// 一个异步函数
function someFun(callback{}

// 可以传callback执行
someFun(console.log);

// 可以返回Promise
someFun().then(console.log);

假设这个异步函数是在2秒后生成一个随机数。对于 callback 风格,可以这样写:

function someFun(callback{
  setTimeout(() => {
    callback(null,Math.random());
  }, 2000);
}

对于 Promise 风格,可以这样写:

function someFun({
  return new Promise((res) => {
    setTimeout(() => {
      res(Math.random());
    }, 2000);
  });
}

怎么将2种风格合并呢?


最初的思考是,判断 callback参数是否存在,不存在则返回 Promise,否则调用 callback

function someFun(callback{
  if (callback === undefined) {
    return new Promise((res) => {
      setTimeout(() => {
        res(Math.random());
      }, 2000);
    });
  }

  setTimeout(() => {
    callback(nullMath.random());
  }, 200);
}

虽然运行起来没问题,但让人感觉别扭,因为 延时生成随机数 这块代码,重复了2次。

再一想,可以优化如下:

function someFun(callback{
  if (callback === undefined) {
    return new Promise((res, rej) => {
      someFun((err, data) => (err ? rej(err) : res(data)));
    });
  }

  setTimeout(() => {
    callback(nullMath.random());
  }, 200);
}

Promise 复用 callback 的逻辑,达到了消除重复代码的问题。

不考虑具体功能,在编写自己的同时支持两种调用风格的代码时,得用以上模板会有问题吗?

请思考一会再往后读。


以上代码的问题

如果单纯按 延时生成随机数 这个要求,上述代码无可厚非。但我们写文章通常是为了要泛化(generalize)某个思考方式,使其具有普适性(更广的用途),而不是局限于某个特定的单一问题。

上述代码有问题吗?如果我们的异步函数内部使用了 this,代码便会出现问题。

提供一个检验的方式供参考:将异步函数someFun挂载到某个对象obj下,然后以 promise 风格执行:

// 一个异步函数
function someFun(callback) {
  console.log(this);
  if (callback === undefined) {
    return new Promise((res, rej) => {
      someFun((err, data) => (err ? rej(err) : res(data)));
    });
  }

  setTimeout(() => {
    callback(null, Math.random());
  }, 200);
}

const obj = {
  someFun,
};

// 以 Promise 风格执行
obj.someFun().then(console.log);

你猜会打印什么?

控制台运行结果如下:

惊讶吗?

首先,打印了2次,因为callback参数为空时,我们会以callback方式重新执行someFun函数。

更重要的问题是——2次打印的this竟然不同!

对于同时支持两种风格的函数,如果其依赖了this(比如使用了this上的属性或方法),某些情况下你会得到意想不到的结果!

怎么解决?还记得 JavaScript 中每个函数都有callapply方法吗?在我看来,它们都是函数的瑰宝(另外还有bind),在解决this问题上,总能帮上忙。

重整后的实现如下:

function someFun(callback) {
  console.log(this);
  if (callback === undefined) {
    return new Promise((res, rej) => {
      someFun.call(this, (err, data) => (err ? rej(err) : res(data)));
    });
  }

  setTimeout(() => {
    callback(null, Math.random());
  }, 200);
}

注意 someFun.call(this,...) 的使用,它将 this 绑定为函数初始执行时的值(本例中即obj)。再运行上面的例子,我们得到:

可以看到,2次打印的this是一致的,即使你要this上其它的属性和方法,也不会有问题了。


本文灵感来自于 github.com/nodejs/undici[1]nodejs/undici 是 Node.js 官方维护的一个 http client 库,没用过的,推荐大家尝尝鲜。

[1]

github.com/nodejs/undici: https://github.com/nodejs/undici/blob/master/lib/client-request.js