Что такое UI в представлении реакта🔗
В рамках поста The Two Reacts Дан Абрамов формулирует образ мышления, стоящий за React, как что-то двойственное: с одной стороны UI - это производная стейта приложения, с другой - это производная данных, полученных с сервера. Это действительно звучит просто и элегантно особенно после однозначного перехода с компонентов-классов на компоненты-функции, ведь термин “функция” буквально означает “производная”.
В бложике я использую реактовский JSX, чтобы делить элементы интерфейса на переиспользуемые компоненты. Чтобы минимизировать количество клиентского JS, сервер рендерит весь этот React в HTML строку и возвращет только ее. Хендлер запроса по факту состоит из сбора необходимых данных и передачи их дереву higher-order JSX функций. В такой системе легко понять что, где и когда происходит: поиск нужной части кода всегда начинается сверху и идет строго вниз по дереву без необходимости прыгать по соседним веткам вправо или влево, единственное по-настоящему необходимое для работы понятие - это “функция”/“производная”, больше ничего.
Недавно же я начал работать над небольшим пет-проектом и выбрал уже полноценный клиентский React в качестве основы, потому что тут есть необходимость в куда более интерактивном интерфейсе, чем в случае с бложиком. Практически сразу я наткнулся на то, парадигмы “UI - это производная стейта / данных с сервера” недостаточно, реальный код очень быстро перестает в нее помещаться.
Первый пример: работает правильно, но есть излишняя нагрузка🔗
export const Counter: FC = () => {
const [counter, setCounter] = useState(0);
const logCounter = () => {
console.log(`Count is: ${counter}`);
};
const increaseCounter = () => {
setCounter(counter + 1);
};
return (
<div className="flex flex-col gap-2 items-start">
<p>{counter}</p>
<p>{doubleCounter}</p>
<button onClick={increaseCounter}>Increase Counter</button>
<button onClick={logCounter}>Log Counter</button>
</div>
);
};
Возьмем за пример компонент из скрина. Компонент имеет в себе стейт, чтобы хранить текущее значение счетчика, див, чтобы показать его, и пару кнопок, чтобы как-то работать с этим всем. Все максимально прямолинейно, но любой JS разработчик сразу заметит тут проблемы с производительностью. В нынешней имплементации каждый цикл рендера компонента, т.е. вызов функции Counter, создает новые инстансы функций logCounter и increaseCounter. Код этих функций не меняется от рендера к рендеру, но инстансы из предыдущих рендеров отправляются в garbage collector, что нагружает браузер. Допустим у нас одновременно 100 таких компонентов отображено на экране, это значит что помимо 100 инстансов самого Counter мы храним в памяти 100 инстансов logCounter и 100 increaseCounter итого 300 переменных. Каждый ренендер - это уже чистка 300 переменных и создание 300 новых переменных (если, конечно, все 100 компонентов были изменены между рендерами). Авторы React прекрасно понимают эту проблему, поэтому они предоставляют решение в качестве встроенного хука useCallback, который сохраняет инстанс функции между рендерами.
Второй пример: работает неправильно, но нагрузка минимальная🔗
export const Counter: FC = () => {
const [counter, setCounter] = useState(0);
const logCounter = useCallback(() => {
console.log(`Count is: ${counter}`);
}, []);
const increaseCounter = useCallback(() => {
setCounter(counter + 1);
}, []);
return (
<div className="flex flex-col gap-2 items-start">
<p>{counter}</p>
<p>{doubleCounter}</p>
<button onClick={increaseCounter}>Increase Counter</button>
<button onClick={logCounter}>Log Counter</button>
</div>
);
};
Выглядит это примерно так. Теперь уже любой разработчик, знакомый с React, будет бить тревогу: пустые массивы зависимостей useCallback (из-за которых инстансы logCounter и increaseCounter создаются лишь однажды)! Суть в том, что реакт хуки иммутабельны, что означает, что при каждом рендере мы получаем не только новый инстанс наших функций (без использования useCallback), но и стейт (counter и setCounter) тоже “создается” заново. Само значение стейта конечно сохраняется между рендерами, но указатели на место в памяти уже новые. Из-за замыкания без новых инстансов функций используются старые инстанты, которые используют старое значение. Для большей наглядности, если мы запустим этот компонент, то получим следующий результат:
- изначально
counterотображается как 0 - при нажатии на “Increase Counter”
counterувеличивается на 1, но последующие нажатия на кнопку эффекта не имеют, потому чтоincreaseCounterиспользует указатель на старое значение 0 и получает 0+1=1 - при нажатии на “Log Counter” в консоль выводится только 0 вне зависимости от того, что отображается на экране (0 или 1), потому что
logCounterтоже использует старое значение 0
Чтобы одновременно избежать создания лишних инстансов функций, но при этом иметь их актуальные инстансы, надо указать реакту, что при изменении counter, тот должен пересоздать и функции, то есть надо указать массив зависимостей в useCallback.
Третий пример: работает правильно, а нагрузка…🔗
export const Counter: FC = () => {
const [counter, setCounter] = useState(0);
const logCounter = useCallback(() => {
console.log(`Count is: ${counter}`);
}, [counter]);
const increaseCounter = useCallback(() => {
setCounter(counter + 1);
}, [counter, setCounter]);
return (
<div className="flex flex-col gap-2 items-start">
<p>{counter}</p>
<p>{doubleCounter}</p>
<button onClick={increaseCounter}>Increase Counter</button>
<button onClick={logCounter}>Log Counter</button>
</div>
);
};
Вот мы и дошли до качественно написанного реактовского компонента, но если оглянуться назад, то можно заметить, что что-то теперь не так. Почему мы должны пересоздавать функции (или же другими словами поведение) компонента при изменении его стейта?!
”А ручки-то вот они!” ©️🔗
В JS наследование реализовано через прототипирование. Допустим, мы создали класс Person с полями firstName и lastName и методом getFullName. Каждый инстанс этого класса имеет свои собственные firstName и lastName. Однако в то же время все они ссылаются на один единственный getFullName, который живет на прототипе нашего класса, а не на конкретном инстансе. Как же этот отдельно стоящий getFullName знает, что он вызван в контексте инстанса person1, а не person2? При помощи this.! Но React отказался от компонентов в виде классов давным давно, поэтому у нас нет this. в компонентах-функциях, поэтому авторам реакта пришлось находить решение в массиве зависимостей хука useCallback. И мне это кое-что напоминает.
Допустим, у нас есть како-то другой стейт, который зависит от counter. Назовем его doubleCounter, он по определению должен обновляться каждый раз, когда обновляется counter и React отлично с этим справляется посредством useMemo хука. А теперь сравните схематически использование useCallback и useMemo. Они одинаковы с точки зрения компонента! increaseCounter и logCounter по сути отвечают за описание поведения компонента, но он к ним относится будто они очередной его стейт (как doubleCounter), который надо обновлять исходя из зависимостей, и который надо отдельно хранить в каждом инстансе компонента!
export const Counter: FC = () => {
const [counter, setCounter] = useState(0);
const doubleCounter = useMemo(() => counter * 2, [counter]);
const logCounter = useCallback(() => {
console.log(`Count is: ${counter}`);
}, [counter]);
const increaseCounter = useCallback(() => {
setCounter(counter + 1);
}, [counter, setCounter]);
return (
<div className="flex flex-col gap-2 items-start">
<p>{counter}</p>
<p>{doubleCounter}</p>
<button onClick={increaseCounter}>Increase Counter</button>
<button onClick={logCounter}>Log Counter</button>
</div>
);
};
Я не знаю, какие проблемы возникали у авторов реакта, из-за которых они решили полностью отказаться от компонентов-классов. С удовольствием послушаю про это, если тут есть кто-то сведующий. Но это не меняет нашей реальности, в которой компонент - это не только производная от стейта или данных, но еще и поведение, связанное с ним. Отрицание этого будь-то осознанное или нет приводит к тому, что на бумаге терминов вроде как меньше (нет ни директив, ни сервисов, ни модулей как в том же Angular), но на практике в этот ограниченный набор категорий приходится засовывать ровно тоже количество понятий. Less is not always more
Upd. по теме:
Оказывается, между первым и последним примерами разницы в продуктивности нет (по крайней мере в плане garbage collection)…
Суть в том как технически useCallback (а так же useState) работает:
- на первом рендере, прогоне компонента-функции, он берет и использует переданное ему начальное значение
- на последующих рендерах начальное значение по-прежнему создается в памяти, так как компонент - это просто JS функция, но хук игнорирует его и возвращает ссылку на то самое изначальное значение
Когда, да, есть смысл использовать useCallback? Когда его надо передать в дочерний компонент или в какой-нибудь кастомный хук. Если дочерний компонент обернут в memo или функция упомянута в зависимостях кастомного хука, то дочерний компонент и/или кастомный хук не будут прогоняться почем зря. В родительском же компоненте разницы ноль.
То есть как ни крути все равно лишние десятки и сотни инстансов функций будут создаваться и тут же чистится через garbage collection. Я вообще удивлен, как подобная парадигма может быть продуктивной, но, да, стоит по-внимательнее читать доки😕
