Привет, друзья!
Представляю вашему вниманию перевод этой небольшой заметки, посвященной предложению нового хука 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)
aelaa
04.11.2022 12:29+2https://ru.wikipedia.org/wiki/Объект_первого_класса
"Первоклассный" в русском языке - показатель высокого качества. А тут про категорию объектов языка.
DmitryKoterov
05.11.2022 07:58Мне кажется, все это уже начало обрастать бородой и приближаться к вымиранию, как динозавры, да и Дэн Абрамов не молодеет, увы. Хуки были когда-то гигантским прорывом по сравнению с класс-компонентами, так же, как теория Ньютона была прорывом по сравнению с тем, что было до ней. Но потом пришла теория относительности и сделала ньютоновскую механику архаичным частным случаем. То же должно произойти и с хуками - ну уж слишком там много волос торчит отовсюду.
Причем что там «за хуками», непонятно. Еще не изобрели. Но изобретут обязательно.
Еще мысль: явно будущее все еще за промисами и AsyncIterables. Точно не за Observables. Массовое сознание никогда не грокнет Observables, слишком большой порог на вход. Они уже практически убили Angular (его много что убило, но и это тоже).
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> ); } }
mclander
С одной стороны классная идея. Код получается очень компактный. С другой пугает громкое название. Сразу вспоминается патентованный болеутолитель из Тома Сойера.
Ну и обработка ошибок не раскрыта.