Своя сверка: что на самом деле делает key

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

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

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

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

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

Чтобы дальше менять ровно одну вещь, сверка детей вынесена из update в отдельную функцию syncChildren: update теперь зовёт её, а внутри — тот же цикл по номерам, что стоял в нём раньше. И ещё одна мелочь: в описании строки появился пропс key. Сверка на него пока не смотрит.

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

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

Вторая строка вывода — ["Вода: —", "Кофе: с молоком", "Чай: —"]: черновик достался «Кофе». Ни один узел при этом не пересоздан, и update отработал безупречно по своим правилам: на месте 1 стоял li — li и остался, у него сменился только текст. Переехали данные, а узлы остались на местах — и поле со всем набранным стоит там, где стояло.

Третья строка — false. Пропс в описании есть, атрибута в разметке нет, и так задумано: ключ нужен сверке, а не браузеру.

Корень беды виден в вопросе, который задаёт цикл. «Кто стоял на этом месте» — вопрос про место. Нужен вопрос про запись, и ответ на него в описании уже лежит.

Единственная правка в create по сравнению со статьёй про свой рендер: пропс key не переносится в атрибуты. React поступает так же — ключ он забирает себе, и до узла страницы тот не доезжает.

Раз метка уже в описании, сверке остаётся ею воспользоваться. Обход по номерам заменяется поиском: разложить старых детей по ключам, а потом для каждого нового описания достать своего. Всё остальное — h, create, проверка типа, правка атрибутов — не меняется вовсе.

Одна строка в keyOf важнее всей остальной функции: если ключа в описании нет, ключом становится номер. Это не заглушка на скорую руку, а точная копия поведения React — к ней вернёмся в следующем разделе.

Сценарий тот же, до последней запятой. Прогноз: где теперь окажется черновик и какой из трёх li будет тем же самым узлом, что стоял вторым до сортировки?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
["Вода: —", "Кофе: —", "Чай: с молоком"] — черновик остался у «Чая». А вторая строка объясняет как: третий li — тот же самый объект, что стоял вторым. Его не пересоздали и не переписали, его переставили. Работает это потому, что append для узла, уже лежащего в документе, не копирует его, а переносит: со старого места узел уходит сам.

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

Ключи берутся из before.children — из прошлого описания, которое update и так держит в руках. Хранить их в разметке не только незачем, но и негде: ребёнком бывает текстовый узел, а у него атрибутов нет вовсе. Сверка обязана уметь и такие — отсюда запасной номер в keyOf.

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

Вернёмся к запасному номеру из keyOf. Сверка теперь по ключу, но описание может прийти и без него. Проверим, что из этого выходит: сверка та же самая, что в прошлом разделе, изменилось только описание — key из него убран. Прогноз: у кого окажется черновик?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
["Вода: —", "Кофе: с молоком", "Чай: —"] — вывод первого раздела, слово в слово. Сверка по ключу с ключом-номером — это и есть сверка по позиции, просто записанная через карту: номер описывает место, а не запись.

Теперь настоящий React: тот же список, та же сортировка, по-прежнему никакого состояния — черновик живёт прямо в поле. Перерисовку делает второй вызов render у того же корня, ключа нет вовсе. Прогноз тот же, плюс ещё один вопрос: что, кроме двух строк, окажется в выводе?

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

Первой строкой приходит предупреждение: Each child in a list should have a unique "key" prop. Дальше — ["Вода: —", "Кофе: с молоком", "Чай: —"] и true: прежним узлом остался тот, что стоит на втором месте, а не тот, что показывает ту же запись. Результат нашей сверки без ключа, строка в строку.

Прочитайте предупреждение глазами сверки. Оно говорит не «вы забыли атрибут», а «вы не сказали, где чья запись, — беру номер». key={index} даёт тот же номер, только молча: предупреждение гаснет, а сверка остаётся по месту. Фраза «такой ключ глушит предупреждение и не чинит ошибку» из прошлой статьи — это одна строка в keyOf.

