Первоначально опубликовано на https://www.developerway.com. На сайте есть еще подобные статьи😉

Атрибут React «key», вероятно, является одной из наиболее часто используемых функций «автопилота» в React 😅 Кто из нас может честно сказать, что использует его из-за «…некоторых уважительных причин», а не «потому что правило eslint пожаловалось на меня». И я подозреваю, что большинство людей, сталкиваясь с вопросом «зачем React нужен атрибут «key»», ответят что-то вроде «э-э… мы должны поместить туда уникальные значения, чтобы React мог распознавать элементы списка, это лучше для производительности». И технически этот ответ правильный. Иногда.

Но что именно означает «распознавать предметы»? Что произойдет, если я пропущу атрибут «ключ»? Взорвется ли приложение? Что, если я положу туда случайную строку? Насколько уникальным должно быть значение? Могу ли я просто использовать там значения индекса массива? Каковы последствия этих выборов? Как именно любой из них влияет на производительность и почему?

Давайте исследовать вместе!

Как работает ключевой атрибут React

Прежде всего, прежде чем перейти к кодированию, давайте разберемся с теорией: что такое атрибут «key» и зачем он нужен React.

Короче говоря, если атрибут ключ присутствует, React использует его как способ идентифицировать элемент того же типа среди своих братьев и сестер во время повторного рендеринга (см. документы: https://reactjs.org/docs/lists). -and-keys.html и https://reactjs.org/docs/reconciliation.html#recursing-on-children). Другими словами, нужен только при повторных рендерах и для соседних однотипных элементов , то есть плоские списки (это важно!).

Упрощенный алгоритм процесса при ререндере выглядит так:

  • сначала React создаст «моментальные снимки» элементов «до» и «после».
  • во-вторых, он попытается идентифицировать те элементы, которые уже существовали на странице, чтобы иметь возможность повторно использовать их вместо того, чтобы создавать их с нуля
    — если атрибут «ключ» существует, он будет считать, что элементы с один и тот же ключ «до» и «после» одинаков
    — если атрибут «ключ» не существует, он просто будет использовать индексы родственного элемента в качестве «ключа» по умолчанию.
  • в-третьих, будет:
    — избавляться от элементов, существовавших на этапе «до», но не существующих на этапе «после» (т. е. размонтировать их)
    — создавать с нуля элементы, которые не не существовало в варианте «до» (т.е. смонтировать их)
    — обновить элементы, существовавшие «до» и продолжающие существовать «после» (т.е. перерендерить их)

Намного легче понять, когда вы немного поиграете с кодом, так что давайте сделаем и это.

Почему случайные «ключевые» атрибуты — плохая идея?

Давайте сначала реализуем список стран. У нас будет компонент Item, который отображает информацию о стране:

const Item = ({ country }) => {
  return (
    <button className="country-item">
      <img src={country.flagUrl} />
      {country.name}
    </button>
  );
};

и компонент CountriesList, который отображает фактический список:

const CountriesList = ({ countries }) => {
  return (
    <div>
      {countries.map((country) => (
        <Item country={country} />
      ))}
    </div>
  );
};

Теперь у меня нет атрибута «ключ» на моих предметах в данный момент. Итак, что произойдет, когда компонент CountriesList повторно отрендерится?

  • React увидит, что там нет «ключа», и вернется к использованию индексов массива countries в качестве ключей.
  • наш массив не изменился, поэтому все элементы будут идентифицированы как «уже существующие», и элементы будут перерисованы

По сути, это ничем не отличается от явного добавления key={index} к Item.

countries.map((country, index) => <Item country={country} key={index} />);

Вкратце: при повторном рендеринге компонента CountriesList каждый Item также будет повторно рендериться. И если мы обернем Item в React.memo, мы даже сможем избавиться от этих ненужных повторных рендеров и улучшить производительность нашего компонента списка.

Теперь самое интересное: что, если вместо индексов мы добавим несколько случайных строк в атрибут «ключ»?

countries.map((country, index) => <Item country={country} key={Math.random()} />);

В этом случае:

  • при каждом повторном рендеринге CountriesList React будет повторно генерировать «ключевые» атрибуты
  • поскольку атрибут «ключ» присутствует, React будет использовать его как способ идентификации «существующих» элементов.
  • поскольку все «ключевые» атрибуты будут новыми, все элементы «до» будут считаться «удаленными», каждый Item будет считаться «новым», а React размонтирует все элементы и снова смонтирует их.

Вкратце: при повторном рендеринге компонента CountriesList каждый Item будет уничтожен и воссоздан с нуля.

И перемонтирование компонентов намного, намного дороже, по сравнению с простым повторным рендерингом, когда мы говорим о производительности. Кроме того, все улучшения производительности, связанные с переносом элементов в React.memo, исчезнут — мемоизация не будет работать, поскольку элементы создаются заново при каждом повторном рендеринге.

Взгляните на приведенные выше примеры в codesandbox. Нажмите на кнопки для повторного рендеринга и обратите внимание на вывод консоли. Немного притормозите свой процессор, и задержка при нажатии на кнопку будет видна даже невооруженным глазом!

Как снизить нагрузку на ЦП

В инструментах разработчика Chrome откройте вкладку «Производительность», щелкните значок «зубчатое колесо» в правом верхнем углу — откроется дополнительная панель с «Дросселирование ЦП» в качестве одного из вариантов.

Почему «индекс» в качестве «ключевого» атрибута — плохая идея

К настоящему моменту должно быть очевидно, зачем нужны стабильные «ключевые» атрибуты, сохраняющиеся между ререндерами. Но как насчет «индекса» массива? Даже в официальной документации они не рекомендуются на том основании, что они могут вызывать ошибки и влиять на производительность. Но что именно происходит, что может привести к таким последствиям, когда мы используем «индекс» вместо какого-то уникального id?

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

const CountriesList = ({ countries }) => {
  // introduce some state
  const [sort, setSort] = useState('asc');
  // sort countries base on state value with lodash orderBy function
  const sortedCountries = orderBy(countries, 'name', sort);
  // add button that toggles state between 'asc' and 'desc'
  const button = <button onClick={() => setSort(sort === 'asc' ? 'desc' : 'asc')}>toggle sorting: {sort}</button>;
  return (
    <div>
      {button}
      {sortedCountries.map((country) => (
        <ItemMemo country={country} />
      ))}
    </div>
  );
};

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

sortedCountries.map((country) => <ItemMemo country={country} key={country.id} />);

и массив index в качестве ключа:

sortedCountries.map((country, index) => <ItemMemo country={country} key={index} />);

И сразу же запомню компонент Item для повышения производительности:

const ItemMemo = React.memo(Item);

Вот codesandbox с полной реализацией. Нажмите на кнопки сортировки с дросселированием ЦП, обратите внимание, что список на основе индекса немного медленнее, и обратите внимание на вывод консоли: в списке на основе индекса каждый элемент перерисовывается при каждом нажатии кнопки, хотя Item запоминается и технически не должен этого делать. Реализация на основе id, точно такая же, как и на основе ключа, за исключением значения ключа, не имеет этой проблемы: никакие элементы не перерисовываются после нажатия кнопки, а вывод консоли чист.

Почему это происходит? Секрет, конечно, в «ключевом» значении:

  • React генерирует список элементов «до» и «после» и пытается идентифицировать элементы, которые «одинаковы».
  • с точки зрения React, «одинаковые» элементы — это элементы с одинаковыми ключами.
  • в «индексной» реализации первый элемент в массиве всегда будет иметь key="0", второй — key="1" и т. д. и т. д. — независимо от сортировки массива

Итак, когда React выполняет сравнение, когда он видит элемент с key="0" в списках «до» и «после», он думает, что это точно такой же элемент, только с другим значением реквизита: значение country изменилось после того, как мы изменили его. массив. И поэтому он делает то, что должен делать для того же элемента: запускает цикл повторного рендеринга. И поскольку он считает, что значение реквизита country изменилось, он пропустит функцию запоминания и вызовет повторную визуализацию фактического элемента.

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

Это поведение будет особенно заметно, если мы добавим некоторое состояние в компонент Item. Давайте, например, изменим его фон при нажатии:

const Item = ({ country }) => {
  // add some state to capture whether the item is active or not
  const [isActive, setIsActive] = useState(false);
  // when the button is clicked - toggle the state
  return (
    <button className={`country-item ${isActive ? 'active' : ''}`} onClick={() => setIsActive(!isActive)}>
      <img src={country.flagUrl} />
      {country.name}
    </button>
  );
};

Взгляните на тот же codeandbox, только на этот раз нажмите сначала на несколько стран, чтобы вызвать изменение фона, и только потом нажмите кнопку сортировать.

Список на основе идентификаторов ведет себя именно так, как вы ожидаете. Но список на основе индекса теперь ведет себя забавно: если я нажму на первый элемент в списке, а затем нажму сортировку — первый элемент останется выбранным, независимо от сортировки. И это симптом поведения, описанного выше: React считает, что элемент с key="0" (первый элемент в массиве) точно такой же до и после изменения состояния, поэтому он повторно использует один и тот же экземпляр компонента, сохраняет состояние как это было (т.е. isActive установлено на true для этого элемента), и просто обновляет значения реквизита (от первой страны до последней страны).

И ровно то же самое будет, если вместо сортировки мы добавим элемент в начало массива: React будет думать, что элемент с key="0" (первый элемент) остается прежним, а последний элемент — новым. Таким образом, если выбран первый элемент, в списке на основе индекса выбор останется на первом элементе, каждый элемент будет повторно отображаться, и даже для последнего элемента будет активировано монтирование. В списке на основе id будет монтироваться и рендериться только вновь добавленный элемент, остальные будут сидеть там спокойно. Проверьте это в codesandbox. Нагрузите свой процессор, и задержка добавления нового элемента в список на основе индекса снова будет видна невооруженным глазом! Список на основе идентификаторов невероятно быстр даже с 6-кратным дросселем процессора.

Почему «индекс» как «ключевой» атрибут — хорошая идея

После предыдущих разделов легко сказать: «Всегда используйте уникальный элемент id для атрибута 'key'», не так ли? И в большинстве случаев это правда, и если вы используете id все время, никто, вероятно, не заметит и не возражает. Но когда у вас есть знания, у вас есть сверхспособности. Теперь, когда мы знаем, что именно происходит, когда React отображает списки, мы можем обмануть и сделать некоторые списки даже быстрее с помощью index вместо id.

Типичный сценарий: разбитый на страницы список. У вас есть ограниченное количество элементов в списке, вы нажимаете кнопку — и хотите отобразить разные элементы одного типа в списке одного размера. Если вы выберете подход key="id", то каждый раз, когда вы меняете страницу, вы будете загружать совершенно новый набор элементов с совершенно разными идентификаторами. Это означает, что React не сможет найти какие-либо «существующие» элементы, размонтировать весь список и смонтировать совершенно новый набор элементов. Но! Если вы выберете подход key="index", React подумает, что все элементы на новой «странице» уже существуют, и просто обновит эти элементы свежими данными, оставив фактические компоненты смонтированными. Это будет заметно быстрее даже на относительно небольших наборах данных, если компоненты элементов данных сложны.

Взгляните на этот пример в codesandbox. Обратите внимание на вывод консоли — когда вы переключаете страницы в списке на основе id справа, каждый элемент перемонтируется. Но в индексном списке слева элементы только перерисовываются. Намного быстрее! При дросселированном процессоре, даже с очень простым списком из 50 элементов (только текст и изображение), разница между переключением страниц в списке на основе id и списке на основе index уже видна.

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

Все ключи на своих местах!

Это все на сегодня! Надеюсь, вам понравилось прочитанное, и теперь вы лучше понимаете, как работает «ключевой» атрибут React, как его правильно использовать и даже как подчинить его правила своей воле и обмануть свой путь в игре производительности.

Несколько ключевых выводов, которые нужно оставить:

  • никогда не используйте случайное значение в атрибуте «key»: это приведет к тому, что элемент будет перемонтироваться при каждом рендеринге. Если, конечно, это не ваше намерение
  • нет ничего плохого в том, чтобы использовать индекс массива в качестве «ключа» в «статических» списках — тех, у которых номер и порядок элементов остаются прежними
  • использовать уникальный идентификатор элемента («id») в качестве «ключа», когда список может быть пересортирован или элементы могут быть добавлены в случайных местах.
  • вы можете использовать индекс массива как «ключ» для динамических списков с элементами без сохранения состояния, где элементы заменяются новыми — списки с разбивкой на страницы, результаты поиска и автозаполнения и тому подобное. Это улучшит производительность списка.

Хорошего дня, и пусть ваши элементы списка никогда не будут перерисовываться, если вы явно не сказали им об этом! ✌🏼

Первоначально опубликовано на https://www.developerway.com. На сайте есть еще подобные статьи😉

Подпишитесь на информационный бюллетень, подпишитесь на LinkedIn или подпишитесь на Twitter, чтобы получать уведомления, как только выйдет следующая статья.