Зачем React, если вы уже писали рендер из состояния

зачем React≈ 10 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В части II, в статье «Рендер из состояния и делегирование», вы уже написали маленький React: правда в массиве, функция render() собирает из него разметку целиком, один обработчик на контейнере ловит все клики. Работало это ровно до того дня, когда в разметке появилось поле ввода. React нужен не для красоты и не потому, что так принято, — он чинит то, что ломается в вашем собственном render().

Разделы темы:·

Цикл из части II был честным и коротким: состояние — источник правды, render() собирает из него разметку целиком, обработчик меняет данные и зовёт render() заново. Ни поиска нужного li, ни переподписки после перерисовки, ни ручной сверки счётчика со списком: баг «на экране одно, в данных другое» становился невозможным по построению.

Ломается это на первом же поле ввода. Присваивание innerHTML ничего не «обновляет»: браузер выбрасывает старое поддерево целиком и разбирает строку заново. Всё, что жило в узле, а не в тексте разметки, уходит вместе с ним.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Три последние строки — три разные потери. again === input даёт false: на месте поля стоит другой объект, старый уже мусор. Значение пустое, потому что value — свойство узла, а в разметке остаётся только начальный атрибут (эту пару вы разбирали в части II). А фокусу просто некуда деться: элемент, на котором он стоял, перестал существовать, и activeElement откатился к body.

Прокрутка ведёт себя хитрее и подсказывает общее правило. Если прокручиваемый контейнер сам попал под пересборку, он новый — и scrollTop у него нулевой.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

А присвойте innerHTML самому #box, заменив только его содержимое, — и прокрутка уцелеет: узел остался прежним, браузер лишь пересчитает предел. Правило одно на все потери: пересборку переживает то, чей узел её пережил.

Пользователь замечает это раньше вас. Он печатает в поиске, приходит ответ, render() перерисовывает список — и буква, набранная в эту миллисекунду, исчезает вместе с фокусом.

Первое, что приходит в голову, — снять перед пересборкой то, что она сотрёт, и вернуть обратно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

И ведь работает. Поэтому такой код и пишут: он короткий, понятный и снимает сегодняшнюю боль. Проблемы начинаются завтра.

Заплатка знает ровно то, что вы в неё положили. Появился прокручиваемый список — ещё две строки. Появился раскрытый <details> — ещё одна. Появилось видео — ещё одна, и оно всё равно дёрнется. И всё это копится в render(), то есть в одном месте на всё приложение, хотя знать ему приходится про частности каждого виджета.

Что пропадает при пересборкеЧто нужно снять и вернуть руками
Набранный текстvalue каждого поля
Каретка и выделениеselectionStart и selectionEnd
Фокусid активного элемента — и focus() после вставки
ПрокруткаscrollTop и scrollLeft каждого контейнера
Раскрытый <details>open у каждого
Проигрывание медиаcurrentTime и paused — и всё равно будет рывок
Незавершённый CSS-переходничего: он начнётся заново

Второй путь — не пересобирать всё, а менять точечно: самому понять, что изменилось, и тронуть только это. Ровно тот код, от которого вы ушли в части II. Разметка снова заживёт своей жизнью, и «галочка стоит, а счётчик не сдвинулся» вернётся. Задача при этом честная и решаемая — просто её не хочется решать заново в каждом проекте.

Обе беды растут из одного корня: результат render() — строка. Сравнить в ней нечего, разобрать её умеет только браузер, а разбор всегда означает новые узлы.

React возвращает из компонента не строку, а обычный объект — описание того, что должно быть на экране. Посмотрите, чем на самом деле оказывается <p>Привет</p>, написанный в TSX.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

typeof даёт "object": у объекта есть type с именем тега и props с атрибутами. Последняя строка нужна ради диагностики — компилятор говорит «Тип "Element" не может быть назначен для типа "string"», и это самая короткая формулировка всей разницы. Кода вы написали столько же, сколько писали бы строкой, но объект, в отличие от строки, можно сравнить с предыдущим.

Сам синтаксис JSX — почему className, почему один корень и что делать с переносами строк — разбирается дальше в этой главе. Здесь важно единственное: это не разметка, а выражение, которое даёт объект.

Раз описание — объект, React может держать при себе предыдущее и сравнивать с новым. По разнице он вычисляет минимальный набор правок для настоящего DOM: тут поменять текст, тут атрибут, тут вставить или убрать узел. Чего в разнице нет, он не трогает вовсе.

Тот же сценарий, что развалился в начале статьи: печатаем в поле, ставим каретку в середину, жмём кнопку — и компонент отрисовывается целиком. Что станет с фокусом?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

document.getElementById("query") === input — true: поле осталось тем же самым объектом. React не запоминал активный элемент и не возвращал фокус — он просто не создавал новое поле. activeElement встречается здесь ровно один раз, и то в проверке: ни в компоненте, ни внутри самого React нет ничего похожего на «снять и вернуть».

Слово «отрисовка» в React означает вызов функции компонента, а не перерисовку страницы. Это разные события, и их полезно разделять с самого начала: посчитаем вызовы и заодно проверим узлы на месте.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Функция Ticker выполнилась дважды — и всё же <span> остался тем же объектом. Больше того: тем же остался и текстовый узел с числом, React поменял в нём только nodeValue. Сравните с пересборкой, где новыми становятся все узлы до единого, включая те, в которых ничего не изменилось.

