Свой рендер: от описания к узлам

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

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

Прошлая статья вскрыла, что элемент — обычный объект { type, props }. Кто и как превращает этот объект в узлы страницы, осталось за кадром: между описанием и экраном лежит ещё половина работы. Сейчас вы напишете её сами — три функции, тридцать с небольшим строк — и своими руками получите тот эффект, из-за которого в первой статье главы уцелели набранный текст и позиция каретки.

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

Соберём рендер целиком: описание — узлы — правка. Ни одного нового приёма не понадобится. createElement, setAttribute и append вы писали руками ещё в части II, объект-описание держали в руках абзацем выше, а клея между ними нужно меньше, чем кажется.

Первая функция — та, в которую компилятор переписывает JSX. Назовём её h: под этим именем она живёт в Preact и во Vue, а в React её зовут createElement. Всё, что она делает, — раскладывает три вещи по трём полям.

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

Никакой обработки, никакой проверки — просто объект. Важнее всего последняя строка: document.getElementById("card") даёт null. Описание ничего не рисует и про страницу не знает вовсе; это данные, и главное их свойство в том, что их можно сравнить с прошлыми.

Имена пропсов у нас пишутся как в HTML: class, onclick. У React между JSX и DOM стоит таблица соответствий — она переводит className в атрибут class, а onClick в обработчик. У нашего рендера такой таблицы нет, поэтому и имена берём те, что понимает сам браузер.

Настоящий createElement делает ровно то же самое, только дети у него уезжают в props.children, а рядом появляются key и служебные поля. Нам дети удобнее отдельным полем: сверять их придётся чаще всего.

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

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

Вторая функция превращает описание в узлы. Правил ровно три: строка становится текстовым узлом, объект — элементом с атрибутами, дети обходятся тем же create. Описание — дерево, поэтому обход рекурсивный.

Прежде чем жать Run, скажите, что именно окажется в app.innerHTML — вплоть до кавычек.

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

Каждой вершине описания достался свой узел: три элемента и один текстовый. Разбора HTML при этом не было — строку с разметкой браузер здесь не читал, узлы построены поштучно, а innerHTML в конце только показывает результат. Заодно видно, что placeholder доехал настоящим атрибутом, а не текстом.

Пока это ровно то же, что делал ваш render() из части II, только длиннее. Разница появится, когда то же самое описание понадобится нарисовать во второй раз.

Данные изменились. Самый очевидный ход — позвать create ещё раз и заменить им содержимое. Читатель к этому моменту успел напечатать в поле и поставить каретку в середину. Прогноз: что от набранного останется?

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

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

Разница появляется, как только на руках оказываются два описания — прошлое и новое. Их можно сравнить и тронуть только то, что разошлось. Это и есть третья функция; она длиннее двух предыдущих, но в ней нет ничего, кроме четырёх решений: заменить узел, поправить текст, поправить атрибуты, спуститься к детям.

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

Заголовок сменился, а h2 остался прежним узлом — больше того, прежним остался текстовый узел внутри него, в нём поменялись только буквы. До поля update дошёл и ничего в нём не тронул: тип тот же, пропсы те же, детей нет — править нечего. Поэтому в нём и уцелели «две ложки» с кареткой на третьем символе.

Перечитайте update и убедитесь: в нём нет ни слова про поля ввода, каретку, выделение и прокрутку. Он их не спасает — он до них не дотрагивается. Тот же ответ вы читали в первой статье главы про настоящий React; теперь он написан вашими руками, и в нём нет ни одной строчки магии.

Фокус в примерах учебника не проверяем, хотя он уцелел бы по той же причине: кадр примера — отдельное окно, и получит ли оно фокус страницы, зависит от того, что вы запускали до этого. Значение поля и каретка таких капризов не знают.

Текст напрашивается править короче — у родителя: el.textContent = "Чай". Соблазн понятный, и на экране выйдет то же самое. Но textContent у элемента выбрасывает всех его детей и кладёт вместо них одного нового, а nodeValue меняет буквы внутри существующего узла. Разница видна на двух абзацах.

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

Первое сравнение — false: узел с буквами «было» выброшен, на его месте новый. Второе — true: тот же узел, другие буквы. Для абзаца с одним текстом разница незаметна, но textContent не разбирает, что сносит: будь рядом с текстом ещё и поле ввода, оно уехало бы вместе со всем набранным. Правка на месте не трогает соседей по определению.

Живой интерфейс — это ещё и обработчики. В описании они выглядят обычным пропсом, только значение у него функция, а не строка. Значит, у пропса появляется второй тип, а create и update должны отличать одно от другого. Обе функции в подложке примера изменены ровно на одну строку: вместо setAttribute они зовут setProp. Осталось написать сам setProp — и первая версия пишется сама собой.

Карточка отрисовывается трижды: «Кофе», потом «Чай», потом «Вода». После этого — одно нажатие на кнопку. Прогноз перед запуском: сколько строк оно напечатает и какие?

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

Три строки на одно нажатие, и первая из них — «клик: Кофе», хотя такой карточки на экране давно нет. addEventListener не заменяет обработчик, а добавляет ещё один. А стрелка в описании создаётся заново при каждом вызове view, поэтому before.props.onclick !== after.props.onclick получается всегда — и подписка добавляется на каждую правку. Три отрисовки, три живых обработчика, и каждый помнит свой заголовок замыканием.

Лечится это тем, что у узла для события есть не только подписка, но и свойство: тот самый button.onclick, которому в части II отведён раздел «Старый способ: onclick» статьи «Что такое событие и как на него подписаться». Присваивание свойству заменяет прежнее значение, а не добавляет новое.

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

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

