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


image
Что говорит уточка, когда узнала, что ты не используешь key


Чтобы представить работу ключей полностью и с различными кейсами, рассмотрим план:


  1. Reconciliation
  2. Реиспользование ключей и нормализация
  3. Использование key при рендере одного элемента
  4. Работа с ключами при передаче компоненту children

Так как материала немало, в конце каждой части будут выводы. В конце статьи также приведен общий вывод и кратко описаны тезисы. Код можно будет смотреть как на примерах в codesandbox, так и под спойлерами.




Reconciliation


Главная задача ключей в реакте — помогать механизму reconciliation. Давайте создадим небольшой компонент, который будет рендерить список имен:


import React from "react";
import { render } from "react-dom";

class App extends React.Component {
  state = {
    names: ["Миша", "Даниил", "Марина"]
  };
  render() {
    return <Names names={this.state.names} />;
  }
}

class Names extends React.PureComponent {
  render() {
    return (<ul>{this.props.names.map(name => <Name>{name}</Name>)}</ul>);
  }
}

class Name extends React.PureComponent {
  render() {
    return (<li>{this.props.children}</li>);
  }
}

render(<App />, 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 (
      <Fragment>
        <Names names={this.state.names} />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

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 (
      <Fragment>
        <input ref={this.getInput} />
        <button onClick={this.addToTop}>Add to TOP</button>
        <button onClick={this.addToBottom}>Add to BOTTOM</button>
      </Fragment>
    );
  }
}

class Names extends PureComponent {
  render() {
    return <ul>{this.props.names.map(name => <Name>{name}</Name>)}</ul>;
  }
}

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 <li>{this.props.children}</li>;
  }
}

render(<App />, 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) Два элемента разных типов продьюсят разные поддеревья — значит, при смене типа элемента с <div> на <section> или другой тег реакт считает поддеревья внутри <div> и <section> разными. Реакт удаляет элементы, которые были внутри div, и маунтит все элементы внутри section. Даже если поменялся только сам тег. Аналогичная ситуация удаления-инициализации дерева происходит при смене одного реакт-компонента на другой, хотя само содержимое, казалось бы, остается прежним (но это только заблуждение).


oldTree:
<div>
  <MyComponent />
</div>

// MyComponent будет удален и создан заново

newTree:
<section>
  <MyComponent />
</section>

Аналогично работает и с React-компонентами:


// В этом примере будет выведено:
// 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 <div>123</div>;
  }
}

class A extends Component {
  render() {
    return <MyComponent />;
  }
}

class B extends Component {
  render() {
    return <MyComponent />;
  }
}

class App extends Component {
  state = {
    test: A
  };
  componentDidMount() {
    this.setState({test: B});
  }
  render() {
    var Component = this.state.test;
    return (
        <Component />
    );
  }
}

render(<App />, document.getElementById("root"));

2) Массивы элементов сравниваются поэлементно, т. е. реакт одновременно итерируется по двум массивам и сравнивает элементы попарно. Поэтому мы и получили перерисовку всех элементов в списке в примере с именами выше. Разберем на примере:


// oldTree
<ul>
  <li>Паша</li>
  <li>Саша</li>
</ul>

// newTree
<ul>
  <li>Паша</li>
  <li>Саша</li>
  <li>Гриша</li>
</ul>

Реакт вначале сверит <li>Паша</li> друг с другом, затем <li>Саша</li> и в конце обнаружит, что <li>Гриша</li> нет в старом дереве. И создаст этот элемент.


В случае же, когда добавляем элемент вверх:


// oldTree
<ul>
  <li>Паша</li>
  <li>Саша</li>
</ul>

// newTree
<ul>
  <li>Гриша</li>
  <li>Паша</li>
  <li>Саша</li>
</ul>

Реакт сравнит <li>Паша</li> с <li>Гриша</li> ?— ?обновит его. Затем сравнит <li>Саша</li> с <li>Паша</li>? —? обновит его и в конце создаст <li>Саша</li>. При вставке элемента в начало реакт обновит все элементы в массиве.




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


// oldTree
<ul>
  <li key='1'>Паша</li>
  <li key='2'>Саша</li>
</ul>

// newTree
<ul>
  <li key='3'>Гриша</li>
  <li key='1'>Паша</li>
  <li key='2'>Саша</li>
</ul>

Реакт найдет key='1', key='2', определит, что c ними не произошло изменений, и затем найдет новый элемент <li key=’3'>Гриша</li> и добавит только его. Следовательно, с ключами реакт обновит только один компонент.


Перепишем наш пример, добавив ключи. Обратите внимание, что теперь при добавлении элементов вверх списка происходит создание только одного компонента:



Замечу, что мы добавили id к нашим именам и управляем keys напрямую, не используя индекс элемента в массиве как key. Это обусловлено тем, что при добавлении имени в верх списка индексы поедут.


