react-afc - библиотека для более простого (чем в простом react) уменьшения количества ненужных ререндеров дочерних компонентов.
Задачи и применение
В обычном react функциональный компонент вызывается каждый раз когда изменяется его состояние или пропсы, что вызывает повторное создание всех callback'ов и переменных.
Так как передаваемые данные из предыдущего и текущего рендера не равны, это порождает ререндер дочерних компонентов.
пример
Функционал компонента не несёт конкретного смысла. Просто пример.
import { useState } from 'react'
import Title from 'title-lib'
import HardCalcHeader from './header'
import NameInput from './name-input'
import AgeInput from './age-input'
function App() {
const [name, setName] = useState('')
const [age, setAge] = useState(1)
const onChangeName = value => setName(value)
const onChangeAge = value => setAge(value)
const closeWindow = () => window.close()
const titleArgs = {
color: 'blue',
size: 20
}
return (
<>
<Title text='Amazing app' args={titleArgs} />
<HardCalcHeader onExit={closeWindow} />
<NameInput value={name} onChange={onChangeName} />
<AgeInput value={age} onChange={onChangeAge} />
</>
)
}
При изменении имени происходит перерисовка Title, NameInput, AgeInput, а также HardCalcHeader, что приводит к зависанию приложения.
Для избежания этого поведения мы разбиваем логику на множество компонентов (не всегда удобно), либо используем useCallback
и useMemo
(стоит дополнительных затрат при многократном вызове, ухудшает читаемость кода и требует отслеживания зависимостей функции).
пример с использованием хуков
import { useState, useCallback, useMemo } from 'react'
import Title from 'title-lib'
import HardCalcHeader from './header'
import NameInput from './name-input'
import AgeInput from './age-input'
function App() {
const [name, setName] = useState('')
const [age, setAge] = useState(1)
const onChangeName = useCallback(value => setName(value), [])
const onChangeAge = useCallback(value => setAge(value), [])
const closeWindow = useCallback(() => window.close(), [])
const titleArgs = useMemo(() => ({
color: 'blue',
size: 20
}), [])
return (
<>
<Title text='Amazing app' args={titleArgs} />
<HardCalcHeader onExit={closeWindow} />
<NameInput value={name} onChange={onChangeName} />
<AgeInput value={age} onChange={onChangeAge} />
</>
)
}
Вся суть работы библиотеки лежит в одном, но значительном изменении структуры функционального компонента - добавлении аналога конструктора классового компонента.
сравнение
import { afc } from 'react-afc'
// обычный компонент
function CommonComponent(props) {
// вызывается каждый рендер
// ...react-хуки
return <p>обычный компонент</p>
}
// afc компонент
const AdvancedComponent = afc(props => {
// вызывается один раз за весь жизненный цикл компонента
// afc-хуки
return () => {
// render-функция, вызывается каждый рендер
// ...react-хуки (только по необходимости)
return <p>afc</p>
}
}
Данное изменение позволяет нам во многих случаях не использовать useCallback
и useMemo
, а также не думать о зависимостях.
пример с использованием библиотеки
import { afc, useState } from 'react-afc'
import Title from 'title-lib'
import HardCalcHeader from './header'
import NameInput from './name-input'
import AgeInput from './age-input'
const App = afc(() => {
const [getName, setName] = useState('')
const [getAge, setAge] = useState(1)
const onChangeName = value => setName(value)
const onChangeAge = value => setAge(value)
const closeWindow = () => window.close()
const titleArgs = {
color: 'blue',
size: 20
}
return () => (
<>
<Title text='Amazing app' args={titleArgs} />
<HardCalcHeader onExit={closeWindow} />
<NameInput value={getName()} onChange={onChangeName} />
<AgeInput value={getAge()} onChange={onChangeAge} />
</>
)
})
Работает аналогично примеру с хуками, никаких лишних перерисовок.
Побочным эффектом является то, что мы больше не нуждаемся в использовании useRef
для передачи данных между рендерами.
пример
import { useRef } from 'react'
import { afc } from 'react-afc'
// обычный компонент
function CommonComponent() {
const renderCount = useRef(0)
renderCount.current++
return (
<p>
Рендер вызван {renderCount.current} раз
</p>
)
}
// afc компонент
const AdvancedComponent = afc(() => {
let renderCount = 0
return () => {
renderCount++
return (
<p>
Рендер вызван {renderCount} раз
</p>
)
}
})
Примечание: в библиотеке имеются аналоги для useState
, useRef
, useMemo
, useEffect
, memo
. Их применение узкоспециализировано, читайте доку.
Пример работы можете найти на codesandbox.
Принцип работы
При первом рендере библиотека вызывает переданную в afc
функцию, определяет какие хуки используются и сохраняет возвращённую рендер-функцию.
При последующих рендерах обновляются свойства в объекте пропсов (если они изменились), выполняются определённые ранее react-хуки и вызывается рендер-функция.
Принцип прост как пробка и требует незначительных вычислений только при первом рендере.
Комментарии (27)
19Zb84
01.01.2024 12:30-2Какой смысл бороться с тем, что в основе библиотеки лежит ? На мой взгляд это очередной костыль.
Сделать хлеборезку, а потом апгрейживать ее до утюга.
Ну так тоже можно.
VerZsuT Автор
01.01.2024 12:30+1Если сами разработчики react предоставили инструменты для того чтобы бороться с ререндерами, то почему бы не упростить их использование?
Если взять UI либу (Antd к примеру, а уж теб более MIU), то ой как встанет вопрос о сокращении лишних отрисовок, ибо даже 20-30 дополнительных отрисовок сложных компонентов будут фризить всё приложение.19Zb84
01.01.2024 12:30+1Где много отрисовок, лучше реакт вообще не использовать. Если бы разработчики предоставили метод, они бы его как контекст в библиотеку добавили.
А так, от этой проблемы только костыли. Ее невозможно в реакте решить. По крайней мере сейчас.
Dartess
01.01.2024 12:30+2Звучит всё неплохо.
Из того что бросилось в глаза - изменение сигнатуры useState вместе с сохранением нейминга. Я думаю это будет путать при чтении, все привыкли что useState это value+setter, а не getter+setter. Можно сказать - ну так это же другой useState, но при чтении на месте использования не очевидно, откуда был импорт.
Также на это имя могут быть завязаны фишки IDE (например парное переименование в jetbrains) или например какие-то правила линтеров.
Я бы посоветовал посмотреть в сторону имени useSignal, сигналы как раз дают ту же сигнатуру, getter+setter.
VerZsuT Автор
01.01.2024 12:30Хорошее замечание. Обязательно подумаю над переименованием
useState
вuseSignal
nin-jin
01.01.2024 12:30+3// afc компонент const AdvancedComponent = afc(() => { let renderCount = 0 return () => { renderCount++ return ( <p> Рендер вызван {renderCount} раз </p> ) } })
// Afc компонент class AdvancedComponent extends Afc { renderCount = 0 render() { this.renderCount++ return ( <p> Рендер вызван {this.renderCount} раз </p> ) } })
Oh, wait..
VerZsuT Автор
01.01.2024 12:30Да, в этом и смысл. Сохранить в функциональном компоненте его гибкость и при этом добавить немного возможностей классов.
nin-jin
01.01.2024 12:30Это буквально классы и есть, только с замороченным объявлением полей через хуки. Никакой особой гибкости они не добавляют.
VerZsuT Автор
01.01.2024 12:30Гибкость в более простом создании переиспользуемой логики.
Для классов есть только путь HOC или заморочкой с наследованием/передачей this, что в больших объёмах понижает производительность, да и читается хуже чем обычный хук в функциональщине.
Лично мне нравится и классовый и функциональный подход, но во втором вечно была проблема с ререндерами, которую библиотека пытается минимизировать.nin-jin
01.01.2024 12:30const App = afc(() => { const [getName, setName] = useState('') const [getAge, setAge] = useState(1) const onChangeName = value => setName(value) const onChangeAge = value => setAge(value) const closeWindow = () => window.close() const titleArgs = { color: 'blue', size: 20 } return () => ( <> <Title text='Amazing app' args={titleArgs} /> <HardCalcHeader onExit={closeWindow} /> <NameInput value={getName()} onChange={onChangeName} /> <AgeInput value={getAge()} onChange={onChangeAge} /> </> ) })
export class App extends Afc { @mem name = '' @mem age = 1 @act onChangeName = value => { this.name = value } @act onChangeAge = value => { this.age = value } @act closeWindow = ()=> { window.close() } titleArgs = { color: 'blue', size: 20 } render () { return <> <Title text='Amazing app' args={this.titleArgs} /> <HardCalcHeader onExit={this.closeWindow} /> <NameInput value={getName()} onChange={this.onChangeName} /> <AgeInput value={getAge()} onChange={this.onChangeAge} /> </> } }
Ой, как useState прекрасно читается-то. И с this такие проблемы, ух. А уж переиспользование классов - это вообще что-то из области фантастики, да.
VerZsuT Автор
01.01.2024 12:30Как без HOC добавить классу общий функционал (применяемый в нескольких компонентах), использующий состояние или метод жизненного цикла?
Ещё и полностью типизированный ;)nin-jin
01.01.2024 12:30Ну вот у вас без HOF и не получилось это сделать. Вы покажите хоть один пример, где эмуляция классов через функции была бы проще/гибче собственно классов.
strannik_k
01.01.2024 12:30+2Для добавления объекту новой функциональности есть 3 подхода - смешивание с другим объектом, обертка другим объектом, замена объектов/функций в полях-ссылках объекта на другие объекты/функции.
Вариации 3-го подхода:
Можно вынести метод в функцию вне класса и вызывать ее в соответствующем методе жизненного цикла в нужных компонентах.Можно завести в компонентах массив ссылок на подобные функции, тем самым еще и позволяя менять функциональность компонента, чего нет в подходе с хуками.
Можно пойти дальше и сделать ссылки не на функции, а на объекты, в которых сделать методы жизненного цикла, аналогичные компонентам. Далее в каждом методе жизненного цикла сделать перебор объектов с вызовом в них соответствующего метода жизненного цикла. Еще понадобиться предоставить этим объектам доступ к компоненту с его состоянием. Тем самым получим механизм, где обработка логики компонентов реакта будет делегирована вложенным переиспользуемым и заменяемым объектам. Плюс верстка будет отделена от логики.
Реализацию такого подхода я описывал в https://habr.com/ru/articles/545064
Rushelex
01.01.2024 12:30+2Мне кажется, когда встаёт вопрос про уменьшение ререндеров путём кардинального обратно-не-совместимого изменения API компонентов, проще совсем уйти от React в сторону библиотек, которые из коробки работают с ререндерами хорошо, например, Preact, Solid или Svelte. Потому что если ваше приложение написано на react-afc и вы используете другие библиотеки для React, то количество ререндеров библиотечных компонентов всё равно останется таким же и это будет бутылочным горлышком, пока создатели библиотек не перепишут их на этот же react-afc или что-то аналогичное.
VerZsuT Автор
01.01.2024 12:30Библиотечные компоненты как правило оптимизированы и сами по себе не вызывают проблем (если их правильно использовать).
Есть совместимое API у библиотеки, можно писать хуки для обычных и для afc компонентов одновременно, при этом само написание остаётся идентичным в 90% случаев.
С точки зрения использования компонента разницы нет, те же пропсы, callback'и и всё остальное. Вся работа идёт внутри, а снаружи то же самое API компонента (afc возвращает React.FC).
donatello2005
01.01.2024 12:30В статье не хватает сравнения с
memo()
. Как понимаю,afc
выполняет схожую работу, но после прочтения я, если честно, не понял существенных различий между ними. Возможно,afc
глубже работает с хуками?VerZsuT Автор
01.01.2024 12:30memo
работает с пропсами компонента, то есть позволяет не ререндерить сам компонент если входные данные идентичны.afc
же добавляет инициализатор компонента, позволяя при изменении состояния не ререндерить дочерние компоненты который в этом не нуждаются.
Также естьafcMemo
, который добавляет и то и другое (afc
оборачивается вmemo
)
DarthVictor
01.01.2024 12:30Я правильно понимаю, что в отличие @preact/signals-react, afc не требует интеграции в сборку?
VerZsuT Автор
01.01.2024 12:30Вся библиотека react-afc это лишь обёртка над обычным react.
Даже сама функцияafc
лишь создаёт функциональный компонент с некоторым функционалом, который в качестве представления возвращает результат render-функции.
То есть да, никакая интеграция не требуется.
Themezv
01.01.2024 12:30Забудьте об этом
https://react.dev/blog/2022/06/15/react-labs-what-we-have-been-working-on-june-2022#react-compiler
VerZsuT Автор
01.01.2024 12:30Прошло почти два года и пока не стандарт.
Надеюсь они делают что-то большее чем просто автодобавление даже аналоговuseMemo
иuseCallback
. Одно дело когда функция статична и никогда не вызовет ререндер, даже если в ней используется состояние. А другое когда функция на каждое изменение будет всё равно создаваться заново (использованиеuseCallback
с зависимостью в виде состояния).
VerZsuT Автор
01.01.2024 12:30Почитал отчёт о нём за прошлый год. Рад что они занимаются этой проблемой. И да, это большее чем я написал.
Правда ещё не ясно можно ли его будет использовать на разных версиях react
markelov69
Лучше просто взять MobX и будут сразу все оптимизации из коробки и отличный стейт менеджмент, как глобальный так и локальный.
VerZsuT Автор
Можно. Эта библиотека не заменяет (и даже не пытается делать этого) такие инструменты как MobX. Лишь выступает как небольшое добавление функционала к оригинальному react.
DarthVictor
MobX не будет делать частичного обновления компонента, пока вы его сами не разобьёте на несколько, а тут как я понимаю, пытаются именно в частичное обновление. Это корректнее вроде бы с @preact/signals-react сравнивать. Но я пока не уверен.
VerZsuT Автор
@preact/signals
это про управление состоянием.Тут же единственная фича - инициализатор (или же "конструктор", как угодно), плюс функции, позволяющие в нём использовать аналогичный react-хукам функционал.
Уменьшение ререндеров следует из этой фичи. Все колбеки и переменные из инициализатора не вызывают ререндер, так как ссылка на них не меняется в течение всего жизненного цикла.
В обычном функциональном компоненте ссылка на любую функцию будет новая каждый рендер. Также все переменные будут созданы заново. При передаче в компонент они вызовут его ререндер (даже компонента в
memo
, исключение - строки и числа, они сравниваются по значению).Именно по этой причине нет смысла оборачивать в
memo
компонент, принимающийchildren
,так как это массив и он имеет разную ссылку при каждом рендере.Это довольно назойливая проблема реакта, которая в один момент может заставить фризить всё приложение и придётся использовать
useMemo / useCallback / useRef
.