Как элегантно управлять прослушивателями событий AbortSignal при реализации прерываемых API?

Рассмотрим этот простой пример, вероятно, функцию, которую вы писали пару раз, но теперь ее можно прервать:

/**
 * 
 * @param {number} delay 
 * @param {AbortSignal} [abortSignal]
 * @returns {Promise<void>}
 */
export default function timeoutPromise(delay, abortSignal) {
    return new Promise((resolve, reject) => {
        if (abortSignal) {
            abortSignal.throwIfAborted();
        }

        const timeout = setTimeout(() => {
            resolve();
        }, delay);

        abortSignal.addEventListener("abort", () => {
            clearTimeout(timeout);
            reject(new Error("Aborted"));
        });
    });
}

Очевидная проблема заключается в том, что это не очистит eventListener, если тайм-аут пройдет нормально. Это можно сделать, но это довольно некрасиво:

/**
 * 
 * @param {number} delay 
 * @param {AbortSignal} [abortSignal]
 * @returns {Promise<void>}
 */
export default function timeoutPromise(delay, abortSignal) {
    return new Promise((resolve, reject) => {
        // note: changed to reject() to get consistent behavior regardless of the signal state
        if (abortSignal && abortSignal.aborted) {
            reject(new Error("timeoutPromise aborted"));
        }
        let timeout = null;
        function abortHandler() {
            clearTimeout(timeout);
            reject(new Error("timeoutPromise aborted"))
        }
        timeout = setTimeout(() => {
            if (abortSignal) {
                abortSignal.removeEventListener("abort", abortHandler);
            }
            resolve();
        }, delay);

        if (abortSignal) {
            abortSignal.addEventListener("abort", abortHandler, {once: true});
        }
    });
}

Это... много кода для такой простой вещи. Правильно ли я делаю или есть лучший способ?

a lot of code написано немного по-другому, фактически это две дополнительные строки кода
Jaromanda X 15.07.2024 04:27

Почему вы больше не используете abortSignal.throwIfAborted();? И что вообще с if (abortSignal)?

Bergi 15.07.2024 08:29

Почему вы хотите удалить прослушиватель событий? Если никто не имеет ссылки на AbortSignal, он будет удален вместе с ним.

Kaiido 15.07.2024 08:31

Также можно отметить, что именно для этого случая прерываемого тайм-аута есть один (в настоящее время только в Chrome): Scheduler.postTask(cb, {delay, signal}) и он даже возвращает обещание.

Kaiido 15.07.2024 08:36

@Kaiido, потому что сигнал об отмене часто намного превышает тайм-аут

Bergi 15.07.2024 08:39

@Берги и? Прикрепление к нему события добавляет очень мало накладных расходов по сравнению со всем AbortController.

Kaiido 15.07.2024 08:40

@Kaiido Это все еще утечка памяти. Обработчик событий abort закрывается по timeout, reject и — в зависимости от реализации обещания — даже по обещанию и его значению. Вы не хотите, чтобы долго выполняющаяся задача с множеством небольших задержек накапливала множество обработчиков для сигнала прерывания — обычно только последний из них действительно может прервать задачу.

Bergi 15.07.2024 08:43

@Kaiido Это всего лишь простой пример. В общем, сохранение прослушивателя может захватывать произвольное количество переменных с помощью обратного вызова. Кроме того, утечка памяти только потому, что вы думаете, что утечка мала, на самом деле не является хорошим подходом и не тем, чего вы ожидаете от «элегантного» решения.

Tomáš Zato 15.07.2024 11:56

@Bergi Я удалил throwIfAborted, потому что это приводит к непоследовательному поведению. Если сигнал прерван, он выдается при создании обещания, если сигнал был отменен мгновением позже, он выдает только await. Это актуально, когда вы ожидаете обещаний позже, а не момента их создания, что необходимо для одновременного выполнения задач. if (abortSignal) присутствует, потому что сигнал должен быть необязательным.

Tomáš Zato 15.07.2024 11:57

@TomášZato "выдает при создании обещания" - нет, signal.throwIfAborted() в обратном вызове исполнителя точно эквивалентно if (signal.aborted) reject(signal.reason). "когда вы ожидаете обещаний позже, а не момента их создания, что необходимо для одновременного выполнения задач" - нет, это не имело бы значения. И в любом случае этого делать никогда не стоит! Всегда используйте Promise.all для параллельных задач.

