Проблеми TypeScript у світі React-додатків вiд Iллi Климова на React fwdays | 27 березня
×Закрыть

CPromise. Еще один велосипед с возможностью закрытия промисов

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Хотелось бы познакомить общественность со своим новорожденным велосипедом питомцем- c-promise2 пакетом, который предоставляет расширенную версию нативного Promise (наследуется от него). Основная причина его появления — эксперименты с закрытием промисов. Большинство существующих решений рассматривают этот процесс как прекращение выполнения навешанных через then колбеков.

К моему сожалению, не так давно этой философией проникся и bluebird.js, но лично мне она не по душе ввиду нарушения контракта/идеологии промисов — обещанный асинхронный переход в одно из двух возможных состояний. Потому пришла идея запилить свое решение, у которого закрытие это старый добрый реджект со специальной ошибкой и рекурсивного вызова пользовательских обработчиков, подписанных на close событие. Простое игнорирование результата промиса, оставляя при этом в фоне ненужную задачу, потребляющую IO/RAM/CPU, это точно не наш метод, хотя на же stackoverflow он в виде костылей с Promise.race встречается весьма часто.

Кроме закрытия, сюда завезли еще:

  • поддержку генераторов (как вынужденная альтернатива async функциям)
  • захват прогресса выполнения
  • пауза/возобновление
  • таймауты
  • декораторы
  • поддержку AbortController (как внешнего, так и внутреннего)
  • сигналы/события и propagate данных
  • лимит одновременно выполняющихся промисов для all, allSettled методов, поддержка маппера/редюсера и генератора в
    качестве источника промисов.

Естественно, все это работает на цепочках промисов, в том числе на вложенных, а не только на одном отдельном взятом, никаких лишних телодвижений не нужно, если цепочка из CPromise не разрывается. У каждого инстанса CPromise есть метод cancel, который через внутренний механизм сигналов просит самый «глубокий» выполняющейся промис в цепочке отклонится с ошибкой CanceledError. Всплывая по цепочке, она будет приводить к генерации события cancel на каждом промисе. По умолчанию, если не установлена force опция, закрываются только обособленные ветки/цепочки, т.е у которых нет других выполняющихся ответвлений.

    import CPromise from "c-promise2";

    const chain = new CPromise((resolve, reject, {onCancel}) => {
        const timer = setTimeout(resolve, 1000, 123);
        onCancel(() => clearTimeout(timer));
    })
        .then((value) => value + 1)
        .then(
            (value) => console.log(`Done: ${value}`),
            (err, scope) => {
                console.warn(err); // CanceledError: canceled
                console.log(`isCanceled: ${scope.isCanceled}`); // true
                console.log(`isCanceled: ${chain.isCanceled}`); // true
            }
        );

    setTimeout(() => chain.cancel(), 100);

В дополнение, промис или их цепочку можно подписать на сигнал от AbortController, при чем на любое их количество
одновременно.

    import CPromise from "c-promise2";

    const controller = new CPromise.AbortController();
    new CPromise((resolve, reject, {onCancel}) => {
        const timer = setTimeout(resolve, 1000, 123);
        onCancel(() => clearTimeout(timer));
    }).listen(controller.signal);

    setTimeout(() => controller.abort(), 100);

У каждого промиса есть внутренний AbortSignal (создается налету по требованию), на который может подписаться внутренний пользовательский код. Например, это позволяет легко обернуть стандартный fetch в CPromise и как бонус добавить timeout (немного более проработанная версия этого кода в связке с кроссплатформенным cross-fetch пока опубликована как cp-fetch).

    function fetchWithTimeout(url, {timeout, ...fetchOptions} = {}) {
        return new CPromise((resolve, reject, {signal}) => {
            fetch(url, {...fetchOptions, signal}).then(resolve, reject)
        }, {timeout, nativeController: true})
    }

    const promise = fetchWithTimeout('http://localhost/', {timeout: 5000})
        .then(response => response.json())
        .then(data => console.log(`Done: `, data), err => console.warn(`Error: `, err))

