Создание ментальной модели для React: согласованность, состояние и хуки
Познакомьтесь с логикой, стоящей за основными концепциями React — согласованием данных, компонентами, свойствами, состоянием и хуками — чтобы развить интуитивное понимание вместо заучивания API.
Если вы уже умеете писать код, но ещё не глубоко изучали React — или только кратко им занимались, — этая статья написана именно для вас.
Когда вы начинаете изучать React, возникает соблазн сразу погрузиться в useState, useEffect, props, хуки и длинный список других API. Вы можете освоить синтаксис, создать работающий проект, но всё равно не понять почему React ведёт себя именно так.
Цель этой статьи — устранить этот пробел.
Вместо того чтобы рассматривать React как набор API, которые нужно запомнить, мы рассмотрим логику проектирования React и то, как его компоненты взаимосвязаны. Синтаксис важен, но его гораздо легче усвоить, когда вы понимаете, что происходит на уровне основ. Поэтому вместо того, чтобы начинать с как писать код на React, сначала мы разберём как мыслить в рамках React.
Мы рассмотрим компоненты, пропсы, состояние, отрисовку, согласование, хуки, проникновение в пропсы, контекст и маршрутизацию, связывая каждую концепцию с проблемой, для решения которой она была создана. Цель не в том, чтобы перечислить все API React, а в том, чтобы дать вам психологическую модель, которая поможет легче понять эти API при их использовании.
Вы, вероятно, слышали, что одним из факторов такой высокой скорости работы React является какой-то умный алгоритм. Это может звучать почти как магия — как библиотеке на JavaScript удается так эффективно обновлять интерфейс?
Этот алгоритм называется согласование, и это хорошая отправная точка для понимания.
Согласование — алгоритм, сделавший возможным React
Поиск различий между двумя деревьями DOM с нуля и вычисление минимального набора необходимых изменений — это задача, решение которой может занять примерно O(n³) времени.
Но что, если процесс согласования будет основан на нескольких разумных предположениях, а мы предоставим ему некоторые подсказки о том, где, скорее всего, произойдут изменения?
Вот и всё! Сложность снижается примерно до уровня O(n).
Это означает, что подход, который в наивном случае занял бы около 31 года для завершения, теперь сокращается примерно до 16 минут — и это только благодаря использованию предположений и небольших указаний со стороны разработчика.
Подождите… подсказки? Что именно это за подсказки и как мы их предоставляем?
Не стоит волноваться. Это проще, чем кажется, и мы скоро всё разберём.
А пока давайте позволим процессу согласования работать в фоновом режиме и перейдём к вопросу, который действительно важен для нас как для разработчиков.
Но зачем вообще использовать React?
Почему React?
Независимо от того, что вам говорят, при переходе от обычных файлов HTML, CSS и JavaScript к концепциям, таким как компоненты, хуки, пропсы и состояние, требуется настоящая психологическая адаптация.
По сравнению с такими фреймворками, как FastAPI или Go, React действительно может казаться более сложным для освоения.
Но дело в том, что эта сложность сосредоточена в начальной фазе обучения.
Как только вы начнете лучше понимать принципы работы React, вы заметите, что многие из этих пугающих на первый взгляд концепций основаны на удивительно простых принципах.
Вам не обязательно запоминать всю свою приложение целиком сразу.
Вместо этого вы можете сосредоточиться на одном компоненте — на том, какие данные он принимает, что ему нужно отслеживать и как должен обновляться его отображаемый результат.
Именно такой разделенный на части способ мышления позволяет справляться с созданием и обслуживанием сложных интерфейсов. В конечном счете именно это и является тем результатом, который действительно важен для разработчиков.
Написание кода, который легче создавать, понимать, изменять и обслуживать. Цель здесь — помочь вам достичь этой цели.
Четыре основополагающих принципа React :—
Компоненты
По сути, компонент — это функция на JavaScript, которая принимает один объект и возвращает элемент интерфейса, написанный на JSX.
JSX — это расширение синтаксиса JavaScript, позволяющее встраивать маркировку, похожую на HTML, прямо в код на JavaScript. Стилизацию можно осуществлять с помощью обычного CSS или библиотек вроде Tailwind CSS.
Любое достаточно сложное приложение на React в конечном итоге представляет собой всего лишь сеть компонентов, обменивающихся данными между собой.
Даже если вы раньше не работали с React, вы всё равно можете понять, что делает компонент.
const data = {
question: "What is React?",
answer: "A JavaScript library for building user interfaces"
};
function FlashCard(data) {
return (
<div className="border rounded-lg bg-gray-50 p-4">
<h2>{data.question}</h2>
<p>{data.answer}</p>
</div>
);
}
Если игнорировать несколько специфических особенностей синтаксиса React, это по сути и есть суть самого React.
Всё не так уж плохо, верно?
По сути, мы передаём какие-то данные в функцию и получаем элемент интерфейса.
Поэтому задача разработчика заключается в создании чистых компонентов, постоянно учитывая три вопроса:
- Какую информацию он принимает?
- Какую информацию он хранит?
- Как должен меняться его вид?
Что может быть сложного в нескольких параметрах и некоторых переменных? Разве мы не можем просто передать любые нужные данные и сохранить то, что хотим, в локальных переменных?
В некотором смысле — да, но не совсем.
А что мы имеем в виду под обновлением интерфейса? Представьте двух продавцов, которые пытаются продать одну и ту же машину, но менеджер сообщает о изменении цены только одному из них.
Что происходит с другим продавцом? Он продолжает называть старую цену, даже не подозревая об изменениях.
Теперь представьте, что менеджер публикует обновление в групповом чате, к которому присоединены все. Изменив цену один раз, вся команда сразу её видит.
Именно эту проблему и было решено с помощью React.
Каждый раз, когда меняется какая-либо информация, влияющая на то, что отображается на экране, всё, что от неё зависит — люди в нашей аналогии, компоненты в React — нуждается в надёжном способе узнавать о этом изменении и реагировать на него. Именно такой механизм предоставляет React.
Props
Могут ли функции function User(name, age, city) и function User(name, city) заменять друг друга? А что насчёт function User(city, name)?
Типичная функция, зависящая от позиционных параметров, может принимать только определённое количество аргументов в строгом порядке — и помните, что компонент — это просто функция.
Теперь представьте, что вы создали компонент, отображающий имя и возраст пользователя, и он в настоящее время используется в 67 разных местах вашего кодового базиса. Затем ваш руководитель просит вас также отображать город пользователя. Обновление каждого места использования займёт часы работы.
Но что, если функция была спроектирована таким образом, чтобы старые вызовы продолжали работать без изменений, в то время как новые вызовы могли по желанию передавать дополнительные данные?
Инструментом, который здесь нужен, является единственный объект, заменяющий список параметров. React называет это props.
Props — это просто способ, с помощью которого React объединяет все данные, передаваемые компоненту, в один объект.
/** So a component like this one **/
function User({ name = "Guest", age = 18, city = "Unknown" }) {
return (
<div>
<h2>{name}</h2>
<p>{age} years old</p>
<p>{city}</p>
</div>
);
}
/** Can be used in ways like **/
<User /> /** Guest, 18, Unknown **/
<User name="Pritam" /> /** Pritam, 18, Unknown **/
<User name="Rahul" age={21} /> /** Rahul, 21, Unknown **/
<User name="Priya" age={22} city="Delhi" /> /** Priya, 22, Delhi **/
Состояния
Должен был ли менеджер дилерского центра сохранить новую цену для себя? Рассказать только одному продавцу? Обоим им? Или объявить её всем в дилерском центре, включая охранников и уборщиков?
Представьте ящик для хранения, в который вы кладете свои вещи. Можете ли вы, просто взглянув на ящик снаружи, понять, изменилось ли что-то внутри? А что, если ящик переместят на другую полку? По крайней мере, вы заметите изменение его положения и сделаете вывод о том, что что-то произошло.
Теперь представьте, что этот ящик — это механизм, который использует React для хранения значений, которые вы определяете.
Это означает, что должен существовать способ передавать обновленные значения тем, кто от них зависит, а также способ, позволяющий этим зависящим элементам узнавать, когда их значения изменились.
const [count, setCount] = useState(initialCount);
Вы уже сталкивались с таким вызовом useState ранее?
Он передает компоненту два элемента:
- count — текущее значение состояния.
- setCount — функция, которую вызывают для обновления значения этого состояния.
Так в чём проблема при простом написании value = 5?
У React нет способа узнать, что что-то внутри этого элемента изменилось!
Другими словами, вам нужен механизм, чтобы сообщить React: «Эй, я обновил этое значение, возможно, стоит обновить интерфейс».
Это тот самый намёк из предыдущего раздела? Вроде бы и да, и нет.
useState позволяет хранить значение, запрашивать его обновление и одновременно уведомлять React о произошедших изменениях. Но почему React нужно прямо об этом сообщать?
Потому что в противном случае вы будете вести себя как тот невнимательный менеджер автосалона.
Помните ту ошибку? Менеджер изменил цену, обновил свою копию табло и забыл сообщить остальным. Вы умнее — вы просто вызовете setCount().
Что же на самом деле происходит после вызова setCount()?
React берет новое состояние, перерисовывает компонент, чтобы определить, каким должен быть интерфейс сейчас, а затем использует процесс согласования, чтобы выяснить, что именно нужно изменить на экране.
Вы просто сообщили React, что что-то изменилось. Процесс согласования определяет, что именно изменилось и что в результате требует обновления.
Hooks
useState() — встроенная функция, которая позволяет компоненту хранить данные состояния и предоставляет способ запроса их обновления.
Когда это состояние обновляется, могут произойти две вещи:
- Новое значение не влияет на то, что отображается — React возможно все равно перерисует компонент, но ничего видимого не изменится.
Функции вроде этой, обладающие специальными возможностями React, называются хуками. Стоит ознакомиться ещё с несколькими из них.
useRef() — полезен, когда вам нужен контейнер для значения, которое совсем не связано с интерфейсом.
const count = useRef(0);
count.current++;
Поэтому общее правило таково: если изменение значения должно повлиять и на интерфейс, используйте useState(). Если нужно изменить только само значение без каких-либо последствий для отрисовки, используйте useRef().
У него также есть второе распространённое применение — обращение к реальным элементам DOM, но пока этой модели мышления достаточно.
useEffect() — используется тогда, когда необходимо выполнить какую-либо операцию после того, как React завершил отрисовку интерфейса.
useEffect(() => {
console.log("Runs after every render");
});
useEffect(() => {
console.log("Runs once after the initial render");
}, []);
useEffect(() => {
console.log("After the initial render and whenever count changes");
}, [count]); /** Dependencies go here **/
useContext() — рассмотрим дерево компонентов, в котором данные вроде name должны пройти через несколько уровней компонентов, чтобы добраться до глубоко вложенного компонента UserName.
Теперь представим, что это дерево увеличивается в размерах, при этом множество данных проходит через компоненты, которые даже не используют их напрямую.
Такой паттерн называется prop drilling.
API Context от React предоставляет способ решения этой проблемы: он позволяет сделать данные доступными в любой более глубокой части дерева, не требуя ручного передачи их через каждый компонент по пути.
const ParentContext = createContext(null)
<ParentContext value={money}>
<ChildComponent />
</ ParentContext>
function ChildComponent() {
const money = useContext(ParentContext);
return <p>Money: {money}</p>;
}
Существует ещё множество хуков, и стоит попробовать ими воспользоваться самостоятельно.
До сих пор речь шла о том, как React организует компоненты и как данные передаются между ними. Но настоящее приложение также должно определить, какие части интерфейса будут отображаться по каким URL-адресам.
React Router
React позволяет создавать интерфейсы из компонентов, которые обновляются автоматически, без необходимости перезагружать всю страницу в браузере. Однако настоящему приложению обычно требуется более одной страницы, что поднимает новый вопрос.
Что происходит, когда приложению нужно несколько разных видов отображения?
Возможно, вам понадобится что-то вроде:
/home → Главная страница
/dashboard → Панель управления
/profile → Профиль
Если вы соедините их с помощью обычных тегов HTML, нажатие на них запустит полную навигацию в браузере. Вся страница удаляется, и приложение снова запускается с нуля по новому адресу.
На самом деле вам нужно, чтобы URL обновлялся, пока React тихо определяет какие компоненты необходимо заменить, не удаляя при этом всё остальное.
Именно эту проблему решает React Router.
Можно представить его как слой, который соответствует URL компонентам.
Например:
<BrowserRouter>
<Routes>
<Route path="/home" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</BrowserRouter>
React Router анализирует текущий URL и отображает тот компонент, который к нему привязан. BrowserRouter использует API истории браузера для обработки навигации на стороне клиента, а Routes выбирает тот Route, который наилучшим образом соответствует текущему пути.
Тем не менее существует ещё одна сложность, с которой нужно справиться.
Что, если вы не хотите, чтобы весь экран перерисовывался?
Представьте себе такую структуру:
┌─────────────────────────────┐
│ Header │
├──────────┬──────────────────┤
│ │ │
│ Menu │ Page content │
│ │ │
├──────────┴──────────────────┤
│ Footer │
└─────────────────────────────┘
Переход с /home на /dashboard не должен приводить к исчезновению и появлению заголовка, боковой панели или футера. Должна меняться только основная область контента.
Именно для такой ситуации и созданы вложенные маршруты вместе с тегом <Outlet />.
function Layout() {
return (
<>
<Header />
<Menu />
<Outlet />
<Footer />
</>
);
}
Сами маршруты можно затем вкладывать друг в друга:
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="dashboard" element={<Dashboard />} />
</Route>
</Routes>
При такой настройке компонент Layout остается активным, а React Router подставляет тот или иной дочерний маршрут, который соответствует условиям внутри тега <Outlet />. Согласно собственной документации React Router, тег <Outlet /> обозначает место, где рендерится соответствующий дочерний маршрут.
Маршрут index соответствует <Home /> пути /, поэтому он является стандартным виджетом, отображаемым в интерфейсе, когда нет более конкретного активного пути.
Следовательно, переход с /home на /dashboard на самом деле не означает замены всей страницы. Это скорее соответствует следующему подходу:
"Оставьте эту часть интерфейса без изменений и замените только этот раздел на компонент, соответствующий новому маршруту."
Что дальше?
Как только вы хорошо поймете проблемы, которые решает React, и механизмы, которые он использует для их решения, стоит уделить время изучению реальных кодовых баз, чтобы увидеть, как опытные команды структурируют свои приложения.
Ищите репозиторий, демонстрирующий, как может быть организовано и спроектировано крупномасштабное производственное приложение на React.
Вместо попыток изучить весь кодовый базис за один прогон выберите отдельную функциональность и проследите за её работой на всех уровнях приложения. Обратите внимание на то, как группируются компоненты, откуда берутся данные, как обрабатывается состояние и как отдельные части приложения взаимодействуют друг с другом.
Также стоит найти пример, демонстрирующий, как React можно сочетать с Redux в рабочем приложении.
Если найденный проект устарел, не считайте его паттерны текущим стандартом для React. Используйте его вместо этого для изучения того, как крупное приложение можно разделить на части и как Redux вписывается в такую структуру.
Как только вы освоите типовую структуру файла в React и сможете самостоятельно создавать простые компоненты, следующим шагом будет изучение способов создания приложений, которые справляются со сложными нагрузками в масштабе.
Масштабирование React-приложения сопряжено со своими особенными проблемами, включая:
- SSR и серверные компоненты — как меняется поведение приложения, когда его части выполняются на сервере, а не полностью в браузере.
- Управление состоянием — что делать, когда состояние приложения становится слишком объемным или слишком широко распространяется, чтобы локальное состояние и Context могли с ним удобно справляться. Redux — один из нескольких вариантов.
- Загрузка данных и кэширование — как производственные приложения управляют индикаторами загрузки, обработкой ошибок, кэшированием и синхронизацией данных клиента с сервером.
- Производительность — определение моментов, когда рендеринг действительно становится узким местом, и оптимизация в соответствии с этим, а не попытки оптимизировать все заранее.
Вам не обязательно овладеть всеми этими темами перед началом разработки.
Более практичный подход — начать разработку, столкнуться с конкретной проблемой, а затем изучить ту или иную концепцию или инструмент, который решает именно эту проблему.
В конечном счете цель изучения React заключалась не в запоминании его API, а в понимании причин существования этих API и способов решения проблем, для которых они были созданы.
Связанные материалы
- Понимание пользовательских хуков в React: повторное использование логики без общего состояния — Узнайте, что такое пользовательские хуки в React, как они позволяют извлекать и делиться логикой с состоянием между компонентами, а также какие распространённые ошибки следует избегать при их создании.
- Шаблоны проектирования в React: от классического ООП до современных хуков — Рассматривается, как классические шаблоны программирования, такие как Singleton, Factory и Observer, применяются в React, наряду с шаблонами, специфичными для React, такими как HOC, хуки и составные компоненты.