Узгодження

React надає декларативний API, щоб вам не довелося турбуватися про те, що саме змінюватиметься під час кожного оновлення. Це робить процес створення додатків значно простішим, але спосіб, за допомогою якого все це реалізовано в React, може бути не очевидним. У цій статті пояснюється вибір, зроблений для алгоритмів порівняння в React, завдяки чому оновлення компонентів є передбачуваним, причому відбувається достатньо швидко в високопродуктивних додатках.

Мотивація

Коли ви використовуєте React, в певний момент часу можна подумати про функцію render() як про функцію для створення дерева елементів React. При наступному оновленні стану або пропсів функція render() поверне нове дерево елементів React. При цьому React повинен розуміти, як ефективно оновити інтерфейс користувача, щоб він повністю відповідав самому новому дереву.

Існує декілька загальних способів вирішення алгоритмічної задачі генерації мінімальної кількості операцій для перетворення одного дерева елементів в інше. Однак складність сучасних алгоритмів (state of the art algorithms) складає приблизно O(n3), де n — кількість елементів в дереві.

Якщо спробувати використати це в React, то для відображення 1000 елементів необхідно було б виконати близько одного мільярда порівнянь. Це приведе до великих затрат пам’яті. Замість цього React використовує евристичний алгоритм O(n), використання якого основане на двох припущеннях:

  1. Два елементи різних типів створюватимуть різні дерева.
  2. Розробник має змогу вказувати, які елементи можуть не змінюватись між різними рендерами за допомогою пропа key.

На практиці ці припущення є правильними практично для всіх випадків використання.

Алгоритм порівняння

При порівнянні двох дерев React в першу чергу порівнює їх кореневі елементи. Подальші дії залежать від того, який тип мають кореневі елементи.

Елементи різних типів

Кожний раз, коли тип кореневих елементів різний, React демонтує старе дерево і створює нове з самого початку. Перехід від <a> до <img>, від <Article> до <Comment> або від <Button> до <div> — всі такі випадки приведуть до повної перебудови дерева.

При демонтуванні дерева старі DOM-вузли видаляються. Для екземплярів компонента настає метод життєвого циклу componentWillUnmount(). При створенні нового дерева нові DOM-вузли вставляють в DOM. Для екземплярів компонента настає метод життєвого циклу componentWillMount(), після нього — componentDidMount(). Будь-який стан, пов’язаний зі старим деревом, втрачається.

Будь-які компоненти, розташовані всередині кореневого, демонтуються, а їх стан втрачається. Наприклад, при порівнянні:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

React демонтує старий Counter і змонтує новий.

DOM-елементи одного типу

При порівнянні двох React DOM-елментів одного типу React розглядає атрибути обох, зберігає DOM-вузол, що лежить в їх основі, і оновлює тільки змінені атрибути. Наприклад:

<div className="before" title="stuff" />

<div className="after" title="stuff" />

Порівнюючи два таких елементи, React знає, що потрібно змінити тільки className в базового DOM-елемента.

При оновленні атрибута style, React також знає, що потрібно оновлювати тільки одну властивість, яка була змінена. Наприклад:

<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

При такому перетворенні React знає, що потрібно змінити тільки значення властивості color, а fontWeight — залишити без змін.

Після оновлення DOM-вузла React рекурсивно виконає такі самії дії з дочірніми елементами.

Елементи компонентів одного типу

Коли компонент оновлюється, його екземпляр залишається без змін, саме тому стан зберігається між рендерами. React оновлює пропси базового екземпляра компонента, щоб він відповідав новому елементу, і викликає методи життєвого циклу componentWillReceiveProps() і componentWillUpdate() на базовому екзумплярі.

Після цього викликається метод render(), і алгоритм порівняння рекурсивно обходить попередній і новий результати.

Рекурсія по дочірнім елементам

За замовчуванням під час рекурсивного обходу дочірніх елементів DOM-вузла React просто виконує обхід обох списків потомків і формує зміни кожний раз, коли знаходить відмінності.