Так как async функции работают только через нативный Promise, то все цепочки внутри такой функции придется вручную подписывать на внешний AbortSignal. Это решает только проблему их согласованного закрытия.

    const controller = new CPromise.AbortController();

    async function asyncMethod(x) {
        await CPromise.delay(1000).listen(controller.signal);
        await CPromise.delay(1000).listen(controller.signal);
        await CPromise.delay(1000).listen(controller.signal);
        return x + 1;
    };

    const chain = asyncMethod(123).then(
        value => console.log(`Done: ${value}`),
        err => console.warn(`Failed: ${err}`)
    );

    setTimeout(() => controller.abort(), 2500);

Но, так как цепочка разорвана нативными промисами, то остальные функции не будут работать. В качестве обходного решения библиотека предлагает использовать генераторы, использование которых почти ничем не отличается и всем известна, благодаря библиотеке co. С ними весь функционал будет работать. Тот же самый код, но на генераторе и уже с захватом прогресса бонусом. Вес каждого промиса при подсчете прогресса можно задавать, но по умолчанию он равен единице. Метод then также поддерживает генераторы.

    function cancelableAsyncMethod(x) {
        return CPromise.from(function* (scope) {
            yield CPromise.delay(1000);
            yield CPromise.delay(1000);
            yield CPromise.delay(1000);
            return x + 1;
        }).innerWeight(3);
    }

    const chain = cancelableAsyncMethod(123)
        .progress(value => console.log(`Progress: ${(value * 100).toFixed(1)}%`))
        .then(
            value => console.log(`Done: ${value}`),
            err => console.warn(`Failed: ${err}`)
        );

    setTimeout(() => chain.cancel(), 2500);
    Progress: 33.3 %
    Progress: 66.7 %
    Failed: CanceledError: canceled

Генераторы поддерживают немного синтаксического сахара:

  • yield [promise1, promise2] — ресолвит промисы через Promise.all
          CPromise.resolve().then(function* () {
              return yield [CPromise.delay(1000, 123), CPromise.delay(1500, 456)]
          }).then(v => console.log(`Done: ${v}`)); // Done: 123,456
      
  • yield [[promise1, promise2]] — ресолвит промисы через Promise.race
          CPromise.resolve().then(function* () {
              return yield [[CPromise.delay(1000, 123), CPromise.delay(1500, 456)]]
          }).then(v => console.log(`Done: ${v}`)); // Done: 123
      

В контексте React, живая минимальная демка с прерываемым запросом JSON может выглядеть так
(Demo)

или так (Demo)

C помощью метода progress(value, data?) пользовательский код в промисе может сообщать свой прогресс с промежуточными значениями в диапазоне [0,1]. Опционально поддерживается тротлинг, одинаковые значения игнорируются. Дополнительный параметр может использоваться для передачи метаданных, что может быть полезным для создания экранов загрузки.

      import CPromise from "c-promise2";

      function loadResources() {
          return new CPromise((resolve, reject, scope) => {
              let counter = 0;
              const timer = setInterval(() => {
                  if (++counter === 5) {
                      clearInterval(timer);
                  }
                  scope.progress(counter / 5, {description: 'loading useful resources'});
              }, 100);
              scope.onCancel(() => clearInterval(timer));
          });
      }

      const chain = loadResources()
          .progress((value, scope, {description}) => {
              console.log(`Progress: ${(value * 100).toFixed(1)}% (${description})`)
          })
          .then(
              value => console.log(`Done: ${value}`),
              err => console.warn(`Failed: ${err}`)
          );
  
Progress: 20.0% (loading useful resources)
Progress: 40.0% (loading useful resources)
Progress: 60.0% (loading useful resources)
Progress: 80.0% (loading useful resources)
Progress: 100.0% (loading useful resources)

CPromise имеют встроенный механизм «сигналов». Сигналы «всплывают» начиная с самого глубокого выполняющегося промиса в цепочке, пока кто-то из них не обработает сигнал, вернув с обработчика true. Закрытие промисов это тоже один из предопределенных сигналов, соответственно если очень будет нужно, то реакцию на него пользовательский код может изменить.

      const chain = new CPromise((resolve, reject, scope) => {
          scope.on('signal', (type, data) => {
              if (type === 'inc') {
                  console.log(`Signal ${type} handled`);
                  resolve(data.x + 1);
                  return true;
              }
          });
      }).then(
          (value) => console.log(`Done: ${value}`),
          (err) => console.log(`Failed: ${err}`)
      )

      setTimeout(() => {
          console.log(`Inc signal result: ${chain.emitSignal('inc', {x: 2})}`);
      });
  
  Signal inc handled
  Inc signal result: true
  Done: 3