Что стоит в keyЧто вернёт keyOfКакой узел найдёт сверка
ключа нетномер с решёткой: "#1"тот, что стоял на том же месте; React ещё и предупредит
key={index}тот же номер без решётки: "1"тот же узел на том же месте — молча
key={item.id}"b"узел той же записи, где бы она теперь ни стояла
key={Math.random()}каждый раз новое числоникакой: все строки строятся заново, старые удаляются

Решётка в keyOf — не украшение: без неё номер 1 совпал бы с настоящим ключом "1". React разводит их так же — номер у него число, а ключ всегда строка. Поэтому переход от списка без ключей к key={index} пересоздаёт все строки, хотя адрес и там и там — номер.

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

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

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

Предупреждения больше нет, а прежний узел теперь третий: «Чай» переехал вниз вместе с полем и черновиком. Ровно то, что выдала наша карта.

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

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

У нас 3, у React 2 — а хватило бы одной: поднять «Воду» наверх, и «Кофе» с «Чаем» сами окажутся под ней. Наша сверка переносит всех без разбора. React идёт по новому списку и помнит самое дальнее старое место среди узлов, которые он уже оставил. «Вода» стояла третьей — её он оставляет. «Кофе» и «Чай» стояли выше неё, значит, их он переносит за неё. Два переноса вместо одного — цена простого правила: сверка идёт по списку один раз и не заглядывает вперёд.

И ни одно из этих чисел не входит в договор. Черновик в обоих списках остался у «Чая», потому что обе сверки держат одно и то же обещание, а сколько раз при этом тронут DOM — дело алгоритма. React вправе переписать его в следующей версии, и опираться поэтому можно только на договор.

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

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

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

Договор работает и в обратную сторону: ключ поменяли нарочно — сверка обязана выбросить старый узел и построить новый. Это законный приём, им сбрасывают форму или компонент целиком; отдельный разговор о нём — в статье «Когда состояние сохраняется, а когда сбрасывается».

У договора есть условие: ключи уникальны среди соседей. Что бывает, когда это неправда, наша миниатюра отвечает буквально — у неё внутри Map. Две записи с одинаковым id, данные между сверками не меняются. Прогноз: сколько строк окажется в списке после второй сверки и что в них будет написано?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера
разбор вывода
["Кофе: —", "Кофе: —", "Чай: —"] — три строки на две записи, и «Кофе» среди них дважды. Разбор по шагам. Карта складывает оба узла под ключом a, и второй перезаписывает первый. Первое новое описание находит по ключу узел второй строки, правит в нём подпись на «Кофе» и переставляет в конец. Второму описанию искать уже нечего — ключ из карты удалён, — и оно строит себе новый узел. А узел первой строки никто не тронул и никто не убрал: он так и стоит с прежней подписью.

У React на этот случай есть предупреждение, и формулировка в нём необычно откровенная.

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

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

ЧтоНаша сверкаReact
Поиск ребёнкакарта строится всегдасначала параллельный проход, карта — с первого расхождения
Перестановкаappend на каждого ребёнкапереносит тех, кто стоял выше уже оставленного узла; не всегда минимум
Фокус после переносатеряетсявозвращает в поле сам после правок
Тип узласравниваем имя тегатег или сама функция компонента
Номер вместо ключа"#" + iчисло — со строкой-ключом не совпадёт
Дубликат ключатихо: лишняя строкапредупреждение, поведение не определено
Что теряется с узломзначение поля, каретка, прокруткато же плюс своё: useState, эффекты, ссылки

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

  • Сверка отвечает на один вопрос: какой из уже стоящих узлов соответствует какому новому описанию. Всё остальное поведение списка — следствия этого ответа.
  • Ответ по номеру ребёнка ломается на любой перестановке: узел переживает отрисовку, но достаётся чужой записи вместе с кареткой и набранным текстом.
  • Ответ по ключу — поиск в карте из прошлого описания: узел находится по метке из данных, append переносит его на новое место, а узел, чьего ключа не стало, удаляется.
  • Без ключа ключом становится номер. Предупреждение React значит «беру номер», а key={index} даёт тот же номер молча — предупреждение гаснет, сверка остаётся по месту.
  • Ключ обязан быть уникальным среди соседей и неизменным между отрисовками, и ставится он на то, что лежит в массиве, а не внутри компонента.
  • React обещает договор, а не алгоритм: тот же ключ и тот же тип — тот же узел. Сколько переносов он сделает, версия вправе менять; на дубликатах обещание снимается прямым текстом.