Гарантия не безусловная. Узел переживает отрисовку, пока на его месте в описании стоит элемент того же типа. Сменился тип — был <span>, стал <p> — и React строит новый узел со всеми последствиями: фокус, каретка и прокрутка внутри пропадут точно так же, как при innerHTML. Атрибут id на это не влияет никак: сверка идёт по позиции и типу, а не по идентификаторам. Проверить это можно в задаче в конце статьи.

Отсюда же следует практическое правило, к которому мы вернёмся уже в этой главе, в статье «Выражения, условия и списки в разметке»: чем стабильнее форма описания, тем больше узлов переживает отрисовку. Списки, которые перетасовываются, приходится помечать отдельно — иначе React решит, что на первом месте теперь другая запись, и перепишет узлы вместо того, чтобы их переставить.

Что вы писали руками в части IIЧто с этим в React
render() собирает строку разметкикомпонент возвращает описание — объект
innerHTML выбрасывает поддерево целикомсверка описаний и точечные правки DOM
data-id, closest(), поиск узла по кликуобработчик прямо в описании, замыкание помнит запись
один слушатель на контейнере, чтобы пережить перерисовкупереживать нечего: узлы не пересоздаются
заплатки на фокус, каретку и прокруткуне нужны, пока узел на месте
сверка счётчика в шапке со спискомоба читают одно состояние

Обратите внимание, чего в этом списке нет. React не делает приложение быстрым: сверка описаний — это работа, которой в вашем render() не было, и на десяти строках она чистый расход. Он не отменяет знание DOM: события, формы, фокус и всплытие остались теми же, вы просто перестали трогать узлы руками. И он не решает за вас, где живёт правда, — выбор состояния по-прежнему ваш, и ошибка «два источника правды» повторяется в React один в один.

Идея не принадлежит React: Vue, Svelte и Solid решают ту же задачу другими средствами, и переходить между ними недорого именно потому, что задача одна — держать разметку функцией состояния, не пересоздавая её целиком. Меняются буквы, а <input>, у которого не должен пропадать фокус, остаётся тем же самым.

Цена вопроса — около 60 КБ в сжатом виде на React и ReactDOM: столько весит боевая сборка. Оснастка, которой запускаются примеры этой части, вдвое тяжелее — она отладочная, ради полных текстов предупреждений. Для доски задач из части II это дорого. Для экрана с полутора десятками полей и живым поиском — дешевле первой недели заплаток.

  • Полная пересборка через innerHTML уносит всё, что живёт в узле, а не в разметке: значение поля, каретку, выделение, фокус, прокрутку внутри пересобранной области.
  • Заплатки «снять и вернуть» работают, но растут вместе с интерфейсом и стягивают знание про каждый виджет в одну функцию.
  • Компонент React возвращает не строку, а объект-описание — и только поэтому новую разметку есть с чем сравнить.
  • React правит в DOM разницу описаний, поэтому узлы не пересоздаются, а фокус и каретка выживают сами, без единой строчки про activeElement.
  • Гарантия держится, пока на том же месте стоит элемент того же типа: сменился тип — будет новый узел со всеми потерями.
  • React не ускоряет приложение и не отменяет знание DOM; он снимает ровно одну задачу — обновлять разметку из состояния, не пересоздавая её целиком.

1/4Функция render() из части II пересобирает разметку через innerHTML. В поле ввода пользователь набрал текст и поставил каретку в середину. Что останется после вызова render()?

2/4В React после клика компонент отрисовался заново, а фокус остался в поле. Почему?

3/4В TSX-файле написано const view = <p>Привет</p>. Что напечатает console.log(typeof view)?

4/4На месте узла в описании был <span id="text">, после смены состояния стал <p id="text">. Что сделает React?

Заплатка на фокус

Перед вами render() из части II. Допишите его так, чтобы после перерисовки поле снова получило набранный текст, фокус и каретку на прежнем месте. Менять разрешено только тело render — всё остальное имитирует пользователя и трогать его нельзя.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Снимаем состояние активного элемента до присваивания innerHTML и возвращаем после. Опознать поле в новой разметке можно только по id — других связей между старым и новым узлом не осталось.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Проверьте, что получилось: три console.log печатают true, true и 2. А теперь прикиньте, что дописывать в эту же функцию, когда в разметке появится второе поле, прокручиваемый список и раскрытый <details>, — и вы получите ту самую задачу, ради которой существует React.

Тот же ли это узел

Заметка показывается то обычным текстом, то абзацем. Сначала предскажите, что напечатает последняя строка, и только потом запускайте. Затем измените компонент так, чтобы узел уцелел при переключении, — вид должен по-прежнему меняться.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Последняя строка печатает false: в описании на этом месте оказался элемент другого типа, и React удалил старый узел, а на его место построил новый. Тот же id ему безразличен — сверка идёт по позиции и типу.

Чтобы узел уцелел, тип должен остаться прежним, а меняться должно оформление:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь after === before даёт true: React лишь правит стиль у существующего узла. Разница не косметическая — будь внутри поле ввода, в первом варианте вы потеряли бы и текст, и фокус.

Спасти прокрутку

Список прокручен вниз, в конец добавилась одна строка — и список уехал наверх. Почините это, не заводя ни одной переменной под прокрутку и не трогая scrollTop руками.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Прокрутка живёт в узле #box, а он каждый раз строится заново вместе со всей разметкой. Достаточно вынести контейнер из пересобираемой области и пересобирать только его содержимое.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь scrollTop остаётся равным 120: узел контейнера пересборку пережил, браузер только пересчитал предел прокрутки. Это и есть в миниатюре то, что React делает для всего дерева сразу.