Управляйте промисами используя Promise.withResolvers()

Источник: «Control JavaScript Promises from Anywhere Using Promise.withResolvers()»
Метод Promise.withResolvers() повышает гибкость, позволяя удалённо разрешать или отклонять промисы, упрощая и оптимизируя асинхронный код.

Промисы в JavaScript всегда крепко держались за свою судьбу. Точка, в которой он разрешается или отвергается (или, проще говоря, разрешается), зависит от функции-исполнителя, предоставляемой при создании промиса. Простой пример:

const promise = new Promise((resolve, reject) => {
setTimeout(() => {
if(Math.random() < 0.5) {
resolve("Resolved!")
} else {
reject("Rejected!");
}
}, 1000);
});

promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});

Дизайн этого API влияет на то, как будет структурирован асинхронный код. Если вы используете промисы, то должны быть согласны с тем, что они будут владеть выполнением этого кода.

В большинстве случаев такая модель вполне подходит. Но бывают случаи, когда было бы неплохо управлять промисом удалённо, разрешая или отклоняя его за пределами конструктора. Я собирался использовать в качестве метафоры дистанционную детонацию, но, надеюсь, ваш код делает что-то менее… разрушительное. Так что давайте рассмотрим такой пример: вы наняли бухгалтера, чтобы он занимался вашими налогами. Он может следовать по пятам, подсчитывая цифры, пока вы занимаетесь своими делами, и сообщить, когда закончит. Или же он может делать всё это из своего офиса на другом конце города и сообщать о результатах. Именно последний вариант и имеется в виду.

Обычно это достигается путём переназначения переменных из внешней области видимости и последующего их использования при необходимости. В продолжение приведённого ранее примера вот как выглядит этот метод внешней области видимости:

let outerResolve;
let outerReject;

const promise = new Promise((resolve, reject) => {
outerResolve = resolve;
outerReject = reject;
});

// Разрешено _извне_ промиса!
setTimeout(() => {
if (Math.random() < 0.5) {
outerResolve("Resolved!")
} else {
outerReject("Rejected!");
}
}, 1000);

promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});

Он выполняет свою работу, но с точки зрения эргономики это немного не то, особенно если учесть, что нужно объявлять переменные в более широкой области, чтобы потом переназначить их.

Более гибкий способ разрешения промисов

Новый метод Promise.withResolvers() делает удалённое разрешение промиса более лаконичным. Метод возвращает объект с тремя свойствами: функция для разрешения, функция для отклонения и свежий промис. Эти свойства можно легко деструктурировать и сделать готовыми к действию:

const { promise, resolve, reject } = Promise.withResolvers();

setTimeout(() => {
if (Math.random() < 0.5) {
resolve('Resolved!');
} else {
reject('Rejected!');
}
}, 1000);

promise
.then((resolvedValue) => {
console.log(resolvedValue);
})
.catch((rejectedValue) => {
console.error(rejectedValue);
});

Поскольку они происходят из одного и того же объекта, функции resolve() и reject() привязаны к этому конкретному промису, а значит, их можно вызывать где угодно. Вы больше не привязаны к конструктору, и нет необходимости переназначать переменные из другой области видимости.

Исследование нескольких примеров

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

Облегчение конструкции промиса

Допустим, необходимо запустить задание, управляемое web worker'ом, для выполнения ресурсоёмкой обработки. Когда задание начинается, необходимо представить его в виде промиса, а затем обработать результат в зависимости от его успешности. Чтобы определить результат, необходимо прослушивать три события: message, error и messageerror. Если использовать традиционный промис, то это будет выглядеть приблизительно так:

const worker = new Worker("/path/to/worker.js");

function triggerJob() {
return new Promise((resolve, reject) => {
worker.postMessage("begin job");

worker.addEventListener('message', function (e) {
resolve(e.data);
});

worker.addEventListener('error', function (e) {
reject(e.data);
});

worker.addEventListener('messageerror', function(e) {
reject(e.data);
});
});
}

triggerJob()
.then((result) => {
console.log("Success!");
})
.catch((reason) => {
console.error("Failed!");
});

Это сработает, но мы впихиваем много всего в сам промис. Код становится более сложным для чтения, и ответственность функции triggerJob() возрастает (здесь происходит не только срабатывание).

Но с помощью Promise.withResolvers() появилось больше возможностей для наведения порядка:

const worker = new Worker("/path/to/worker.js");

function triggerJob() {
worker.postMessage("begin job");

return Promise.withResolvers();
}

function listenForCompletion({ resolve, reject, promise }) {
worker.addEventListener('message', function (e) {
resolve(e.data);
});

worker.addEventListener('error', function (e) {
reject(e.data);
});

worker.addEventListener('messageerror', function(e) {
reject(e.data);
});

return promise;
}

const job = triggerJob();

listenForCompletion(job)
.then((result) => {
console.log("Success!");
})
.catch((reason) => {
console.error("Failed!");
})

