Управляйте промисами используя 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();
});
Вот так выглядит более проработанная реализация:
Благодаря этому изменению обработчики действий пользователя не нужно распылять по нескольким слушателям событий. Их проще разместить в одном месте, а также сэкономить немного дублирующегося кода, поскольку можно поместить всё, что должно выполняться для каждого действия, в один .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
функцию:
Это не кардинальное изменение, но оно сглаживает некоторые неровности при выполнении такой задачи.
Инструмент, позволяющий сохранить больше возможностей
Как видите, ничего концептуально нового в этой функции нет. Вместо этого это одно из тех улучшений качества жизни
. Что-то, облегчающее периодическое раздражение при проектировании асинхронного кода. Тем не менее я удивлён тем, как часто начинаю встречать использование этого инструмента в повседневной жизни, наряду со многими другими свойствами Promise
, появившимися за последние несколько лет.
Думаю, всё это подтверждает, насколько фундаментальной и ценной стала асинхронная разработка на основе Promise
, независимо от того, выполняется ли она в браузере или на сервере. С нетерпением жду, как сильно мы сможем усовершенствовать эту концепцию и связанные с ней API в будущем.