Чем функциональные компоненты React отличаются от компонентов, основанных на классах? Уже довольно давно традиционный ответ на этот вопрос звучит так: «Применение классов позволяет пользоваться большим количеством возможностей компонентов, например — состоянием». Теперь, с появлением хуков, этот ответ больше не отражает истинное положение дел.
Возможно, вы слышали о том, что один из этих видов компонентов отличается лучшей производительностью, чем другой. Но какой именно? Большинство бенчмарков, которыми это проверяют, имеют недостатки, поэтому я делал бы выводы, основываясь на их результатах, с большой осторожностью. Производительность, в основном, зависит от того, что происходит в коде, а не от того, выбраны ли для реализации неких возможностей функциональные компоненты или компоненты, основанные на классах. Наше исследование показало, что разница в производительности между разными видами компонентов незначительна. Однако надо отметить, что применяемые при работе с ними стратегии оптимизации немного различаются.
Я, в любом случае, не рекомендую переписывать существующие компоненты с применением новых технологий если на то нет веских причин, и если вы не против оказаться в числе тех, кто раньше всех начал этими технологиями пользоваться. Хуки — это всё ещё новая технология (такая же, какой была библиотека React в 2014 году), и в руководства по React ещё не попали некоторые «передовые методики» их применения.
К чему же мы, в итоге, пришли? Есть ли вообще какие-то фундаментальные различия между функциональными компонентами React, и компонентами, основанными на классах? Конечно, такие различия есть. Это — различия в ментальной модели использования таких компонентов. В этом материале я рассмотрю самое серьёзное их различие. Оно существует с тех пор, как, в 2015 году, появились функциональные компоненты, но его часто обходят вниманием. Оно заключается в том, что функциональные компоненты захватывают отрендеренные значения. Поговорим о том, что это, на самом деле значит.
Нужно отметить, что этот материал не представляет собой попытку оценки компонентов разных видов. Я лишь описываю разницу между двумя моделями программирования в React. Если вы хотите узнать подробности о применении функциональных компонентов в свете нововведений — обратитесь к этому списку вопросов и ответов по хукам.
Каковы особенности кода компонентов, основанных на функциях и на классах?
Рассмотрим этот компонент:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
Он выводит кнопку, нажатие на которую имитирует, с помощью функции setTimeout
, выполнение сетевого запроса, а затем выводит окно сообщения с подтверждением выполнения операции. Например, если в props.user
хранится 'Dan'
, то в окне сообщения, через три секунды, будет выведено 'Followed Dan'
.
Обратите внимание на то, что неважно, используются ли тут стрелочные функции или объявления функций. Конструкция вида function handleClick()
будет работать точно так же.
Как переписать этот компонент в виде класса? Если просто переделать только что рассмотренный код, преобразовав его в код компонента, основанного на классе, то получится следующее:
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
handleClick = () => {
setTimeout(this.showMessage, 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
Принято считать, что два подобных фрагмента кода эквивалентны. И разработчики часто совершенно свободно, в ходе рефакторинга кода, преобразуют одно в другое, не задумываясь о возможных последствиях.
Кажется, что эти фрагменты кода эквивалентны
Однако между этими фрагментами кода есть небольшое отличие. Присмотритесь к ним повнимательнее. Видите разницу? Я, например, увидел её не сразу.
Дальше мы эту разницу рассмотрим, поэтому вот, для тех, кто хочет понять суть происходящего сам, работающий пример этого кода.
Прежде чем мы продолжим, мне хотелось бы особо отметить, что разница, о которой идёт речь, не имеет никакого отношения к хукам React. В предыдущих примерах, кстати, хуки даже не используются. Речь идёт о разнице между функциями и классами в React. И если вы планируете использовать в своих React-приложениях много функциональных компонентов, то вам, возможно, захочется эту разницу понять.
Собственно говоря, разницу между функциями и классами мы проиллюстрируем на примере ошибки, которая часто встречается в React-приложениях.
Ошибка, которая часто встречается в React-приложениях
Откройте страницу примера, на которой выводится список, позволяющий выбирать профили пользователей, и две кнопки Follow
, выводимых компонентами ProfilePageFunction
и ProfilePageClass
, функциональным, и основанным на классе, код которых показан выше.
Попробуйте, для каждой из этих кнопок, выполнить следующую последовательность действий:
- Щёлкните по кнопке.
- Измените выбранный профиль до того, как после нажатия на кнопку прошли 3 секунды.
- Прочтите текст, выведенный в окне сообщения.
Сделав это, вы заметите следующие особенности:
- При щелчке по кнопке, сформированной функциональным компонентом, при выбранном профиле
Dan
и при последующем переключении на профильSophie
, в окне сообщения будет выведено'Followed Dan'
. - Если сделать то же самое с кнопкой, сформированной компонентом, основанном на классе, будет выведено
'Followed Sophie'
.
Особенности работы компонента, основанного на классе
В этом примере правильным является поведение функционального компонента. Если я подписался на чей-то профиль, а затем перешёл к другому профилю, мой компонент не должен сомневаться в том, на чей именно профиль я подписался. Очевидно, реализация рассматриваемого механизма, основанная на использовании классов, содержит ошибку (кстати, вам, определённо, стоит стать подписчиком Софии).
Причины неправильного поведения компонента, основанного на классе
Почему же компонент, основанный на классе, ведёт себя именно так? Для того чтобы это понять — присмотримся к методу showMessage
в нашем классе:
class ProfilePage extends React.Component {
showMessage = () => {
alert('Followed ' + this.props.user);
};
Этот метод осуществляет чтение данных из this.props.user
. Свойства в React иммутабельны, поэтому они не меняются. Однако this
, как это было всегда, является мутабельной сущностью.
На самом деле, цель наличия this
в классе кроется в возможности this
изменяться. Сама библиотека React периодически выполняет мутации this
, что позволяет работать со свежими версиями метода render
и методов жизненного цикла компонента.
В результате, если наш компонент выполняет повторный рендеринг во время выполнения запроса, this.props
изменится. После этого метод showMessage
прочтёт значение user
из «слишком новой» сущности props
.
Это позволяет сделать интересное наблюдение, касающееся пользовательских интерфейсов. Если сказать, что пользовательский интерфейс, концептуально, представляет собой функцию текущего состояния приложения, то обработчики событий являются частью результатов рендеринга — так же, как и видимые результаты рендеринга. Наши обработчики событий «принадлежат» конкретной операции рендеринга вместе с конкретными свойствами и состоянием.
Однако планирование таймаута, коллбэк которого читает this.props
, нарушает эту связь. Коллбэк showMessage
не «привязан» ни к какой конкретной операции рендеринга, в результате он «теряет» правильные свойства. Чтение данных из this
разрывает эту связь.
Как, средствами компонентов, основанных на классах, решить проблему?
Представим, что функциональных компонентов в React не существует. Как в таком случае решить эту проблему?
Нам нужен какой-то механизм, позволяющий «восстановить» связь между методом render
с правильными свойствами и коллбэком showMessage
, выполняющим чтение данных из свойств. Этот механизм должен располагаться где-то там, где теряется сущность props
с правильными данными.
Один из способов сделать это заключается в том, чтобы заблаговременно прочитать this.props
в обработчике события, а затем в явном виде передать то, что было прочитано, функции обратного вызова, используемой в setTimeout
:
class ProfilePage extends React.Component {
showMessage = (user) => {
alert('Followed ' + user);
};
handleClick = () => {
const {user} = this.props;
setTimeout(() => this.showMessage(user), 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
Такой подход оказывается рабочим. Но используемые здесь дополнительные конструкции, со временем, приведут к увеличению объёма кода и к тому, что вырастет вероятность появления в нём ошибок. Что если нам нужно нечто большее, чем единственное свойство? Что если нам нужно ещё и работать с состоянием? Если метод showMessage
вызовет другой метод и этот метод прочтёт this.props.something
или this.state.something
, то мы снова столкнёмся с той же самой проблемой. А для того, чтобы решить её, нам пришлось бы передавать this.props
и this.state
в виде аргументов всем методам, вызываемым из showMessage
.
Если и правда так поступать, то это уничтожит все удобства, которые даёт использование компонентов, основанных на классах. То, что работать с методами нужно именно так, сложно запомнить, это сложно автоматизировать, в результате разработчики часто, вместо того, чтобы пользоваться подобными методиками, соглашаются с тем, что в их проектах имеются ошибки.
Аналогично, встраивание кода alert
в handleClick
не решает более глобальную проблему. Нам нужно структурировать код так, чтобы это позволило бы разделять его на множество методов, но и так, чтобы можно было читать свойства и состояние, которые соответствуют операции рендеринга, связанной с конкретным вызовом. Эта проблема, кстати, даже не относится исключительно к React. Воспроизвести её можно в любой библиотеке для разработки пользовательских интерфейсов, которая помещает данные в мутабельные объекты наподобие this
.
Может быть, для того, чтобы эту проблему решить, можно привязать методы к this
в конструкторе?
class ProfilePage extends React.Component {
constructor(props) {
super(props);
this.showMessage = this.showMessage.bind(this);
this.handleClick = this.handleClick.bind(this);
}
showMessage() {
alert('Followed ' + this.props.user);
}
handleClick() {
setTimeout(this.showMessage, 3000);
}
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
Но и это нашу проблему не решает. Помните о том, что она заключается в том, что мы выполняем чтение данных из this.props
слишком поздно, а не в используемом синтаксисе! Однако эта проблема разрешится в том случае, если мы будем полагаться на JavaScript-замыкания.
Разработчики часто стараются избегать замыканий, так как непросто размышлять о значениях, которые, со временем, не могут мутировать. Но свойства в React являются иммутабельными! (Или, как минимум, это настоятельно рекомендуется). Это позволяет перестать воспринимать замыкания как нечто, из-за чего программист может, что называется, «выстрелить себе в ногу».
Это означает, что если «запереть» в замыкании свойства или состояние конкретной операции рендеринга, то всегда можно рассчитывать на то, что они меняться не будут.
class ProfilePage extends React.Component {
render() {
// Захватываем свойства!
const props = this.props;
// Обратите внимание на то, что мы находимся внутри метода render.
// Эти функции - не методы класса.
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return <button onClick={handleClick}>Follow</button>;
}
}
Как видите, тут мы «захватили» свойства во время вызова метода render
.
Свойства захвачены при вызове render
При таком подходе любой код, находящийся в методе render
(включая showMessage
) гарантированно будет видеть свойства, захваченные в ходе некоего конкретного вызова этого метода. В результате React больше не сможет помешать нам делать то, что нам нужно.
В методе render
можно описывать сколько угодно вспомогательных функций и все они смогут пользоваться «захваченными» свойствами и состоянием. Вот как замыкания позволили решить нашу проблему.
Анализ решения проблемы с использованием замыкания
То к чему мы только что пришли, позволяет решить проблему, но такой код выглядит странно. Зачем вообще нужен класс, если функции объявляют внутри метода render
, а не в качестве методов класса?
Мы, на самом деле, можем упростить этот код, избавившись от «оболочки» в виде класса, которая его окружает:
function ProfilePage(props) {
const showMessage = () => {
alert('Followed ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
Здесь, как и в предыдущем примере, свойства оказываются захваченными в функции, так как React передаёт их ей в виде аргумента. В отличие от this
, React никогда не выполняет мутаций объекта props
.
Это становится немного более очевидным в том случае, если деструктурировать props
в объявлении функции:
function ProfilePage({ user }) {
const showMessage = () => {
alert('Followed ' + user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Follow</button>
);
}
Когда родительский компонент рендерит ProfilePage
с другими свойствами, React снова вызовет функцию ProfilePage
. Но обработчик события, который уже вызван, «принадлежит» предыдущему вызову этой функции, в этом вызове используется его собственное значение user
и его собственный коллбэк showMessage
, который это значение читает. Всё это остаётся нетронутым.
Именно поэтому в исходной версии нашего примера при работе с функциональным компонентом выбор другого профиля после щелчка по соответствующей кнопке до вывода сообщения уже ничего не меняет. Если перед щелчком по кнопке был выбран профиль Sophie
, то в окне сообщения, что бы ни происходило, будет выведено 'Followed Sophie'
.
Использование функционального компонента
Такое поведение является правильным (возможно, кстати, вы захотите подписаться и на Сунила).
Теперь мы выяснили — в чём заключается большое различие между функциями и классами в React. Как уже было сказано, речь идёт о том, что функциональные компоненты захватывают значения. Теперь поговорим о хуках.
Хуки
При использовании хуков принцип «захвата значений» распространяется и на состояние. Рассмотрим следующий пример:
function MessageThread() {
const [message, setMessage] = useState('');
const showMessage = () => {
alert('You said: ' + message);
};
const handleSendClick = () => {
setTimeout(showMessage, 3000);
};
const handleMessageChange = (e) => {
setMessage(e.target.value);
};
return (
<>
<input value={message} onChange={handleMessageChange} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
→ Здесь можно с ним поэкспериментировать
Хотя перед нами и не образцовый пример интерфейса приложения для обмена сообщениями, этот проект иллюстрирует ту же самую идею: если пользователь отправил некое сообщение, компонент не должен путаться в том, какое именно сообщение было отправлено. Константа message
этого функционального компонента захватывает состояние, которое «принадлежит» тому, что рендерит компонент, который даёт браузеру вызываемый им обработчик щелчка по кнопке. В результате в message
хранится то, что было в поле ввода в момент щелчка по кнопке Send
.
Проблема захвата свойств и состояния функциональными компонентами
Мы знаем о том, что функциональные компоненты в React, по умолчанию, захватывают свойства и состояние. Но что если нам нужно прочесть самые свежие данные из свойств или состояния, не принадлежащие конкретному вызову функции? Что если мы хотим «прочитать их из будущего»?
В компонентах, основанных на классах, это можно было бы сделать, просто обратившись к this.props
или this.state
, так как this
— мутабельная сущность. Её изменением занимается React. В функциональных компонентах тоже можно работать с мутабельными значениями, которые совместно используются всеми компонентами. Эти значения называются ref
:
function MyComponent() {
const ref = useRef(null);
// Можно считывать и записывать `ref.current`.
// ...
}
Однако управлять такими значениями программисту необходимо самостоятельно.
Сущность ref
играет ту же роль, что и поля экземпляра класса. Это — «запасной выход» в мутабельный императивный мир. Возможно, вы знакомы с концепцией DOM refs, но эта идея является гораздо более общей. Её можно сравнить с ящиком, в который программист может что-то положить.
Даже внешне конструкция вида this.something
выглядит зеркальным отражением конструкции something.current
. Они являются представлением одной и той же концепции.
По умолчанию React не создаёт в функциональных компонентах сущности ref
для самых свежих значений свойств или состояния. Во многих случаях они вам не понадобятся, и их автоматическое создание оказалось бы пустой тратой времени. Однако работу с ними, если это нужно, можно организовать своими силами:
function MessageThread() {
const [message, setMessage] = useState('');
const latestMessage = useRef('');
const showMessage = () => {
alert('You said: ' + latestMessage.current);
};
const handleSendClick = () => {
setTimeout(showMessage, 3000);
};
const handleMessageChange = (e) => {
setMessage(e.target.value);
latestMessage.current = e.target.value;
};
Если мы прочитаем message
в showMessage
, то мы увидим то сообщение, которое было в поле на момент нажатия на кнопку Send
. Но если прочесть latestMessage.current
, то можно получить самое свежее значение — даже если мы продолжаем вводить текст в поле после нажатия на кнопку Send
.
Можете сравнить этот и этот примеры для того чтобы самостоятельно оценить разницу. Значение ref
— это способ «уклонения» от единообразия рендеринга, в некоторых случаях это может быть очень кстати.
В общем случае стоит избегать чтения или записи значений ref
в процессе рендеринга из-за того, что эти значения мутабельны. Мы стремимся к тому, чтобы сделать рендеринг предсказуемым. Однако если нам нужно получить самое свежее значение чего-то, хранящегося в свойствах или в состоянии, ручное обновление значения ref
может оказаться утомительным занятием. Его можно автоматизировать, используя эффект:
function MessageThread() {
const [message, setMessage] = useState('');
// Отслеживаем самое свежее значение.
const latestMessage = useRef('');
useEffect(() => {
latestMessage.current = message;
});
const showMessage = () => {
alert('You said: ' + latestMessage.current);
};
→ Вот пример, в котором используется этот код
Мы выполняем присвоение значения внутри эффекта, в результате значение ref
изменится только после того, как обновится DOM. Это позволяет обеспечить то, что наша мутация не нарушит работу таких возможностей, как Time Slicing и Suspense, которые полагаются на непрерывность операций рендеринга.
Использование значения ref
таким способом требуется нечасто. Захват свойств или состояния обычно представляется гораздо лучшей схемой стандартного поведения системы. Однако это может быть удобным при работе с императивными API, наподобие тех, что используют интервалы или подписки. Помните о том, что вы можете работать так с любыми значениями — со свойствами, с переменными, хранящимися в состоянии, со всем объектом props
или даже с функцией.
Этот паттерн, кроме того, может оказаться полезным для целей оптимизации. Например, когда нечто вроде useCallback
изменяется слишком часто. Правда, более предпочтительным решением часто является использование редьюсера.
Итоги
В этом материале мы рассмотрели один из неправильных паттернов использования компонентов, основанных на классах, и поговорили о том, как решить эту проблему с помощью замыканий. Однако вы могли заметить, что когда вы пытаетесь оптимизировать хуки, указывая массив зависимостей, вы можете столкнуться с ошибками, связанными с устаревшими замыканиями. Означает ли это, что сами замыкания — это проблема. Я так не думаю.
Как было показано выше, замыкания, на самом деле, помогают нам исправлять небольшие проблемы, которые сложно бывает заметить. Они, аналогично, упрощают написание кода, который правильно работает в параллельном режиме. Это возможно благодаря тому, что внутри компонента «запираются» правильные свойства и состояние, с которыми этот компонент был отрендерен.
Во всех случаях, которые мне приходилось до сих пор видеть, проблема «устаревших замыканий» происходила из-за ошибочного предположения о том, что «функции не меняются», или о том, что «свойства всегда остаются одинаковыми». Надеюсь, прочтя этот материал, вы убедились в том, что это не так.
Функции «захватывают» свои свойства и состояние — и поэтому понимание того, о каких именно функциях идёт речь — это также важно. Это — не ошибка, это — особенность функциональных компонентов. Функции не следует исключать из «массива зависимостей» для useEffect
или useCalback
, например. (Подходящим средством для решения проблемы обычно является либо useReducer
, либо useRef
. Об этом мы говорили выше, скоро мы подготовим материалы, которые посвящены выбору того или иного подхода).
Если большинство кода наших приложений будет основано на функциональных компонентах — это означает, что нам нужно больше знать об оптимизации кода, и о том, какие значения могут со временем меняться.
Вот хорошее высказывание на эту тему: «Лучшее ментальное правило, применимое к хукам, которое мне удалось обнаружить, заключается в том, что программировать надо так, как будто любое значение может измениться в любое время».
Работа с функциональными компонентами не является исключением из этого правила. На то, чтобы это воспринималось бы в руководствах по React как нечто совершенно очевидное, потребуется некоторое время. Для этого нужно чтобы те, кто до этого «думали классами», немного изменили бы стиль
Функциональные компоненты всегда захватывали их значения, и теперь вы знаете о том, почему это так.
Функции в React — это совсем другой покемон
Автор: ru_vds