Привет, Хабр!

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

Интересно? Тогда добро пожаловать под кат! 

Supercat думает над цепочками вызовов
Supercat думает над цепочками вызовов

Типичная задача

На странице есть поле для ввода данных (input_element), рядом отображается индикатор ошибки (error_element). Также есть индикатор загрузки данных с сервера (status_element). В элементе output_element будет отображаться ответ от сервера.

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

Вот что нужно сделать:

  1. Пользователь вводит данные в поле. Как только он заканчивает вводить, через 100 миллисекунд происходит проверка данных. В нашем примере предположим, что в поле вводятся только цифры. Если данные не проходят проверку, то появляется сообщение об ошибке. В этом случае цепочка прерывается.

  2. Если данные корректны, то через 500 миллисекунд они отправляются на сервер. В этот момент появляется индикатор загрузки со статусом «Loading». Предыдущее содержимое элемента «output_element» удаляется.

  3. После получения ответа от сервера данные отображаются в элементе «output_element».

  4. Также необходимо реализовать кэширование запросов к серверу. Если пользователь уже вводил данные, которые были сохранены в кэше, то не нужно повторно запрашивать их с сервера, а сразу выводить ответ.

В чем проблема написания цепочки

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

/**
 * @param {number} ms
 */
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}

const input_element = /** @type {HTMLInputElement} */ (document.querySelector("#input"));
const output_element = /** @type {HTMLDivElement} */ (document.querySelector("#output"));
const status_element = /** @type {HTMLSpanElement} */ (document.querySelector("#status"));
const error_element = /** @type {HTMLDivElement} */ (document.querySelector("#error"));

// кэш
let cache = {};

/**
 * @param {string} value значение поле ввода
 */
async function operateValue(value) {
    try {
        await sleep(100);
        status_element.textContent = "";
        error_element.textContent = "";
        
        // валидация
        if (value === "") {
            throw new Error("Empty input");
        }
    
        if (!/^\d+$/.test(value)) {
            throw new Error("Not a number");
        }

        // проверка на наличие значения в кэше
        if (cache.hasOwnProperty(value)) {
            output_element.textContent = cache[value];
            return;
        }
      
        await sleep(500);

        // загрузка данных
        status_element.textContent = "Loading...";
        output_element.textContent = "";
    
        let response = await fetch("https://jsonplaceholder.org/comments?id=" + value);
        let text = await response.text();

        // запись в кэш
        cache[value] = text;

        status_element.textContent = "Loaded";
        output_element.textContent = text;
        return;
    }
    catch (e) {
        error_element.textContent = e.message;
    }
}

Код, который, казалось бы, выполняет свою задачу, на практике оказывается неприменим. В чём причина?

Прежде всего, нужно предотвратить возможность одновременного выполнения функции operateValue. Необходимо использовать так называемый «конкурентный» режим, который остановит выполнение предыдущей функции.

Кроме того, в коде используются асинхронные функции sleep и fetch, выполнение которых нужно как-то остановить. В случае с fetch нужно будет использовать AbortController. Однако для функции sleep придётся переписать код так, чтобы она либо зависела от AbortController, либо создать обёртку, которая будет прерывать ожидание результата.

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

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

Решение с помощью @supercat1337/chain

Библиотека @supercat1337/chain — это инструмент, который позволяет создавать, запускать и отменять цепочку вызовов. Код доступен на GitHub: https://github.com/supercat1337/chain.

Библиотека представляет собой ESM-модуль. Она имеет строгую типизацию. Размер её бандла после минификации составляет всего 3.0 килобайта. Лицензия MIT.

Прежде чем перейти к описанию возможностей библиотеки, я покажу, как можно решить задачу с её помощью. Живой пример можно глянуть тут: https://stackblitz.com/edit/vitejs-vite-wsacda?file=index.html,index.js&terminal=dev.  

Установка @supercat1337/chain

npm install @supercat1337/chain

Теперь пишем код:

// @ts-check
import { Chain } from "@supercat1337/chain";

const input_element = /** @type {HTMLInputElement} */ (document.querySelector("#input"));
const output_element = /** @type {HTMLDivElement} */ (document.querySelector("#output"));
const status_element = /** @type {HTMLSpanElement} */ (document.querySelector("#status"));
const error_element = /** @type {HTMLDivElement} */ (document.querySelector("#error"));

/** @type {{[key:string]:string}} */
let cache = {};

// создаем цепочку
const chain = new Chain();

// устанавливаем обработчик ошибок
chain.on("error", (details) => {
    status_element.textContent = "";
    error_element.textContent = details.error?.message || "";
});