Если сигналы предназначены для передачи информации «вглубь», то метод propagate широковещательно передает ее по цепочке наружу. Через него работает механизм захвата прогресса, но можно использовать для любых других нужд.

      const chain = new CPromise((resolve, reject, scope) => {
          setTimeout(() => {
              scope.propagate('userMessage', 'hello');
              scope.propagate('userMessage', 'there!');
          }, 0)
      })

      chain.then(
          (value) => console.log(`Done: ${value}`),
          (err) => console.log(`Failed: ${err}`)
      ).on('propagate', (type, scope, message) => console.log(`Propagate type '${type}' message: '${message}'`))
  
  Propagate type 'userMessage' message: 'hello'
  Propagate type 'userMessage' message: 'there!'
CPromise.all / allSettled с лимитом «многопоточности» (Demo)
      CPromise.allSettled(
          ['http://localhost/1', 'http://localhost/2', 'http://localhost/3'],
          {
              mapper: (url) => fetch(url).then((response) => response.json()),
              concurrency: 2
          }
      ).then((results) => {
          console.log(`Done: `, JSON.stringify(results));
      });
  

И последний значимый API — pause/resume. Позволяет приостановить выполнение цепочки. Пользовательский код может опционально подписаться на соответствующие события, чтобы приостановить долгосрочный процесс (например, приостановить выгрузку файла) и тем самым избежать больших атомарных операций в контексте промисов.

      const promise = new CPromise(function (resolve, reject, {onCancel, onPause, onResume}) {
          onPause(() => console.log(`Pause`));
          onResume(() => console.log(`Resume`));
          onCancel(() => console.log(`Cancel`));
      }).catch(err => console.log(`Failed: ${err}`));

      setTimeout(() => promise.pause(), 1000);
      setTimeout(() => promise.resume(), 2000);
      setTimeout(() => promise.cancel(), 3000);
  

Пожалуй, вот и все плюшки этого зверька на данный момент :)

Update: v0.10.x: поддержка декораторов (legacy + current draft)
Простейший код компонента React:

В нем Вы не получите ошибку Warning: Can’t perform a React state update on an unmounted component. даже если unmount произойдет прямо по середине запроса.
В таком случае, CPromise в лице обертки cpFetch просто автоматически прервет запрос, хотя по сути никакого дополнительного кода мы не написали.

Демка React компонента с менеджментом асинхронного кода (вложенные запросы) через декораторы

При использовании функциональных компонентов (с помощью смежного проекта-обертки с хуками React use-async-effect2):

👍НравитсяПонравилось1
В избранноеВ избранном2
Подписаться на тему «JavaScript»
LinkedIn

Похожие статьи

Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Добавился маленький смежной проект- use-async-effect2 с все тем же смыслом- закрытие асинхронного кода, как минимум, при unmount, но для юзания в функциональных компонентах (демка).

import React from "react";
import {useState} from "react";
import {useAsyncEffect} from "use-async-effect2";
import cpFetch from "cp-fetch";

function JSONViewer(props) {
    const [text, setText] = useState("");

    useAsyncEffect(function* () {
            setText("fetching...");
            const response = yield cpFetch(props.url);
            const json = yield response.json();
            setText(`Success: ${JSON.stringify(json)}`);
    }, [props.url]);

    return <div>{text}</div>;
}

Первым делом посмотрел, завезен ли TypeScript, и не найдя ни сорцов на .ts, ни декларационных файлов, немного расстроился. :( Имхо, библиотеками такого характера страшно пользоваться, если они не были статически провалидированы. Хотел спросить, планируется переписать на .ts или хотя бы типизировать интерфейс декларационными файлами, чтобы можно было пользоваться в проектах на TypeScript?

На TS пока маловероятно, что будет переписан, т.к я небольшой фанат, да и смысла в этом особого нет, а вот приложить тайпинги как альтернативу инлайновым докам запланировано, но под стабильный релиз, чтобы не усложнять пока себе жизнь синхронизацией JSDoc и TS нотаций. Сейчас все силы уходят на код, чтобы запал не угас, но под стабильный релиз, конечно отрефакторить доки и организовать тайпинги надо будет.

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

Но, тем не менее, спасибо за контрибьюшен в экосистему. Как раз сам недавно открывал для себя `AbortController` и то, как во фронтендах решать проблему отмены IO и промисов.

Тема, конечно, холиворная

Угу, это классика :) Так было всегда- свитчеры с типизированных языков глубоко убеждены, что тру вей он один, и только так, как это решалось у них. Впрочем, это естественное желание сделать знакомую себе обстановку, зону комфорта и как то поменять курс инородной экосистемы в «приятное» для себя направление. Я в начале тоже требовал классы и destroy() :)

