util.promisify() для замены колбэков на промисы

Промисы на практике оказались удобнее колбэков из-за того, что не требуют постоянного увеличения вложенности при работе с цепочками асинхронных функций. Более того, на базе промисов строится работа async function.

Все асинхронные функции node.js и подавляющее большинство асинхронных функций внешних модулей на данный момент всё же используют колбэки, чтобы вернуть результат работы или сообщить об ошибке.

Технически, не сложно обернуть асинхронную функцию в промиз. Но, к сожалению, это не одна строчка кода.

const fs = require('fs');

function reader(path) {
    return new Promise((resolve, reject) => {
        fs.readdir(path, (err, list) => {
            if (err) {
                reject(err);
                return;
            }

            resolve(list);
        });
    });
}

reader('.')
    .then(list => {
        console.log(list);
        process.exit(0);
    })
    .catch(err => {
        console.error(err);
        process.exit(1);
    });

В стандартной библиотеке node.js 8 util появилась функция promisify(), которая как раз решает эту задачу.

const fs = require('fs');
const util = require('util');

const reader = util.promisify(fs.readdir);

reader('.')
    .then(list => {
        console.log(list);
        process.exit(0);
    })
    .catch(err => {
        console.error(err);
        process.exit(1);
    });

Функция promisify() придерживается соглашения:

  1. колбек в параметрах исходной функции всегда идет последним;
  2. первым параметром колбека приходит объект ошибки, если она есть.

Экспериментируя с этим новым методом я почти сразу натолкнулся на ситуацию, когда мой код переставал работать ожидаемым образом.

function Timer(delay) {
    this._delay = delay;
}

Timer.prototype.start = function (callback) {
    setTimeout(callback, this._delay);
};

const timer = new Timer(1000);
timer.start(() => {
    console.log('Finish');
});

Из метода start() я хочу сделать новую функцию, возвращающую мне промиз.

const start = util.promisify(timer.start);
start().then(() => {
    console.log('Finish');
});

Но этот фрагмент кода перестаёт работать. Он начинает выдавать ошибку:

TypeError: Cannot read property '_delay' of undefined

Проблема кроется в том, что метод внутри себя использует ссылку на экземпляр класса Timer — в частности, он вытаскиваем величину задержки. А так как метод был вызван вне контекста экземпляра таймера, то this оказалось не определено.

Если вернуться к примеру с колбэком, то следующий фрагмент кода тоже не будет работать и выдаст такую же ошибку.

const start = timer.start;
start(() => {
    console.log('Finish');
});

Исправить ситуацию можно несколькими путями.

Связать функцию с экземпляром

const start = util.promisify(timer.start).bind(timer);

Теперь не важно как мы вызовем функцию start(). Она всегда будет иметь экземпляр таймера в качестве контекста исполнения.

Сохранить функцию в экземпляр класса

timer.start = util.promisify(timer.start);
timer.start().then(() => {
    console.log('Finish');
});

Для данного экземпляра мы подменили реализацию метода из прототипа на новую функцию. Контекст исполнения будет передаваться естественным образом.

Развивая этот вариант дальше, новый метод можно сохранить в прототипе класса.

Timer.prototype.start = util.promisify(Timer.prototype.start);

Такой вариант опасен тем, что логика работы изменится для абсолютно всех экземпляров данного класса. Возможно, это не страшно для кода, который вы сами пишете и обслуживаете. Но, вряд-ли будет приемлемо для сторонних модулей.