Раздел 7 · Браузер и Frontend

React: композиция, жизненный цикл и что вызывает ререндер

middle~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

React: композиция, жизненный цикл и что вызывает ререндер

В уроке 04 весь интерфейс Echo жил в одной функции App: список каналов, выбор активного, заголовок справа. Пока узлов мало, это терпимо. Но реальное приложение это не одна большая функция, а дерево из десятков маленьких. Этот урок про то, как собирать UI из переиспользуемых кусков и, главное, как перестать бояться слова “ререндер”: понять, когда React перерисовывает компонент и почему это почти всегда дёшево.

1. Композиция: children и слоты

Представь шкаф из лего. Каждый компонент это коробка, и у коробки есть место, куда вкладывают другие коробки. Большой интерфейс это не один гигантский компонент, а композиция маленьких.

Самый частый способ вложения это специальный prop children. Всё, что ты написал между открывающим и закрывающим тегом компонента, попадает в него:

type LayoutProps = {
  children: React.ReactNode;
};

function Layout({ children }: LayoutProps) {
  return (
    <div className="layout">
      <header className="layout-top">Echo</header>
      <main className="layout-body">{children}</main>
    </div>
  );
}

// использование: ChannelView оказывается внутри main
<Layout>
  <ChannelView channelId="general" />
</Layout>;

Layout ничего не знает про то, что внутри. Он задаёт рамку, а содержимое приходит снаружи. Тип React.ReactNode это “всё, что React умеет рендерить”: JSX, строка, число, массив таких, null.

Если мест для вкладывания несколько, используй обычные props, которые принимают JSX. Это и есть слоты:

type PanelProps = {
  header: React.ReactNode;
  footer: React.ReactNode;
  children: React.ReactNode;
};

function Panel({ header, footer, children }: PanelProps) {
  return (
    <section className="panel">
      <div className="panel-head">{header}</div>
      <div className="panel-body">{children}</div>
      <div className="panel-foot">{footer}</div>
    </section>
  );
}

2. Compound-компоненты и render-props

Иногда коробки должны делить общее состояние между собой: набор вкладок знает, какая вкладка активна, и активная панель должна это видеть. Тащить активный индекс пропсами через все уровни больно. Решение это compound-компонент:

// внешний API: подкомпоненты делят состояние, ты их просто расставляешь
<Tabs defaultTab="messages">
  <Tabs.List>
    <Tabs.Tab id="messages">Сообщения</Tabs.Tab>
    <Tabs.Tab id="files">Файлы</Tabs.Tab>
  </Tabs.List>
  <Tabs.Panel id="messages">{/* лента */}</Tabs.Panel>
  <Tabs.Panel id="files">{/* список файлов */}</Tabs.Panel>
</Tabs>;

Внутри Tabs держит активную вкладку в состоянии и раздаёт её потомкам через контекст. Контекст подробно разберём в уроке 07, пока запомни сам паттерн: общее состояние живёт в одном месте, подкомпоненты его читают. Это твоё домашнее задание hw-fe-05-card-compound.

Когда нужна полная свобода разметки, а компонент хочет лишь дать состояние, помогает render-prop:

<Toggle>
  {(isOn, toggle) => (
    <button type="button" onClick={toggle}>
      {isOn ? 'выключить' : 'включить'}
    </button>
  )}
</Toggle>;

Toggle владеет состоянием isOn, но разметку кнопки задаёшь ты. В эпоху хуков render-props нужны реже (логику вынесешь в custom hook, урок 06), но в чужом коде ты их встретишь, и важно их узнавать.

3. Жизненный цикл функционального компонента

У компонента три момента жизни. Он монтируется (первое появление на экране), обновляется (перерисовывается при изменениях) и размонтируется (исчезает с экрана).

В отличие от классов со специальными методами, у функционального компонента нет отдельных методов жизненного цикла. Тело функции просто вызывается заново на каждом рендере, а в моменты mount, update и unmount React встраивает твой код через хук useEffect. Его механику разберём в уроке 06, здесь важна сама картина: монтирование, обновления, размонтирование.

Главное правило этой картины это чистота рендера. Тело компонента считает JSX и больше ничего не делает: не пишет в глобальные переменные, не мутирует props, не ходит в сеть. Всё, что взаимодействует с внешним миром, живёт в обработчиках событий и в эффектах. React оставляет за собой право вызвать рендер лишний раз или прервать его, и только чистая функция это переживает без багов.

4. Virtual DOM, reconciliation и файбер

Когда компонент возвращает JSX, он не трогает DOM напрямую. Он строит виртуальный DOM: дерево обычных JS-объектов, которые ты видел в уроке 04 как результат _jsx(...). Это описание, а не сам экран.

Дальше React делает реконсиляцию: берёт новое дерево, сравнивает с предыдущим и считает, что именно поменялось. Эвристики простые. Разный тип узла (div стал section) означает выкинуть старое поддерево и построить новое. Одинаковый тип означает оставить узел и обновить только изменившиеся атрибуты. Списки сопоставляются по key, про это раздел 7.