Подведем итог первой части:


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




Реиспользование ключей и нормализация


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


Сайд-эффекты при дублировании ключей
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 (
      <Fragment>
        <TeamsSwitcher onSwitch={this.switchTeam} teams={this.state.teams} />
        <Users
          onClick={this.toggle}
          names={
            this.state.teams.find(team => team.id === this.state.active)
              .developers
          }
        />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

class TeamsSwitcher extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.teams.map(team => (
          <li
            onClick={() => {
              this.props.onSwitch(team.id);
            }}
            key={team.id}
          >
            {team.name}
          </li>
        ))}
      </ul>
    );
  }
}

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 (
      <Fragment>
        <input ref={this.getInput} />
        <button onClick={this.addToTop}>Add to TOP</button>
        <button onClick={this.addToBottom}>Add to BOTTOM</button>
      </Fragment>
    );
  }
}

class Users extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.names.map(user => (
          <Name
            id={user.id}
            onClick={this.props.onClick}
            highlighted={user.highlighted}
            key={user.id}
          >
            {user.name}
          </Name>
        ))}
      </ul>
    );
  }
}

class Name extends PureComponent {
  render() {
    return (
      <li
        className={this.props.highlighted ? "highlight" : ""}
        onClick={() => this.props.onClick(this.props.id)}
      >
        {this.props.children}
      </li>
    );
  }
}

render(<App />, document.getElementById("root"));


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



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


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


В приведенном коде есть пара проблем:


  1. Данные не нормализованы, работа с ними усложняется.
  2. Происходит дублирование ключей у сущности developer, из-за чего реакт не пересоздает компонент, а обновляет его. Это и ведет к сайд-эффектам.

Решить проблему можно двумя путями. Простым решением будет создать составной ключ для разработчиков в формате: ${id команды}.${id разработчика}, это позволит не пересекаться ключам и избавиться от сайд-эффектов.


Но проблему можно решить комплексно, нормализовав данные и объединив сущности. Значит, в state компонента будет 2 поля: 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 (
      <Fragment>
        <TeamsSwitcher onSwitch={this.switchTeam} teams={this.state.teams} />
        <Users
          onClick={this.toggle}
          users={this.state.teams
            .find(team => team.id === this.state.active)
            .developers.map(id => ({ id, ...this.state.developers[id] }))}
        />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

Полный код примера с нормализацией
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 (
      <Fragment>
        <TeamsSwitcher onSwitch={this.switchTeam} teams={this.state.teams} />
        <Users
          onClick={this.toggle}
          users={this.state.teams
            .find(team => team.id === this.state.active)
            .developers.map(id => ({ id, ...this.state.developers[id] }))}
        />
        <AddName addTop={this.addTop} addBottom={this.addBottom} />
      </Fragment>
    );
  }
}

class TeamsSwitcher extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.teams.map(team => (
          <li
            onClick={() => {
              this.props.onSwitch(team.id);
            }}
            key={team.id}
          >
            {team.name}
          </li>
        ))}
      </ul>
    );
  }
}

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 (
      <Fragment>
        <input ref={this.getInput} />
        <button onClick={this.addToTop}>Add to TOP</button>
        <button onClick={this.addToBottom}>Add to BOTTOM</button>
      </Fragment>
    );
  }
}

class Users extends PureComponent {
  render() {
    return (
      <ul>
        {this.props.users.map(user => (
          <Name
            id={user.id}
            onClick={this.props.onClick}
            highlighted={user.highlighted}
            key={user.id}
          >
            {user.name}
          </Name>
        ))}
      </ul>
    );
  }
}

class Name extends PureComponent {
  render() {
    return (
      <li
        className={this.props.highlighted ? "highlight" : ""}
        onClick={() => this.props.onClick(this.props.id)}
      >
        {this.props.children}
      </li>
    );
  }
}

render(<App />, document.getElementById("root"));


Теперь элементы обрабатываются корректно:



Нормализация данных упрощает взаимодействие с дата-слоем приложения, упрощает структуру и уменьшает сложность. Например, сравните функцию toggle с нормализованными и ненормализованными данными.


Hint: Если бекенд или api отдает данные в ненормализованном формате, нормализовать их можно с помощью — https://github.com/paularmstrong/normalizr


Подведем итог второй части:


При использовании ключей важно понимать, что при смене данных ключи должны меняться. Яркий пример ошибок, которые я встречал во время ревью, использование индекса элемента в массиве как key. Это приводит к сайд-эффектам типа того, что мы рассматривали на примере отображения списка людей с выделением.


Нормализация данных и/или составные key позволяют добиться нужного эффекта:


  1. Обновляем данные, когда изменяется сущность (например, помечается выделенной или мутирует).
  2. Удаляем старые инстансы, если элемента с заданным key больше не существует.
  3. Создаем новые элементы, когда это нужно.