Bergi 15.07.2024 12:49

@Bergi Если вы ждете Promise.all, то общее время вашей работы — это время на выполнение самого медленного обещания плюс сумма времени на обработку результатов. Если вы используете Promise.race для обработки ответов по мере их поступления, вы можете обрабатывать ответы параллельно с ожиданием промисов, но в этом случае вам нужно где-то хранить невыполненные промисы. Совсем недавно я слышал, как коллега рассказывал об ошибке, когда кто-то слепо следовал правилу Promise.all, что вызывало серьезные узкие места в системе.

Tomáš Zato 15.07.2024 13:41

@TomášZato Вы по-прежнему можете обрабатывать результаты по мере их поступления с помощью Promise.all, просто поместите код обработки прямо там, где вы делаете каждый запрос (Promise.all(vals.map(async v => { const response = await request(v); return handle(response); }))). Нет необходимости ставить Promise.race или держать невыполненные обещания в течение некоторого времени.

Bergi 15.07.2024 14:00

красиво и познавательно, и... вопрос и ответ.

Peter Seliger 15.07.2024 15:07

@Bergi Хорошо, это хороший момент, ты можешь это сделать. Думаю, я больше думал о конкретном примере, над которым работаю, где количество обещаний очень велико, поэтому я начинаю некоторые, использую Promise.race и заменяю выполненные новыми. Вероятно, это не то, о чем стоит беспокоиться типичному веб-разработчику. Я говорю о миллионах задач, выполняющих максимум N параллельно.

Tomáš Zato 15.07.2024 16:45

@TomášZato Конечно, есть устоявшиеся шаблоны для решения этой проблемы, ни одно из них не требует невыполненных обещаний, оставшихся висеть

Bergi 15.07.2024 16:53
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
Улучшение производительности загрузки с помощью Google Tag Manager и атрибута Defer
В настоящее время производительность загрузки веб-сайта имеет решающее значение не только для удобства пользователей, но и для ранжирования в...
Безумие обратных вызовов в javascript [JS]
Безумие обратных вызовов в javascript [JS]
Здравствуйте! Юный падаван 🚀. Присоединяйся ко мне, чтобы разобраться в одной из самых запутанных концепций, когда вы начинаете изучать мир...
Система управления парковками с использованием HTML, CSS и JavaScript
Система управления парковками с использованием HTML, CSS и JavaScript
Веб-сайт по управлению парковками был создан с использованием HTML, CSS и JavaScript. Это простой сайт, ничего вычурного. Основная цель -...
JavaScript Вопросы с множественным выбором и ответы
JavaScript Вопросы с множественным выбором и ответы
Если вы ищете платформу, которая предоставляет вам бесплатный тест JavaScript MCQ (Multiple Choice Questions With Answers) для оценки ваших знаний,...
3
15
71
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Вы можете использовать необязательную цепочку для всех вызовов метода AbortSignal, и это станет более простым:

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    function done() {
      resolve();
      signal?.removeEventListener("abort", stop);
    }
    function stop() {
      reject(this.reason);
      clearTimeout(handle);
    }
    signal?.throwIfAborted();
    const handle = setTimeout(done, ms);
    signal?.addEventListener("abort", stop);
  });
}

(from my answer to How to cancel JavaScript sleep?)

Либо сделать только одну проверку на наличие сигнала:

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (!signal) {
      setTimeout(resolve, ms);
      return;
    }

    function done() {
      resolve();
      signal.removeEventListener("abort", stop);
    }
    function stop() {
      reject(this.reason);
      clearTimeout(handle);
    }
    signal.throwIfAborted();
    const handle = setTimeout(done, ms);
    signal.addEventListener("abort", stop);
  });
}

что вы, конечно, можете играть в гольф дальше:

function delay(ms, signal) {
  return new Promise((resolve, reject) => {
    if (!signal) return setTimeout(resolve, ms);
    signal.throwIfAborted();
    const handle = setTimeout(() => {
      resolve();
      signal.removeEventListener("abort", stop);
    }, ms);
    const stop = () => {
      reject(signal.reason);
      clearTimeout(handle);
    };
    signal.addEventListener("abort", stop);
  });
}

Другие вопросы по теме