Работа разбита на две фазы:

  • Render-фаза: React вызывает компоненты, строит виртуальное дерево и считает разницу. Чистая и прерываемая: на экране пока ничего не изменилось.
  • Commit-фаза: React вносит вычисленные изменения в реальный DOM. Синхронная и быстрая, потому что патчит только разницу.

Прерываемость render-фазы обеспечивает файбер: дерево, где каждый узел это единица работы, которую можно посчитать, отложить и возобновить. Это движок time slicing.

И вот тонкость, которая делает файбер по-настоящему интересным. Обновления у React не равнозначны: у каждого есть своя полоса приоритета. Ввод и клик идут срочной полосой, переход по useTransition несрочной, фоновая отрисовка скрытого Suspense ещё ниже. Внутри планировщика это кооперативная многозадачность: JS однопоточный, никто React не прерывает, он сам уступает браузеру примерно каждые 5 миллисекунд и подхватывает работу обратно.

Поэтому поле ввода не залипает даже при тяжёлой фоновой реконсиляции: срочный setState обходит несрочный, отложенный рендер ставится на паузу посреди дерева, отрабатывает срочное, потом возобновляется отложенное с того же файбер-узла. Управлять полосами руками не нужно, для этого есть готовые useTransition и useDeferredValue из урока 07.

Откуда взялся VDOM и спор “DOM медленный”

Полезно понять, почему React вообще пошёл этим путём, иначе VDOM выглядит как магия. Задача у любой UI-библиотеки одна: держать состояние и картинку на экране синхронными. Поменял state, перерисовалось представление. Способов сделать это несколько, и VDOM лишь один из них.

Самый наивный способ: рендер-функция возвращает новое DOM-дерево, и оно целиком заменяет старое. Работает, но при смене одного слова в тексте мы пересобираем весь экран. Вот это и называли “DOM медленный”.

Следующий шаг: рендер всё ещё строит новое DOM-дерево, но мы не заменяем экран целиком, а рекурсивно сравниваем новое дерево со старым и вносим только точечную разницу. Лучше, но мы по-прежнему на каждый рендер создаём настоящие DOM-узлы только ради того, чтобы их обойти и выкинуть. А узлы нам тут и не нужны, нужно лишь понять, что поменялось.

Здесь и появляется идея VDOM, которую популяризировал Pete Hunt в докладе на JSConf EU 2013. Вместо настоящих DOM-узлов рендер строит обычные JS-объекты (это и есть результат _jsx из урока 04), сравнивает их, и только итог вносит в реальный DOM. Это заметно быстрее: DOM-узел это внешний браузерный API с богатой семантикой, его создание не бесплатно, а JS-объект это мелкий кирпичик языка, который движок создаёт очень быстро.

Так что “DOM медленный” на самом деле про узкую ситуацию: когда ради поиска разницы каждый раз строят новое настоящее дерево и сравнивают его с прежним. Сам по себе DOM работает нормально.

Через несколько лет Rich Harris из Svelte ответил тезисом “Virtual DOM is pure overhead”: сравнение деревьев это не единственный путь. Можно на этапе сборки скомпилировать код так, чтобы сравнение деревьев было не нужно вовсе, а обновления шли точечно прямо в нужный узел. Или подвязываться к обновлению конкретных узлов в рантайме, как делают signals. У каждого подхода свои плюсы, поэтому крупные фреймворки постепенно сближаются и становятся гибридами. Эту развилку разберём в уроке 13.

И всё же VDOM ускоряет сравнение, но не отменяет двух его врождённых проблем. Первая: рендер-функцию приходится звать на каждое обновление, а это вызов функции плюс пересборка дерева, пусть и виртуального. Вторая: сравнение больших деревьев или череда обновлений подряд всё ещё могут подвесить кадр. Именно ради этих двух проблем и придуманы файберы с полосами приоритета выше: разбить работу на куски и вовремя уступать браузеру.

Полезно увидеть это во времени, потому что обе проблемы команда React закрывала по очереди. Вторую, фризы, закрыл сам движок. До React 16 сравнение деревьев было синхронной рекурсией: большое дерево надолго занимало поток, и чтобы не залипало, разработчик подсказывал React руками, где не пересчитывать лишнее. React 16 принёс файбер и планировщик из этого раздела, рендер стало можно резать на куски и прерывать, и фризы ушли на уровень движка: ручные подсказки ради отзывчивости больше не нужны. А вот первую проблему, вызов рендер-функции на каждое обновление, файбер не тронул вовсе. Её ещё годы лечили ручной мемоизацией, пока в React 19 не появился компилятор, который снизил цену этого вызова автоматически. И ручные подсказки, и компилятор разберём в уроке 07, важно держать в голове порядок: сначала файбер против фризов, потом компилятор против лишней цены вызова.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.