Использование key при рендере одного элемента


Как мы обсуждали, реакт при отсутствии key сравнивает элементы старого и нового дерева попарно. При наличии ключей — ищет в списке children нужный элемент с заданным ключом. Случай, когда children состоит только из одного элемента, не исключение из правила.


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


class Notification1 extends PureComponent {
  componentDidMount() {
    setTimeout(() => {
      this.element && this.element.classList.add("notification_hide");
    }, 3000);
  }
  render() {
    return (
      <div ref={el => (this.element = el)} className="notification">
        {this.props.children}
      </div>
    );
  }
}

Да, в этом компоненте нет обратной связи, он не вызывает никаких 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 (
      <div ref={el => (this.element = el)} className="notification">
        {this.props.children}
      </div>
    );
  }
}

Здесь мы получили намного больше кода, который перезапускает таймаут, если контент нотификации изменился, и убирает класс notification_hide при обновлении данных.


Но решить задачу можно, и используя наш первый компонент Notification1 и атрибут key. У каждой нотификации есть свой уникальный id, которым мы воспользуемся как key. Eсли при изменении нотификации изменяется key, то Notification1 будет пересоздаваться. Компонент будет соответствовать нужной бизнес-логике:




Таким образом


В редких случаях использование key при рендере одного компонента оправданно. key — очень мощный способ «помогать» механизму reconciliation понимать, нужно ли сравнивать компоненты или стоит сразу же (пере)создать новый.




Работа с ключами при передаче компоненту children


Интересная особенность key — он недоступен в самом компоненте. Это происходит потому, что keyspecial prop. В React существует 2 специальных props: key и ref:


class TestKey extends Component {
  render() {
    // Выведет в консоли null
    console.log(this.props.key);
    // div будет пустым
    return <div>{this.props.key}</div>;
  }
}

const App = () => (
  <div>
    <TestKey key="123" />
  </div>
);

Кроме того, в консоли будет warning:


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 <div>{this.props.key}</div>;
  }
}

class TestChildrenKeys extends Component {
  render() {
    React.Children.forEach(this.props.children, child => {
      // Доступ к key можно получить внутри child, при итерировании.
      // Как правило, такой хак может пригодиться только в исключительных ситуациях
      // Не нужно завязываться на key таким образом
      // Лучше задублировать ключ в другом prop
      console.log(child.key);

      // Все остальные props, кроме key и ref будут переданы в объекте props
      console.log(child.props.a);
    });
    return this.props.children;
  }
}

const App = () => (
  <div>
    <TestChildrenKeys>
      <TestKey a="prop1" key="1" />
      <TestKey a="prop2" key="2" />
      <TestKey a="prop3" key="3" />
      <TestKey a="prop10" key="10" />
    </TestChildrenKeys>
  </div>
);

Консоль выведет:


1
prop1
2
prop2
3
prop3
10
prop10

Подытожим:


key и refspecial props в реакте. Они не включаются в объект props и недоступны внутри самого компонента.


Получить доступ к child.key или child.ref можно из родительского компонента, которому передали children, но делать этого не нужно. Практически нет ситуаций, когда это нужно. Всегда можно решить задачу проще и лучше. В случае если нужен key для обработки в компоненте — задублируйте его, например в prop id.




Мы рассмотрели области применения key, как он передается в компонент, каким образом изменяется механизм reconciliation с заданием key и без него. А также посмотрели на использование key для элементов, которые являются единственным child. Сгруппируем в конце основные тезисы:


  1. Без key механизм reconciliation сверяет компоненты попарно между текущим и новым VDOM. Из-за этого может происходить большое количество лишних перерисовок интерфейса, что замедляет работу приложения.


  2. Добавляя key, вы помогаете механизму reconciliation тем, что с key он сверяет не попарно, а ищет компоненты с тем же key (тег / имя компонента при этом учитывается) — это уменьшает количество перерисовок интерфейса. Обновлены/добавлены будут только те элементы, которые были изменены/не встречались в предыдущем дереве.


  3. Следите, чтобы не появлялись дублирующие key, при переключении отображения у новых данных не совпадали ключи. Это может привести к нежелательным сайд-эффектам, таким как анимации, или некорректной логике поведения элемента.


  4. В редких случаях key используют и для одного элемента. Это сокращает размер кода и упрощает понимание. Но область применения этого подхода ограничена.


  5. key и ref — специальные props. Они недоступны в компоненте, их нет в child.props. Можно получить доступ в родителе через child.key, но реальных областей применения для этого практически нет. Если в дочерних компонентах нужен key — правильным решением будет задублировать в prop id, например.

Комментарии (1)


  1. alexiusp
    29.03.2018 18:13

    Отличный разбор, спасибо!