можно было бы сэкономить на рефакторингах, если писать на TypeScript.

Когда ты пишешь на JS 13 лет, то рефакторингов, которые может решить TS давно нет, глаз уже наметан до автоматизма.

когда это все может предотвратить компилятор. :)

Ну, не знаю, может Вы не в курсе, но современные IDE это не блокнот, где Вы вслепую пишете буковки на JS, относительно простые кейсы с типами, структурами, Г. кодом она легко показывает и просто так, а с нотациями jsdoc так вообще тоже самое, что и в TS. К тому же тайпскрипт это не только линтер с типами, нужно еще досконально знать как все структуры там транслируются в JS- черная коробка, которая коверкает твой код не по мне, при том что твои знания производительности и цене всех возможностей/выражений в JS приравнивается к 0, когда транслятор становится между тобой и JS. Да и синтаксические сахарки сейчас там те же, что и в стандартного JS.

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

Я свитчер из динамических языков в статические. Начинал с PHP/JS/Ruby, потом были C++ и Rust, сейчас меня снова занесло на проект с из JS мира. И, о боги, как я счастлив, что наш проект на TypeScript. :) Исходя из моего опыта здесь, где-то треть багов на фронтенде можно было избежать, если бы в меньшем количестве мест у кого-то возникало желание воткнуть тип `any`.

Ну, не знаю, может Вы не в курсе, но современные IDE это не блокнот

Ну все-таки далеко не все кейсы может покрыть IDE из тех, что я перечислил.

а с нотациями jsdoc так вообще тоже самое, что и в TS

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

К тому же тайпскрипт это не только линтер с типами, нужно еще досконально знать как все структуры там транслируются в JS- черная коробка, которая коверкает твой код

Очень жаль слышать такое заблуждение, особенно учитывая, что у вас 13 лет опыта в JS. TypeScript компилируется в идентичный JavaScript код, просто выкидывая информацию о типах. Нету никаких структур, которые транслируются в JS код каким-то иным образом. Единственное, что может делать TS — это добавлять поддержку нового синтаксического сахара для старых таргетов — не больше, что делают, например, babel трансформеры. Знания по производительности и выразительности JS применимы ровно таким же образом к TS.

Запилил поддержку декораторов. Теперь можно в теории писать такие штуки (извращаюсь как могу :) ):

import React from "react";
import {
  CPromise,
  async,
  listen,
  cancel,
  timeout,
  canceled,
  E_REASON_DISPOSED
} from "c-promise2";
import cpFetch from "cp-fetch";

export class TestComponent extends React.Component {
  state = {};

  @canceled(function (err) {
    console.warn(`Canceled: ${err}`);
    if (err.code !== E_REASON_DISPOSED) {
      this.setState({ text: err + "" });
    }
  })
  @listen
  @async
  *componentDidMount() {
    console.log("mounted");
    const json = yield this.fetchJSON(
      "https://run.mocky.io/v3/7b038025-fc5f-4564-90eb-4373f0721822?mocky-delay=2s"
    );
    this.setState({ text: JSON.stringify(json) });
  }

  @timeout(5000)
  @async
  *fetchJSON(url) {
    const response = yield cpFetch(url); // cancellable request
    return yield response.json();
  }

  render() {
    return (
      <div>
        AsyncComponent: <span>{this.state.text || "fetching..."}</span>
      </div>
    );
  }

  @cancel(E_REASON_DISPOSED)
  componentWillUnmount() {
    console.log("unmounted");
  }
}
Хотя это еще не все декораторы...

Спасибо, будем разбираться ...

Наконец-то что-то достойное на DOU

Подписаться на комментарии