На этот раз triggerJob() действительно просто запускает задание, и в ней нет никаких конструкторов. Юнит-тестирование, вероятно, тоже станет проще, поскольку функции имеют более узкое назначение и меньше побочных эффектов.

Ожидание действия пользователя

Эта функция также может сделать обработку пользовательского ввода более интересной. Допустим, есть <dialog>, предлагающий пользователю просмотреть новый комментарий в блоге. Когда пользователь открывает диалог, появляются кнопки approve и reject. Без использования каких-либо промисов обработка кликов по этим кнопкам может выглядеть следующим образом:

reviewButton.addEventListener('click', () => dialog.show());

rejectButton.addEventListener('click', () => {
// обработка отказа
dialog.close();
});

approveButton.addEventListener('click', () => {
// обработка согласия
dialog.close();
});

Опять, это работает. Но можно централизовать часть этой обработки событий с помощью промиса, сохранив при этом относительно плоский код:

const { promise, resolve, reject } = Promise.withResolvers();

reviewButton.addEventListener('click', () => dialog.show());
rejectButton.addEventListener('click', reject);
approveButton.addEventListener('click', resolve);

promise
.then(() => {
// обработка согласия
})
.catch(() => {
// обработка отказа
})
.finally(() => {
dialog.close();
});

Вот так выглядит более проработанная реализация:

See the Pen

Благодаря этому изменению обработчики действий пользователя не нужно распылять по нескольким слушателям событий. Их проще разместить в одном месте, а также сэкономить немного дублирующегося кода, поскольку можно поместить всё, что должно выполняться для каждого действия, в один .finally().

Уменьшение вложенности функций

Вот ещё один пример, подчёркивающий тонкое эргономическое преимущество этого метода. При дебаунсинге затратной функции обычно всё сводится к этой единственной функции. Обычно не возвращается никакого значения.

Представьте интерактивную форму поиска. И запрос, и обновление UI, скорее всего, обрабатываются в одном вызове.

function debounce(func) {
let timer;

return function (...args) {
clearTimeout(timer);

timer = setTimeout(() => {
func.apply(this, args);
}, 1000);
};
}

const debouncedHandleSearch = debounce(async function (query) {
// Получение данных.
const results = await search(query);

// Обновление UI.
updateResultsList(results);
});

input.addEventListener('keyup', function (e) {
debouncedHandleSearch(e.target.value);
});

Но могут быть веские причины для дебаунсинга только асинхронного запроса, а не объединения с ним обновления UI.

Это означает, что нужно дополнить debounce(), чтобы она возвращала промис, который иногда будет разрешаться в результат (когда запрос разрешён к выполнению). Это не сильно отличается от простого подхода, основанного на таймауте. Нужно только убедиться, что правильно разрешаем или отклоняем промис.

До появления Promise.withResolvers() код выглядел очень… многослойным:

function asyncDebounce(callback) {
let timeout = null;
let reject = null;

return function (...args) {
reject?.('rejected_pending');
clearTimeout(timeout);

return new Promise((res, rej) => {
reject = rej;

timeout = setTimeout(() => {
res(callback.apply(this, args));
}, 500);
});
};
}

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

Но теперь можно хоть немного упорядочить происходящее:

function asyncDebounce(callback) {
let timeout = null;
let resolve, reject, promise;

return function (...args) {
reject?.('rejected_pending');
clearTimeout(timeout);

({ promise, resolve, reject } = Promise.withResolvers());

timeout = setTimeout(() => {
resolve(callback.apply(this, args));
}, 500);

return promise;
};
}

Обновление UI при отказе от отклонённых вызовов может выглядеть так:

input.addEventListener('keyup', async function (e) {
try {
const results = await debouncedSearch(e.target.value);

appendResults(results);
} catch (e) {
// Пропускайте исключения из отклонённых промисов,
// но выбрасывайте все остальные.
if(e !== 'rejected_pending') {
throw e;
}
}
});

И получим тот же самый результат, не связывая всё в одну void функцию:

See the Pen

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

Инструмент, позволяющий сохранить больше возможностей

Как видите, ничего концептуально нового в этой функции нет. Вместо этого это одно из тех улучшений качества жизни. Что-то, облегчающее периодическое раздражение при проектировании асинхронного кода. Тем не менее я удивлён тем, как часто начинаю встречать использование этого инструмента в повседневной жизни, наряду со многими другими свойствами Promise, появившимися за последние несколько лет.

Думаю, всё это подтверждает, насколько фундаментальной и ценной стала асинхронная разработка на основе Promise, независимо от того, выполняется ли она в браузере или на сервере. С нетерпением жду, как сильно мы сможем усовершенствовать эту концепцию и связанные с ней API в будущем.

Дополнительные материалы

Предыдущая Статья

Насколько строга ваша транспортная безопасность

Следующая Статья

Как сделать таймер на CSS