Настоящий React идёт ещё дальше и не вешает обработчик на сам узел: он слушает корень приложения и находит нужный обработчик по описанию. Как это устроено — в главе про события.

Осталось честно посмотреть, где наш рендер ломается. Слабое место видно прямо в коде update: дети сверяются по номеру — i-й старый против i-го нового. Пока список растёт снизу, номера совпадают с записями и всё сходится.

Прогноз: в списке две строки, в поле первой набрано «две ложки», и в начало добавляется третья запись. Где окажется набранное?

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

«Две ложки» достались «Воде», которую только что добавили. Ошибки в update при этом нет ни одной: на позиции 0 стоял li — li и остался, внутри стоял input с теми же пропсами, значит, трогать его незачем. Поменялся только текст. Последняя строка вывода подтверждает: узел поля тот же самый — переехали не узлы, а данные.

Причина одна: у позиции нет тождества. «Первая строка» — это не запись, это место. Чтобы update понял, что запись переехала, в описании должна стоять метка, которую он сверит вместо номера. Как эта метка называется, вы узнаете уже в следующей статье — и увидите, что настоящий React без неё ведёт себя в точности как наши тридцать строк.

Что делает наш рендерКак это устроено у React
h собирает описаниеcreateElement, в который компилятор переписывает JSX
create строит узлы по описаниюпервая отрисовка, она же монтирование
update сравнивает два описания и правит разницусверка описаний и правки в DOM
обработчик кладётся в свойство узлаодин слушатель на корне приложения
дети сверяются по номеру позициипо метке из описания, а по номеру — только когда метки нет
рисуем сразу, в тот же миготрисовка ставится в очередь: когда взяться, решает React
описание пишется руками, вызов за вызовомкомпоненты, состояние и хуки — надстройка над теми же тремя шагами

Правая колонка — почти оглавление того, что ждёт дальше: в этой главе и двух следующих. Но самое дорогое уже позади: описание, узлы и правка разницы — три шага, на которых стоит React, а с ним Preact и Vue, и все три вы только что написали сами.

  • Рендер укладывается в три функции: h собирает описание, create строит по нему узлы, update сравнивает два описания и правит только разницу.
  • Описание — просто данные: пока его не отдали в create, в документе нет ни одного узла.
  • Повторный create — это тот же innerHTML другими руками: новые узлы, пустое поле, каретка в нуле.
  • Ничего специального ради фокуса и каретки писать не нужно: они остаются целы потому, что узел не пересоздан, — в update нет о них ни слова.
  • Обработчик кладётся в свойство узла, а не подписывается заново: addEventListener на каждой правке копит подписки, и одно нажатие срабатывает столько раз, сколько было отрисовок.
  • Дети сверяются по позиции — и здесь рендер ломается: вставка в начало оставляет узлы на местах, а данные съезжают на соседей.

1/4Наш update поменял заголовок карточки. Почему набранный в соседнем поле текст уцелел?

2/4Внутри setProp обработчик вешается через addEventListener. Карточку отрисовали три раза подряд. Что напечатает одно нажатие на кнопку?

3/4В новом описании на месте <h2> оказался <h3>. Что сделает наш update?

4/4Список ["Кофе", "Чай"] стал ["Вода", "Кофе", "Чай"]. В поле первой строки было набрано «две ложки». Где оно окажется после update?

Поле не слушается описания

В карточке живёт поле с ником. Читатель поправил его руками, а потом описание сменилось на «админ» — и поле осталось прежним, хотя атрибут в разметке обновился. Почините setProp, больше ничего не трогая. Подсказка живёт в части II — в разделе «Поля формы: value и checked» статьи «Атрибуты и свойства».

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

Атрибут value задаёт полю начальное значение: после того как в поле что-то ввели, атрибут на него больше не влияет. Значит, такие пропсы нужно класть в свойство узла, а не в атрибут.

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

Теперь вывод другой: getAttribute("value") даёт null — атрибут мы не ставим вовсе, — а nick.value даёт «админ».

Но починка половинчатая. Если описание не изменилось — снова «админ», — а читатель успел напечатать своё, update увидит два одинаковых пропса и поле не тронет: напечатанное останется, хотя описание говорит другое. Наш рендер сверяет прошлое описание с новым и верит, что DOM совпадает с прошлым, а поле ввода — ровно то место, где пользователь меняет DOM в обход описания. Поэтому управляемое поле в React сверяет value не с прошлым описанием, а с живым node.value — и возвращает «админ», даже когда пропс не менялся.

Сколько узлов создаёт правка

Утверждение «update не пересоздаёт узлы» стоит проверить, а не принять на веру. Посчитайте вызовы document.createElement: сколько узлов создаёт первая сборка карточки и сколько — смена одного заголовка. Пока счётчик не подменён, обе строки печатают ноль, так что нулю верьте только после правки.

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

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

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

Получается 3 и 0: три узла на сборку карточки (div, h2, input) и ни одного на смену заголовка — там правился nodeValue существующего текстового узла. Повторный create вместо update дал бы три узла на каждую правку, и так на каждое нажатие клавиши в живом интерфейсе.

Куда уедет текст

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

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

Вывод — ["Кофе: ", "Вода: без сахара"]. Сверка идёт по номеру: на позиции 0 «Кофе» встретился с «Кофе», на позиции 1 «Чай» встретился с «Водой» — тип совпал, поэтому узел строки остался прежним, поменялся только текст, а поле с набранным «без сахара» никто не трогал. Третья строка оказалась лишней, и её убрал последний цикл update.

Обратите внимание, что удалили запись из середины, а пострадала последняя — та, которую не трогали вовсе. Это и есть цена сверки по позиции: удаление в списке ведёт себя как сдвиг всего, что ниже.