Привет, друзья!


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


Первоклассная поддержка промисов в React — как это должно работать


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


❯ RFC: первоклассная поддержка промисов


Новая возможность посвящена "первоклассной" (first-class) поддержке промисов в React. Она описывается в RFC (Request for Comments — запрос/предложение на обсуждение) от одного из членов команды React: 0000-first-class-support-for-promises.


Документ носит название "Первоклассная поддержка промисов, async/await" и описывает новые возможности по улучшению интеграции кода, основанного на промисах, с компонентами React.


RFC не обязательно означает, что возможность будет реализована, поскольку любой желающий может написать RFC и открыть запрос на слияние (pull request) в репозитории React. Однако поскольку автором данного RFC является один из членов команды React, логично предположить, что он будет принят в той или иной форме и фича будет реализована в одном из будущих релизов React.


❯ Какие проблемы решает фича?


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


const WidgetList = () => {
  const [widgets, getWidgets] = React.useState([])

  React.useEffect(() => {
    widgetsAPI.get().then((r) => {
      setWidgets(r)
    })
  }, [])

  return (
    <div>
      {widgets.map((w) => (<p id={w.id}>{w.name}</p>))}
    </div>
  )
}

В приведенном примере widgetsAPI возвращает промис. После получения результатов мы обновляем состояние компонента в коллбэке then. Это очень распространенный паттерн, который хорошо себя зарекомендовал, но он несколько многословен и может усложнять код больших компонентов.


Другим подходом является использование ключевого слова await для ожидания результатов разрешения промиса:


const widgets = await widgetsAPI.get()

Серверные компоненты (server rendered components) React будут поддерживать асинхронных рендеринг, что позволит использовать await в точности, как в приведенном примере.


Но что насчет клиента? В настоящее время компоненты React не могут быть асинхронными. А, как известно, await можно использовать только в асинхронных функциях (прим. пер.: и на верхнем уровне модуля). RFC описывает это ограничение и предполагает будущую поддержку асинхронных клиентских компонентов:


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


Как же нам получить первоклассную поддержку промисов на клиенте без асинхронных клиентских компонентов?


В качестве решения предлагается новый хук с весьма спорным названием.


❯ Решение: новый хук use()


Решением проблемы обработки асинхронных операций на клиенте является новый хук под названием use(). Его функционал схож с функционалом await с некоторыми важными отличиями:


const WidgetList = () => {
  const widgets = use(widgetsAPI.get())

  return (
    <div>
      {widgets.map((w) => (<p id={w.id}>{w.name}</p>))}
    </div>
  )
}

Подобно await use() разворачивает (unwrap) значение промиса, возвращаемого widgetsAPI. В отличие от await выполнение кода компонента не приостанавливается до разрешения промиса в момент вызова use(). В случае отклонения промиса с ошибкой use() выбрасывает исключение, прерывающее рендеринг. В этом use() похож на React.Suspense. После успешного разрешения промиса компонент подвергается повторному рендерингу:


При успешном разрешении промиса компонент подвергается повторному рендерингу. Во время ререндеринга use() возвращает разрешенное значение промиса.


Конечный результат обоих подходов является одинаковым. await — это синтаксический сахар для promise.then(callback). Но в первом случае выполнения кода после разрешения промиса продолжается с места использования await. Во-втором — часть кода компонента выполняется повторно и use() возвращает результат.


RFC указывает, что компоненты React должны быть идемпотентными — повторный рендеринг с одними и теми же пропами, состоянием и контекстом должен приводить к одинаковому результату:


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


Однако на практике возможны ситуации, когда побочные эффекты, запускаемые в компоненте, будут выполняться дважды. В качестве простого примера представим, что в widgetsAPI используется console.log() для логгирования какой-либо информации. В случае с await данный console.log() будет вызван только один раз, а в случае с use() — два раза.


Существует еще одна проблема: повторное выполнение use() означает необходимость кэширования результатов вызова API в той или иной степени:


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


Если API не поддерживает кэширование (или оно реализовано неправильно) и возвращает другой промис, который не разрешается внутри микрозадачи (например, отправляет новый запрос к API), React снова приостановит рендеринг компонента — это может привести к бесконечному циклу выполнения запросов и повторных рендерингов. Да, это будет ошибкой разработчика, но такую ошибку будет очень легко совершить.


Что интересно, в отличие от других хуков, при использовании use() можно не соблюдать правила использования хуков. Это означает, что use() можно вызывать условно, в циклах и т.д. Это в определенной степени связано с кэшированием: второй рендеринг может вызывать use() с "новым" промисом, который имеет доступ к тем же данным и должен возвращать кэшированный результат. У нас нет необходимости следить за порядком вызова use(), поэтому правилами можно пренебречь.


❯ Какие сложности могут возникнуть в процессе реализации и применения use()?


Несмотря на то, что, в целом, я за первоклассную поддержку промисов в React, у меня есть несколько вопросов, многие из которых разделяются другими разработчиками. Я не претендую на то, что у меня есть правильные ответы на все эти вопросы, но мне бы хотелось их обсудить.


Название


Безусловно, придумать хорошее имя для ПО — задача не из простых, но use — слишком общее название, которое не говорит ни о том, что функция делает, ни о том, как она связана с промисами.