// добавляем таск в цепочку. value - это значение поля ввода. см. вызов chain.run()
chain.add(async (/** @type {string} */ value, chainController) => {
    await chainController.sleep(100);
    
    status_element.textContent = "";
    error_element.textContent = "";

    // валидация
    if (value === "") {
        throw new Error("Empty input");
    }

    if (!/^\d+$/.test(value)) {
        throw new Error("Not a number");
    }

    await chainController.sleep(500);
    
    // проверка на наличие значения в кэше
    if (cache.hasOwnProperty(value)) {
        output_element.textContent = cache[value];
        return;
    }

    // загрузка данных
    status_element.textContent = "Loading...";
    output_element.textContent = "";
  
    let response = await chainController.fetch("https://jsonplaceholder.org/comments?id=" + value);

    let text = await response.text();
    cache[value] = text;

    status_element.textContent = "Loaded";
    output_element.textContent = text;
    return;
});

input_element.addEventListener("keyup", async (event) => {
    // отменяем выполнение цепочки
    await chain.cancel();
    // запускаем цепочку, передаем в первый таск значение поля ввода
    chain.run(input_element.value);
});

И что мы видим? Первоначальный код практически не изменился. Читаемость не ухудшилась. Однако, можно заметить, что вместо sleep() в новом коде используется chainController.sleep(), и вместо fetch - chainController.fetch(). Опытные разработчики уже догадались, что под капотом chainController имеется AbortController. Но обо всем по порядку.

Работа с Chain

Класс Chain предоставляет инструменты для добавления задач, запуска цепочки и контроля её состояния. Управляет цепочкой извне (дальше поймете о чем идет речь). Документацию вы можете почитать тут: https://github.com/supercat1337/chain/blob/main/README.md.

Я не буду тут приводить перевод документации. Из примера понятно, что обработка событий осуществляется через метод on. Таски добавляются с помощью метода add. Метод cancel - отменяет цепочку. А run - запускает.

Единственное, на что стоит обратить внимание - это на то, что запуск цепочки может быть отменен, если цепочка уже выполняется. По этой причине перед вызовом run() рекомендую вызывать метод cancel, чтобы гарантировать запуск, обеспечивая так называемый "конкурентный режим".

Общий пример:

// @ts-check
import { Chain } from "@supercat1337/chain";

const chain = new Chain();

chain.on("complete", () => {
    console.log("complete");
});

chain.on("run", () => {
    console.log("run");
});

chain
    .add(async (previousResult, chainController) => {
        console.log("previousResult = ", previousResult);
        console.log("task 0");
        return 1;
    })
    .add(async (previousResult, chainController) => {
        console.log("task 1");
        console.log("previousResult = ", previousResult);
        return 200;
    })
    .add(async (previousResult, chainController) => {
        console.log("task 2");
        console.log("previousResult = ", previousResult);
        return 3;
    });

chain.run(500).then(result => {
    console.log("result = ", result);
});

/* Output:
run
task 0
previousResult =  500
task 1
previousResult =  1
task 2
previousResult =  200
complete
result =  3
*/

Обратите внимание на то, что каждое возвращаемое значение завершенного таска передается в следующий за ним таск.

chainController: Управление цепочкой из таска

Таск - это функция, которая вызывается с двумя параметрами: previousResult, chainController.

  1. previousResult - содержит значение предыдущего завершенного таска. Если таск в очереди первый, то previousResult содержит значение, переданное в первом аргументе метода run(). См. Общий пример.

  2. chainController - это специальный объект для управления цепочкой изнутри. Вот на этом месте я хочу процитировать документацию:

Свойства chainController

  • chain: содержит ссылку на инстанс цепочки.

  • abortController:  AbortController, используемый "под капотом" для завершения работы асинхронных функций.

  • ctx: контекст - объект, позволяющий передавать дополнительную информацию по всей цепочке.

Методы chainController

  • async cancel(): Отменяет цепочку, если она была запущена.

  • async complete(value): Завершает выполнение цепочки с указанным значением.

  • async sleep(ms): Ставит паузу на выполнение на указанные ms миллисекунд. Если цепочка будет отменена во время работы sleep, то он будет завершен немедленно.

  • async fetch(url, options): Функция-враппер вокругfetch , которая будет сразу же завершена, если в процессе ее работы цепочка будет отменена.

  • async wrap(fn): Функция-враппер вокруг асинхронной функции fn. Ее смысл в том, что если асинхронная функция fn не поддерживает интерфейс AbortController, то возвращаемый Promise будет завершен либо после того, как функция fn отработала, либо по событию отмены цепочки.

  • async checkAbortSignal(): Проверяет если сигнал AbortController'a был прерван, то он прерывает цепочку. Это сделано на тот случай, если кому-то необходимо будет работать с chainController.abortController.abort() внутри кода таска.

Приведу несколько коротких примеров работы с chainController под спойлерами.

Успешное завершение цепочки внутри таска:
import { Chain } from "@supercat1337/chain";

/** @type {Chain<number>} */
const chain = new Chain();

chain
    .add(async (previousResult, chainController) => {
        console.log("task 0");
        return 0;
    })
    .add(async (previousResult, chainController) => {
        console.log("task 1");
        chainController.complete(100);
        return 1;
    })
    .add(async (previousResult, chainController) => {
        console.log("task 2");
        return 2;
    });

