Keys in React. Готовим правильно
Сегодня поговорим об атрибуте key в React. Часто разработчики, которые только начинают использовать React, не придают большого значения атрибуту key. А зря…

Что говорит уточка, когда узнала, что ты не используешь key
Чтобы представить работу ключей полностью и с различными кейсами, рассмотрим план:
- Reconciliation
- Реиспользование ключей и нормализация
- Использование key при рендере одного элемента
- Работа с ключами при передаче компоненту children
Так как материала немало, в конце каждой части будут выводы. В конце статьи также приведен общий вывод и кратко описаны тезисы. Код можно будет смотреть как на примерах в codesandbox, так и под спойлерами.
Reconciliation
Главная задача ключей в реакте — помогать механизму reconciliation. Давайте создадим небольшой компонент, который будет рендерить список имен:
import React from "react";
import { render } from "react-dom";
class App extends React.Component {
state = {
names: ["Миша", "Даниил", "Марина"]
};
render() {
return ;
}
}
class Names extends React.PureComponent {
render() {
return ({this.props.names.map(name => {name} )}
);
}
}
class Name extends React.PureComponent {
render() {
return ({this.props.children} );
}
}
render( , document.getElementById("root"));
Мы не указали ни одного key. В консоли увидим сообщение:
Warning: Each child in an array or iterator should have a unique «key» prop.
Теперь усложним задачу и создадим инпут с кнопкой добавления нового имени в начало и в конец. Кроме этого, в componentDidUpdate и DidMount для Name компонента добавим логирование изменений, с указанием children:
import React, { Component, PureComponent, Fragment } from "react";
import { render } from "react-dom";
class App extends Component {
state = {
names: ["Миша", "Даниил", "Марина"]
};
addTop = name => {
this.setState(state => ({
names: [name, ...state.names]
}));
};
addBottom = name => {
this.setState(state => ({
names: [...state.names, name]
}));
};
render() {
return (
);
}
}
class AddName extends PureComponent {
getInput = el => {
this.input = el;
};
addToTop = () => {
if (!this.input.value.trim()) {
return;
}
this.props.addTop(this.input.value);
this.input.value = "";
};
addToBottom = () => {
if (!this.input.value.trim()) {
return;
}
this.props.addBottom(this.input.value);
this.input.value = "";
};
render() {
return (
);
}
}
class Names extends PureComponent {
render() {
return {this.props.names.map(name => {name} )}
;
}
}
class Name extends PureComponent {
componentDidMount() {
console.log(`Mounted with ${this.props.children}`);
}
componentDidUpdate(prevProps) {
console.log(`Updated from ${prevProps.children} to ${this.props.children}`);
}
render() {
return {this.props.children} ;
}
}
render( , document.getElementById("root"));Попробуйте добавить «Василий» в конец списка, а затем «Павел» в начало. Обратите внимание на консоль. Codesandbox позволяет также открыть исходный код, нажав на кнопки изменения отображения (сверху по центру).
Демонстрация работы подобного списка:
При добавлении элемента сверху получаем ситуацию, когда компоненты Name будут перерисованы и создан новый компонент с children === Василий:
Updated from Миша to Павел
Updated from Даниил to Миша
Updated from Марина to Даниил
Updated from Василий to Марина
Mounted with Василий
Почему так происходит? Давайте посмотрим на механизм reconciliation.
Полная сверка и приведение одного дерева к другому — дорогая задача с алгоритмической сложностью O (n³). Это значит — при больших количествах элементов реакт был бы медленным.
По этой причине механизм сверки VDOM работает с использованием следующих упрощений (правил):
1) Два элемента разных типов продьюсят разные поддеревья — значит, при смене типа элемента с Аналогично работает и с React-компонентами: 2) Массивы элементов сравниваются поэлементно, т. е. реакт одновременно итерируется по двум массивам и сравнивает элементы попарно. Поэтому мы и получили перерисовку всех элементов в списке в примере с именами выше. Разберем на примере: Реакт вначале сверит В случае же, когда добавляем элемент вверх: Реакт сравнит Для решения проблемы в реакте используют атрибуты key. При добавлении key реакт будет сравнивать элементы не друг за другом, а будет искать по значению ключа. Пример с рендерингом имен станет более производительным: Реакт найдет Перепишем наш пример, добавив ключи. Обратите внимание, что теперь при добавлении элементов вверх списка происходит создание только одного компонента: Замечу, что мы добавили id к нашим именам и управляем keys напрямую, не используя индекс элемента в массиве как key. Это обусловлено тем, что при добавлении имени в верх списка индексы поедут. Подведем итог первой части: Ключи оптимизируют работу с элементами массивов, уменьшают количество ненужных удалений и созданий элементов. Реиспользование ключей и нормализация Усложним задачу. Теперь создадим список не абстрактных людей, а список людей — членов команды разработки. В компании две команды. Члена команды можно выделить по клику мышки. Попробуем решить задачу «в лоб». Попробуйте выделять людей и переключаться между командами: Заметим неприятную особенность: если выбрать человека и затем переключить команду, произойдет анимирование снятия выделения, хотя человек в другой команде никогда мог и не быть выделен. Вот яркий пример на видео: При реиспользовании ключей, где не надо, мы можем получить сайд-эффекты, так как реакт будет обновлять, а не удалять и создавать новые компоненты. Так происходит, потому что мы использовали идентичные ключи для разных людей. И поэтому реакт реиспользует элементы, хотя в примере это не нужно. Кроме того, добавление новых людей порождает сложный код. В приведенном коде есть пара проблем: Решить проблему можно двумя путями. Простым решением будет создать составной ключ для разработчиков в формате: Но проблему можно решить комплексно, нормализовав данные и объединив сущности. Значит, в state компонента будет 2 поля: Теперь элементы обрабатываются корректно: Нормализация данных упрощает взаимодействие с дата-слоем приложения, упрощает структуру и уменьшает сложность. Например, сравните функцию toggle с нормализованными и ненормализованными данными. Hint: Если бекенд или api отдает данные в ненормализованном формате, нормализовать их можно с помощью — https://github.com/paularmstrong/normalizr Подведем итог второй части: При использовании ключей важно понимать, что при смене данных ключи должны меняться. Яркий пример ошибок, которые я встречал во время ревью, использование индекса элемента в массиве как Нормализация данных и/или составные Использование key при рендере одного элемента Как мы обсуждали, реакт при отсутствии Разберем другой пример — нотификации. Положим, что нотификация может быть только одна в конкретный период времени, отображается в течение нескольких секунд и исчезает. Такую нотификацию реализовать просто: компонент, который по Да, в этом компоненте нет обратной связи, он не вызывает никаких Мы получили простой компонент. Представим ситуацию — при клике на кнопку отображается подобная нотификация. Пользователь кликает на кнопку не переставая, но уже через три секунды нотификации добавится класс Чтобы исправить работу компонента без использования Здесь мы получили намного больше кода, который перезапускает таймаут, если контент нотификации изменился, и убирает класс Но решить задачу можно, и используя наш первый компонент Таким образом В редких случаях использование Работа с ключами при передаче компоненту children Интересная особенность Кроме того, в консоли будет warning: Но, если компоненту передали Консоль выведет: Подытожим: Получить доступ к Мы рассмотрели области применения key, как он передается в компонент, каким образом изменяется механизм reconciliation с заданием key и без него. А также посмотрели на использование key для элементов, которые являются единственным child. Сгруппируем в конце основные тезисы: Без Добавляя Следите, чтобы не появлялись дублирующие В редких случаях или другой тег реакт считает поддеревья внутри разными. Реакт удаляет элементы, которые были внутри div, и маунтит все элементы внутри section. Даже если поменялся только сам тег. Аналогичная ситуация удаления-инициализации дерева происходит при смене одного реакт-компонента на другой, хотя само содержимое, казалось бы, остается прежним (но это только заблуждение).
oldTree:
// В этом примере будет выведено:
// did mount
// will unmount
// did mount
// То есть при смене родителя, MyComponent вначале будет удален,
// а затем создан новый инстанс MyComponent.
class MyComponent extends PureComponent {
componentDidMount() {
console.log("did mount");
}
componentDidUpdate() {
console.log("did update");
}
componentWillUnmount() {
console.log("will unmount");
}
render() {
return // oldTree
// newTree
друг с другом, затем и в конце обнаружит, что нет в старом дереве. И создаст этот элемент.// oldTree
// newTree
с — обновит его. Затем сравнит с — обновит его и в конце создаст . При вставке элемента в начало реакт обновит все элементы в массиве. // oldTree
// newTree
key='1', key='2', определит, что c ними не произошло изменений, и затем найдет новый элемент и добавит только его. Следовательно, с ключами реакт обновит только один компонент. import React, { Component, PureComponent, Fragment } from "react";
import { render } from "react-dom";
import "./style.css";
class App extends Component {
state = {
active: 1,
teams: [
{
id: 1,
name: "Amazing Team",
developers: [
{ id: 1, name: "Миша" },
{ id: 2, name: "Екатерина" },
{ id: 3, name: "Валерий" }
]
},
{
id: 2,
name: "Another Team",
developers: [
{ id: 1, name: "Саша" },
{ id: 2, name: "Даниил" },
{ id: 3, name: "Марина" }
]
}
]
};
addTop = name => {
this.setState(state => ({
teams: state.teams.map(
team =>
team.id === state.active
? {
...team,
developers: [
{ id: team.developers.length + 1, name },
...team.developers
]
}
: team
)
}));
};
addBottom = name => {
this.setState(state => ({
teams: state.teams.map(
team =>
team.id === state.active
? {
...team,
developers: [
...team.developers,
{ id: team.developers.length + 1, name }
]
}
: team
)
}));
};
toggle = id => {
this.setState(state => ({
teams: state.teams.map(
team =>
team.id === state.active
? {
...team,
developers: team.developers.map(
developer =>
developer.id === id
? { ...developer, highlighted: !developer.highlighted }
: developer
)
}
: team
)
}));
};
switchTeam = id => {
this.setState({ active: id });
};
render() {
return (
{this.props.teams.map(team => (
);
}
}
class AddName extends PureComponent {
getInput = el => {
this.input = el;
};
addToTop = () => {
if (!this.input.value.trim()) {
return;
}
this.props.addTop(this.input.value);
this.input.value = "";
};
addToBottom = () => {
if (!this.input.value.trim()) {
return;
}
this.props.addBottom(this.input.value);
this.input.value = "";
};
render() {
return (
{this.props.names.map(user => (
);
}
}
class Name extends PureComponent {
render() {
return (
${id команды}.${id разработчика}, это позволит не пересекаться ключам и избавиться от сайд-эффектов. teams, developers. developers будут содержать мапу id + name, teams будут иметь список разработчиков, которые находятся в команде. Реализуем это решение: class App extends Component {
state = {
active: 1,
nextId: 3,
developers: {
"1": { name: "Миша" },
"2": { name: "Саша" },
},
teams: [
{
id: 1,
name: "Amazing Team",
developers: [1]
},
{
id: 2,
name: "Another Team",
developers: [2]
}
]
};
addTop = name => {...};
addBottom = name => {...}
toggle = id => {
this.setState(state => ({
developers: {
...state.developers,
[id]: {
...state.developers[id],
highlighted: !state.developers[id].highlighted
}
}
}));
};
switchTeam = id => {...};
render() {
// При реальной разработке вычисление списка людей лучше мемоизировать или вынести в computed value и хранить в state
return (
import React, { Component, PureComponent, Fragment } from "react";
import { render } from "react-dom";
import "./style.css";
class App extends Component {
state = {
active: 1,
nextId: 7,
developers: {
"1": { name: "Миша" },
"2": { name: "Екатерина" },
"3": { name: "Валерий" },
"4": { name: "Саша" },
"5": { name: "Даниил" },
"6": { name: "Марина" }
},
teams: [
{
id: 1,
name: "Amazing Team",
developers: [1, 2, 3]
},
{
id: 2,
name: "Another Team",
developers: [4, 5, 6]
}
]
};
addTop = name => {
this.setState(state => ({
developers: { ...state.developers, [state.nextId]: { name } },
nextId: state.nextId + 1,
teams: state.teams.map(
team =>
team.id === state.active
? {
...team,
developers: [state.nextId, ...team.developers]
}
: team
)
}));
};
addBottom = name => {
this.setState(state => ({
// установку developers и nextId можно вынести в отдельную функцию, чтобы не дублировать код
developers: { ...state.developers, [state.nextId]: { name } },
nextId: state.nextId + 1,
teams: state.teams.map(
team =>
team.id === state.active
? {
...team,
developers: [...team.developers, state.nextId]
}
: team
)
}));
};
toggle = id => {
this.setState(state => ({
developers: {
...state.developers,
[id]: {
...state.developers[id],
highlighted: !state.developers[id].highlighted
}
}
}));
};
switchTeam = id => {
this.setState({ active: id });
};
render() {
// При реальной разработке вычисление списка людей лучше мемоизировать или вынести в computed value и хранить в state
return (
{this.props.teams.map(team => (
);
}
}
class AddName extends PureComponent {
getInput = el => {
this.input = el;
};
addToTop = () => {
if (!this.input.value.trim()) {
return;
}
this.props.addTop(this.input.value);
this.input.value = "";
};
addToBottom = () => {
if (!this.input.value.trim()) {
return;
}
this.props.addBottom(this.input.value);
this.input.value = "";
};
render() {
return (
{this.props.users.map(user => (
);
}
}
class Name extends PureComponent {
render() {
return (
key. Это приводит к сайд-эффектам типа того, что мы рассматривали на примере отображения списка людей с выделением. key позволяют добиться нужного эффекта: key больше не существует.key сравнивает элементы старого и нового дерева попарно. При наличии ключей — ищет в списке children нужный элемент с заданным ключом. Случай, когда children состоит только из одного элемента, не исключение из правила.componentDidMount ставит счетчик, по окончании счетчика анимирует скрытие нотификации, например: class Notification1 extends PureComponent {
componentDidMount() {
setTimeout(() => {
this.element && this.element.classList.add("notification_hide");
}, 3000);
}
render() {
return (
onClose, но для этой задачи не важно. notification_hide, и она станет невидимой пользователю (если мы не использовали key).key, создадим класс Notification2, который с помощью lifeCycle-методов будет корректно обновляться: class Notification2 extends PureComponent {
componentDidMount() {
this.subscribeTimeout();
}
componentWillReceiveProps(nextProps) {
if (nextProps.children !== this.props.children) {
clearTimeout(this.timeout);
}
}
componentDidUpdate(prevProps) {
if (prevProps.children !== this.props.children) {
this.element.classList.remove("notification_hide");
this.subscribeTimeout();
}
}
subscribeTimeout() {
this.timeout = setTimeout(() => {
this.element.classList.add("notification_hide");
}, 3000);
}
render() {
return (
notification_hide при обновлении данных.Notification1 и атрибут key. У каждой нотификации есть свой уникальный id, которым мы воспользуемся как key. Eсли при изменении нотификации изменяется key, то Notification1 будет пересоздаваться. Компонент будет соответствовать нужной бизнес-логике: key при рендере одного компонента оправданно. key — очень мощный способ «помогать» механизму reconciliation понимать, нужно ли сравнивать компоненты или стоит сразу же (пере)создать новый.key — он недоступен в самом компоненте. Это происходит потому, что key — special prop. В React существует 2 специальных props: key и ref: class TestKey extends Component {
render() {
// Выведет в консоли null
console.log(this.props.key);
// div будет пустым
return Warning: TestKey:
key is not a prop. Trying to access it will result in undefined being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://fb.me/react-special-props)children, у которых есть key, вы сможете с ними взаимодействовать, но поле key будет не внутри объекта props, а на уровне самого компонента: class TestKey extends Component {
render() {
console.log(this.props.key);
return 1
prop1
2
prop2
3
prop3
10
prop10key и ref — special props в реакте. Они не включаются в объект props и недоступны внутри самого компонента. child.key или child.ref можно из родительского компонента, которому передали children, но делать этого не нужно. Практически нет ситуаций, когда это нужно. Всегда можно решить задачу проще и лучше. В случае если нужен key для обработки в компоненте — задублируйте его, например в prop id.key механизм reconciliation сверяет компоненты попарно между текущим и новым VDOM. Из-за этого может происходить большое количество лишних перерисовок интерфейса, что замедляет работу приложения.key, вы помогаете механизму reconciliation тем, что с key он сверяет не попарно, а ищет компоненты с тем же key (тег / имя компонента при этом учитывается) — это уменьшает количество перерисовок интерфейса. Обновлены/добавлены будут только те элементы, которые были изменены/не встречались в предыдущем дереве.key, при переключении отображения у новых данных не совпадали ключи. Это может привести к нежелательным сайд-эффектам, таким как анимации, или некорректной логике поведения элемента. key используют и для одного элемента. Это сокращает размер кода и упрощает понимание. Но область применения этого подхода ограничена.key и ref — специальные props. Они недоступны в компоненте, их нет в child.props. Можно получить доступ в родителе через child.key, но реальных областей применения для этого практически нет. Если в дочерних компонентах нужен key — правильным решением будет задублировать в prop id, например.