В RFC отмечается, то use() в дальнейшем будет использоваться также для "разворачивания" потребляемых из контекста и других типов данных. Также отмечается, что название use сигнализирует о том, что речь идет о специфичной для React функции. Согласитесь, данные замечания выглядят не очень убедительно.


Разные правила для use() и других хуков


Как отмечалось ранее, в случае с use() можно не соблюдать правила использования хуков. Это хорошо, но сбивает с толку.


Это хорошо, поскольку снимает ограничения на возможности использования use(), присущие другим хукам.


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


Мне кажется, что разные правила для use() и других хуков усложнят понимание новыми разработчиками не только правил использования хуков, но и самой их сути.


Новые ограничения для кода, вызываемого use()


Несмотря на то, что use() избавлен от ограничений других хуков, он вводит новые ограничения на используемый (used) код. Речь идет о необходимости кэширования и отсутствия побочных эффектов, производимых кодом промиса, передаваемого в use().


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


Кэширование могло бы обрабатываться автоматически при изменении API и массива зависимостей по аналогии с тем, как это работает в хуке useEffect():


use(() => myAPI.fetch(id), [id])

Обратите внимание: реализация такого подхода, скорее всего, приведет к необходимости отслеживания порядка вызова use(), т.е. к необходимости соблюдения правил использования хуков.


Разное поведение клиентских/серверных компонентов


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


Аргумент, приводимый в RFC, лично мне кажется не очень убедительным:


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


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


Не думаю, что ключевое слово async сильно поможет человеку, который не понимает, в какой среде он работает.


Для разделения окружений можно было бы использовать более явные средства, такие как разные пространства имен для модулей или API клиента и сервера, предоставление обертки React.serverComponent() и т.п. Приведенное выше объяснение звучит как попытка оправдания интерфейса задним числом.


❯ Промисы, промисы...


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


Я считаю, что лучшим решением было бы представление полноценных асинхронных клиентских компонентов. В конце концов, async/await — это стандартный способ работы с асинхронным кодом в JS.


Что вы думаете по этому поводу? Делитесь своим мнением в комментариях.


Благодарю за внимание и happy coding!




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


  1. mclander
    04.11.2022 11:48
    +1

    С одной стороны классная идея. Код получается очень компактный. С другой пугает громкое название. Сразу вспоминается патентованный болеутолитель из Тома Сойера.

    Ну и обработка ошибок не раскрыта.


  1. aelaa
    04.11.2022 12:29
    +2

    https://ru.wikipedia.org/wiki/Объект_первого_класса

    "Первоклассный" в русском языке - показатель высокого качества. А тут про категорию объектов языка.


  1. DmitryKoterov
    05.11.2022 07:58

    Мне кажется, все это уже начало обрастать бородой и приближаться к вымиранию, как динозавры, да и Дэн Абрамов не молодеет, увы. Хуки были когда-то гигантским прорывом по сравнению с класс-компонентами, так же, как теория Ньютона была прорывом по сравнению с тем, что было до ней. Но потом пришла теория относительности и сделала ньютоновскую механику архаичным частным случаем. То же должно произойти и с хуками - ну уж слишком там много волос торчит отовсюду.

    Причем что там «за хуками», непонятно. Еще не изобрели. Но изобретут обязательно.

    Еще мысль: явно будущее все еще за промисами и AsyncIterables. Точно не за Observables. Массовое сознание никогда не грокнет Observables, слишком большой порог на вход. Они уже практически убили Angular (его много что убило, но и это тоже).


    1. djEban
      06.11.2022 18:54

      На чем основана аналитика?


  1. nin-jin
    05.11.2022 19:24
    -1

    Отличная новость, фича из $mol от 2016 года наконец добирается и до React. Правда в довольно кривом виде, прибитом гвоздями к шаблонам, и с кучей ограничений. Почему бы уже не взять $mol_wire и перестать менять апи каждый год? Вот пример модели и вьюшки оттуда:

    export class GitHub extends Object {
    	
    	@mems static issue( value: number, reload?: "reload" ) {
    		
    		sleep(500) // for debounce
    		
    		return getJSON( `https://api.github.com/repos/nin-jin/HabHub/issues/${value}` ) as {
    			title: string
    			html_url: string
    		}
    		
    	}
      
    }
    export class Counter extends Component<Counter> {
      @mem numb(value = 48) {
        return value;
      }
    
      issue(reload?: "reload") {
        return GitHub.issue(this.numb(), reload);
      }
    
      title() {
        return this.issue().title;
      }
    
      link() {
        return this.issue().html_url;
      }
    
      compose() {
        return (
          <div id={this.id} className="counter">
      
            <InputNumber
              id={`${this.id}-numb`}
              numb={(next) => this.numb(next)} // hack to lift state up
            />
    
            <Safe
              id={`${this.id}-title-safe`}
              task={() => (
                <a
                  id={`${this.id}-title`}
                  className="counter-title"
                  href={this.link()}
                >
                  {this.title()}
                </a>
              )}
            />
    
            <Button
              id={`${this.id}-reload`}
              action={() => this.issue("reload")}
              title={() => "Reload"}
            />
    
          </div>
        );
      }
    }