chain.run().then(result => {
    console.log("result = ", result);
});

/* Output:
task 0
task 1
result =  100
*/

Отмена цепочки внутри таска.
import { Chain } from "@supercat1337/chain";

/** @type {Chain<number>} */
const chain = new Chain();

chain.on("cancel", (details) => {
    console.log("cancel");
});

chain
    .add((previousResult, chainController) => {
        console.log("task 0");
        return 0;
    })
    .add((previousResult, chainController) => {
        console.log("task 1");
        chainController.cancel();
        return 1;
    })
    .add((previousResult, chainController) => {
        console.log("task 2");
        return 2;
    });

chain.run().then(result => {
    console.log("result = ", result);
});

/* Output:
task 0
task 1
cancel
result =  null
*/

Обертка асинхронной функции, с помощью метода wrap
import { Chain } from "@supercat1337/chain";

// async function that can't be cancelled by the abort signal.
// function will be executed after 5 seconds and will log "test"
async function test() {
    return new Promise(resolve => setTimeout(()=>{
        console.log("test");
        resolve();
    }, 5000));    

}

const chain = new Chain();

chain.add(async (v, chainController) => {
    let fn = chainController.wrap(test);
    await fn();
});

chain.add(async () => {
    console.log("Never executed");
});

console.log("Start");
chain.run();
await sleep(1000);
await chain.cancel();
console.log("End");

/* Output:
Start
End
test
*/

Заключение

Использование библиотеки @supercat1337/chain для управления цепочками вызовов асинхронных функций — это мощный и элегантный подход. Он позволяет упростить код, сделать его более читаемым и гибким.

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

Если вам нужно управлять цепочками вызовов асинхронных функций, я настоятельно рекомендую попробовать библиотеку @supercat1337/chain. Она поможет вам сэкономить время и усилия, а также улучшить качество вашего кода.

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

Комментарии (8)


  1. js_onelove
    24.10.2024 12:50

    Хорошая статья, но честно не хватает визуала :)
    Не все любят смотреть код. В целом понял какое используется решение.
    Идею можно взять на заметку!


    1. supercat1337 Автор
      24.10.2024 12:50

      Спасибо! На всякий случай ещё раз продублирую ссылку на песочницу https://stackblitz.com/edit/vitejs-vite-wsacda?file=index.html,index.js&terminal=dev


  1. hahavenn
    24.10.2024 12:50

    Статья интересная, было интересно посмотреть исходный код библиотеки) Даже звездочку поставил

    По статье - довольно много кода. Как вариант могу предложить - разбивать на логические блоки, по типу - "создадим тестовую функцию", "добавим её в цепочку", "отменяем цепочку". Мне кажется проще так будет для понимания, потому что читающий сможет сосредоточиться на логическом блоке, а не листать весь код

    Автору удачи, задумка крутая, исходники "прошершены"

    Вопрос только (из исходника), может вместо подхода с просмотром через интервал - добавить что-то типа наблюдателя? А то получается что мы цикл прогоняем и просто в нём ждем завершения. Плюсом т.к. через промисы сделана sleep, я думаю может быть в очереди микротаск этот может затеряться среди других и время может быть увеличено с 100мс до 300мс (как пример). Если я неправ в чем-то - прошу поправить :)

    /**
     * Waits until the chain is not running anymore. If the chain is not running, the function returns immediately.
     * @returns {Promise<void>}
     */
    async waitForChainToFinish() {
      while (this.#isRunning) {
        await sleep(100);
      }
    }


    upd1: Можно попробовать взять за основу Proxy, они должны (насколько знаю) сразу применять изменения и запускать код внутри оберток синхронно, что дает преимущество надо промисами


    1. supercat1337 Автор
      24.10.2024 12:50

      Спасибо! Постараюсь в следующий раз код разбивать на более маленькие блоки :).

      Кстати, что касается waitForChainToFinish() мысль вполне здравая. Можно ресолвить промис с событий complete, error, cancel. Я рассмотрю этот вариант.


      1. hahavenn
        24.10.2024 12:50

        Удачи!


  1. adminNiochen
    24.10.2024 12:50

    Что-то я не понимаю, зачем какие-то чейны, если задача из примера - типичный дебаунс. Создаём тайммаут, создаём функцию, которая отменяет таймаут - profit.


    1. supercat1337 Автор
      24.10.2024 12:50

      У нас там не один слип, а два слипа. Дебаунс вы можете применить в начале вызова функции. Но не забудьте, что нам нужно еще и отменить всю цепочку. Кроме того, отменить все последующие асинхронные функции.


    1. Senyaak
      24.10.2024 12:50

      Основная "фича" данного подхода прерывание асинхронных операций, что в вашем х*к х*к и вродакшн не предусмотрено. Но на самом деле на дебаунсе код не многим бы отличался, просто везде пробрасывался бы абортконтроллер который при старте нового чейна обнулял предыдущий, но в данной статье цель автора в демонстрации приятнoй утилитки которая может комуто да облегчит жизнь.