1/4Список отсортировали. Сверка по позиции оставила на месте узел второй строки и сменила в нём подпись. Что стало с текстом, который был набран в поле этой строки?

2/4Список всегда рисуется одним и тем же способом. Чем для сверки key={index} отличается от полного отсутствия key?

3/4На одних и тех же данных наша сверка по ключу сделала три вставки в список, а React — две. Что из этого следует?

4/4У двух соседей в списке совпал ключ. Что гарантирует React?

Удалённая строка, которая не ушла

В этой syncChildren потеряна последняя строка — та, что убирает детей, чьих ключей не осталось. Из данных удаляют «Чай», черновик набран в «Воде». Предскажите вывод целиком: не только сколько строк останется, но и в каком порядке они встанут. Потом запустите и почините сверку.

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

Печатается ["Чай: —", "Кофе: —", "Вода: холодная"]. Узел удалённой записи никто не убрал, поэтому строк по-прежнему три. А наверх он всплыл сам: append переносит узел в конец, и оба уцелевших ребёнка сверка по очереди переставила в конец — «Чай», к которому она не притронулась, остался там, где стоял, то есть перед ними.

Чинится одной строкой в конце функции:

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

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

Ключ, который меняется

Ключом здесь взято название записи: оно уникально и читается лучше, чем id. Список не сортируют и не пополняют — одну запись переименовывают. Предскажите вывод, запустите и объясните, почему сверка по ключу в этот раз проиграла даже сверке по позиции.

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

Печатается ["Кофе: —", "Чай зелёный: —", "Вода: —"] — черновика нет нигде. Ключ строки был «Чай», стал «Чай зелёный»: для сверки это не переименование, а исчезновение одной записи и появление другой. Узел с полем удаляется, на его место строится новый.

Сверка по позиции здесь как раз справилась бы: место у записи не менялось. Но это удача, а не свойство приёма, — стоит добавить сортировку, и она проиграет. Чинится ключом из того, что не редактируют:

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

Требований к ключу два, и второе забывают чаще первого: он уникален среди соседей и не меняется между отрисовками. Название — поле, которое правят; ключ из него держится ровно до первой правки.

Ключ не на том элементе

Здесь настоящий React, ключ есть и берётся из данных, но предупреждение в консоли всё равно появляется, а черновик после сортировки не уезжает к соседу — он пропадает совсем. Предскажите вывод, запустите и объясните оба следствия, прежде чем смотреть решение.

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

Печатается ["Вода: —", "Кофе: —", "Чай: —"]: черновика нет нигде. Следствий два, и причины у них разные.

Первое: в массиве лежат элементы <Row />, и ключа у них нет — отсюда предупреждение и сверка компонентов по номеру. Второе: у единственного ребёнка Row ключ как раз есть. У Row на втором месте ребёнок был с ключом b, а стал с ключом a — другой ключ означает другой узел, поэтому старый li удаляется вместе с полем и черновиком.

Выходит, что ключ не на том уровне хуже, чем никакого: без ключа черновик хотя бы уезжает к соседу, а здесь он уничтожается. Ключ ставится на то, что лежит в массиве:

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

А внутри Row он не нужен: там нет ни массива, ни соседей, среди которых пришлось бы различать.