Наприклад, при додаванні нового елемента в кінець списку потомків, перетворення між цими двома деревами виконується добре:

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React порівняє два дерева <li>first</li>, потім <li>second</li>, після чого вставить дерево <li>third</li>.

Якщо ж спробувати вставити новий елемент на початок списку потомків, від цього зменшиться продуктивність. Наприклад, перетворення між цими двома деревами виконується неефективно:

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React буде змінювати кожний дочірній елемент замість того, щоб залишити піддерева <li>Duke</li> і <li>Villanova</li> без змін. Подібна неефективність може стати проблемою.

Ключі

Для вирішення такої проблеми React використовує атрибут key. Коли дочірні елементи мають ключі, React використовує їх для того, щоб встановити відповідність дочірніх елементів у початковому дереві з аналогічними елементами наступного. Наприклад, якщо додати keyдля попереднього неефективного прикладу, то перетворення дерева елементів стане ефективним:

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

Тепер React буде знати, що елемент з ключем '2014' — новий, а елементи з ключами '2015' і '2016' тільки що були переміщені.

На практиці знайти ключ частіше за все не складно. Елемент, який ви хочете відобразити, вже може мати унікальний ID, тому ключ може бути отриманий прямо з ваших даних:

<li key={item.id}>{item.name}</li>

Якщо ж унікального значення немає, ви можете додати нову властивсть ID до своєї моделі, або ж створити хеш частини вмісту для генерації ключа. При цьому ключа повинен бути унікальним серед всіх сусідніх елементів, а не унікальним глобально.

На крайній випадок ви можете передати порядковий номер елементам масиву як ключ. Це буде працювати за умови, коли елементи ніколи не будуть змінювати свій порядок, їх перестановка виконуватиметься повільно.

При використанні індексів елементів як їх ключів перестановки можуть приводити до проблем зі станом компоненту. Екземпляри компонентів оновлюються і повторно використовуються на основі їх ключів. Якщо ключем є індекс, переміщення елемента змінить його. В результаті стан компонента, який рендерить, наприклад, неконтрольоване поле, може зміщуватись і оновлюватись неочікуваним способом.

Тут на CodePen є приклади проблем, які можуть виникати при використанні індексів як ключів (an example of the issues that can be caused by using indexes as keys), а також оновлена версія того ж прикладу, де показані способи вирішення проблем з перестановкою, сортуванням і вставленням елементів на початок, якщо не використовувати індекси як ключі (an updated version of the same example showing how not using indexes as keys will fix these reordering, sorting, and prepending issues).

Компроміси

Важливо пам’ятати, що алгоритм узгодження є деталлю реалізації. React може повторно рендерити весь додаток у відповідь на кожну дію, кінцевий результат буде однаковим. Щоб було більш зрозуміло, повторний рендеринг в цьому контексті означає виклик функції render для всіх компонентів, але це не означає те, що React демонтує та змонтує їх заново. Він буде застосовувати відмінності у відповідності до правил, які були описані в попередніх розділах.

Ми регулярно здійснюємо евристику, щоб прискорити варіанти використання, які зустрічаються часто. В поточній реалізації ви можете припустити той факт, що піддерево перемістилось серед сусідніх елементів, але ви не можете стверджувати, що воно перемістилось в інше місце. Алгоритм буде повторно здійснювати рендеринг всього піддерева.

Оскільки React покладається на евристику, якщо припущення, на яких вона базується, не будуть дотримані, знизиться продуктивність.

  1. Алгоритм не буде намагатися порівнювати піддерева компонентів різних типів. Якщо ви помітите, що використовуєте два типи компонентів з виведенням однакових даних, буде краще, якщо зробити їх компонентами однакового типу. На практиці ми не виявили з цим ніяких проблем.
  2. Ключі повинні бути незмінними, передбачуваними та унікальними. Ключі, що можуть змінюватись (наприклад, створені за допомогою Math.random()), будуть приводити до необов’язкового повторного створення багатьох екземплярів компонентів у DOM-вузлів, що може привести до зниження продуктивності і втрати стану дочірніх компонентів.