Как стать автором
Обновить
1500.57
Timeweb Cloud
То самое облако

React Reconciliation: скрытый механизм, управляющий компонентами

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров1.9K
Автор оригинала: Christian Ekrem



❯ Механизм согласования


В моих предыдущих статьях (1, 2) я подробно рассматривал, как работает React.memo, а также делился более эффективными подходами к оптимизации производительности с помощью компоновки. Однако для глубокого понимания работы React необходимо разобраться в основном механизме, лежащем в основе всех этих оптимизаций – алгоритме согласования (reconciliation).


Согласование – это процесс, в результате которого React приводит DOM в соответствие с деревом компонентов. Именно этот механизм позволяет реализовать декларативный подход к программированию на React: вы формулируете свои намерения, а React самостоятельно определяет, как выполнить эти намерения наилучшим образом и с наименьшими затратами.


❯ Идентификация компонентов и сохранение состояния


Прежде чем углубляться в технические детали, обратим внимание на удивительное поведение React в контексте идентификации компонентов.


Рассмотрим простой пример – компонент, который позволяет переключать ввод текста:


const UserInfoForm = () => {
  const [isEditing, setIsEditing] = useState(false);

  return (
    <div className="form-container">
      <button onClick={() => setIsEditing(!isEditing)}>
        {isEditing ? "Cancel" : "Edit"}
      </button>

      {isEditing ? (
        <input
          type="text"
          placeholder="Enter your name"
          className="edit-input"
        />
      ) : (
        <input
          type="text"
          placeholder="Enter your name"
          disabled
          className="view-input"
        />
      )}
    </div>
  );
};

При взаимодействии с этой формой наблюдается интересное поведение. Когда текст вводится в поле и затем нажимается кнопка "Отмена", введенный текст остается в поле при повторном нажатии кнопки "Редактировать". Это происходит даже несмотря на то, что два элемента ввода имеют разные свойства (один из них отключен (disabled) и имеет другой класс).


React сохраняет элемент DOM и его состояние, поскольку оба элемента имеют одинаковый тип (input) и находятся на одной и той же позиции в дереве элементов. Вместо создания нового элемента, React просто обновляет свойства существующего.


Однако, если изменить реализацию на:


{
  isEditing ? (
    <input type="text" placeholder="Enter your name" className="edit-input" />
  ) : (
    <div className="view-only-display">Name will appear here</div>
  );
}

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


Это поведение подчеркивает основополагающий аспект процесса согласования в React: тип элемента является основным фактором для определения его идентичности. Понимание этого принципа крайне важно для достижения высокой производительности приложений на React.


❯ Деревья элементов, а не виртуальный DOM


Вы, вероятно, слышали, что React использует виртуальный DOM для оптимизации обновлений. Хотя такая модель и упрощает понимание, более точным внутреннее устройство React следует представлять как дерево элементов — компактное описание того, что должно отображаться на экране.


Такой JSX:


const Component = () => {
  return (
    <div>
      <h1>Hello</h1>
      <p>World</p>
    </div>
  );
};

Преобразуется React в такое дерево простых объектов JS:


{
  type: 'div',
  props: {
    children: [
      {
        type: 'h1',
        props: {
          children: 'Hello'
        }
      },
      {
        type: 'p',
        props: {
          children: 'World'
        }
      }
    ]
  }
}

Для DOM-элементов, таких как div или input, "тип" представляет собой строку. Для пользовательских компонентов React "тип" — это ссылка на функцию:


{
  type: Input, // ссылка на функцию Input
  props: {
    id: "company-tax-id",
    placeholder: "Enter company Tax ID"
  }
}

❯ Процесс согласования


Когда React требуется обновить пользовательский интерфейс (вследствие изменения состояния или повторного рендеринга), он выполняет следующие шаги:


  1. Создает новое дерево элементов, вызывая компоненты-функции.
  2. Сравнивает его с предыдущим деревом.
  3. Определяет, какие операции с DOM необходимо выполнить, чтобы реальный DOM соответствовал новому дереву.
  4. Эффективно выполняет эти операции.

Алгоритм сравнения основан на нескольких ключевых принципах:


1. Тип элемента определяет его идентичность


Сначала React проверяет "тип" элементов. Если тип изменился, React перестраивает все поддерево:


// Начиная с этого (первый рендеринг)
<div>
  <Counter />
</div>

// Заканчивая этим (второй рендеринг)
<span>
  <Counter />
</span>

Поскольку div изменился на span, React полностью уничтожает старое дерево (включая компонент Counter) и строит новое с нуля.


2. Позиция элемента в дереве имеет значение


React сравнивает элементы, находящиеся на одной и той же позиции в дереве:


// До
<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

// После (когда изменяется showDetails)
<>
  {showDetails ? <UserProfile userId={123} /> : <LoginPrompt />}
</>

В этом условном примере, когда showDetails равно true, на позиции 1 находится элемент UserProfile. Когда оно равно false, на позиции 1 находится элемент LoginPrompt. React видит разные типы компонентов на одной и той же позиции, поэтому он размонтирует один и монтирует другой.


Но если у нас будет два компонента одного типа:


// До
<>
  {isPrimary ? (
    <UserProfile userId={123} role="primary" />
  ) : (
    <UserProfile userId={456} role="secondary" />
  )}
</>

React будет видеть один и тот же тип компонента (UserProfile) на позиции 1 до и после изменений, поэтому он просто обновит его свойства/пропы, вместо уничтожения/создания компонентов.


3. Ключи переопределяют сравнение по позиции


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


<>
  {isPrimary ? (
    <UserProfile key="active-profile" userId={123} role="primary" />
  ) : (
    <UserProfile key="active-profile" userId={456} role="secondary" />
  )}
</>

Даже если компоненты появляются в разных ветках условия, React считает их одним и тем же компонентом, поскольку они имеют одинаковый ключ. Это позволяет сохранять состояние при переключении между ними.


❯ Магия ключей


Ключи, прежде всего, ассоциируются со списками, но они также играют важную роль в процессе согласования в React.


Ключи необходимы для отслеживания изменений в списках


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


<ul>
  {items.map((item) => (
    <li key={item.id}>{item.text}</li>
  ))}
</ul>

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


С ключами React может сопоставлять элементы между рендерингами, независимо от их позиции.


Ключи вне массивов


React не требует добавления ключей для статических элементов:


// Ключи не требуются
<>
  <Input />
  <Input />
</>

Это работает, потому что React знает, что эти элементы статичны — их положение в дереве предсказуемо.


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


const Component = () => {
  const [isReverse, setIsReverse] = useState(false);

  return (
    <>
      <Input key={isReverse ? "some-key" : null} />
      <Input key={!isReverse ? "some-key" : null} />
    </>
  );
};

Когда переключается isReverse, ключ "some-key" перемещается с одного элемента ввода на другой, что приводит к "перемещению" состояния компонента между двумя позициями.


Сочетание динамических и статических элементов


Вопрос на засыпку — влияет ли добавление элементов в динамический список на идентичность статических элементов, следующих за ним?


<>
  {items.map((item) => (
    <ListItem key={item.id} />
  ))}
   {/* Будет ли React повторно рендерить этот компонент при изменении элементов списка выше? */}
  <StaticElement />
</>

React обрабатывает это умным образом. Он рассматривает весь динамический список как единое целое, поэтому StaticElement всегда сохраняет свое положение и идентичность, независимо от изменений в списке.


Вот как React на самом деле управляет этой ситуацией:


[
  // Весь динамический массив рассматривается как один элемент
  [
    { type: ListItem, key: "1" },
    { type: ListItem, key: "2" },
  ],
  { type: StaticElement }, // всегда остается на второй позиции
];

❯ Идентичность компонентов и производительность


Понимание этих нюансов согласования объясняет некоторые паттерны производительности в React:


1. Определение одних компонентов внутри других — плохая практика


Определение одних компонентов внутри других создает новые ссылки на функции при каждом рендеринге:


const Parent = () => {
  // Плохая практика: InnerComponent пересоздается при каждом рендеринге
  const InnerComponent = () => <div>Inner content</div>;

  return <InnerComponent />;
};

Поскольку "тип" компонента (ссылка на функцию) изменяется при каждом рендеринге, React рассматривает его как совершенно новый компонент, каждый раз монтируя его заново.


2. Паттерны композиции работают


Паттерн композиции из предыдущей статьи использует алгоритм согласования React:


const CounterButton = () => {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
};

const Parent = () => {
  return (
    <div>
      <CounterButton />
      <ExpensiveComponent />
    </div>
  );
};

Когда значение count изменяется, требует согласования только дерево компонента CounterButton. React не трогает дерево компонента ExpensiveComponent, поскольку оно находится в отдельной ветке.


3. Использование ключей для продвинутого сохранения состояния


Основываясь на нашем понимании ключей, мы можем внедрять более сложные паттерны:


const TabContent = ({ activeTab }) => {
  // Все вкладки имеет одинаковый ключ, поэтому React сохраняет состояние при переключении между ними
  return (
    <div>
      {activeTab === "profile" && <ProfileTab key="tab-content" />}
      {activeTab === "settings" && <SettingsTab key="tab-content" />}
      {activeTab === "activity" && <ActivityTab key="tab-content" />}
    </div>
  );
};

Почему это работает? Когда activeTab меняется, React видит:


  1. До: элемент с типом ProfileTab и ключом "tab-content"
  2. После: элемент с типом SettingsTab и ключом "tab-content"

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


Это позволяет эффективно переносить внутреннее состояние от одного компонента к другому. Например, если у компонента ProfileTab есть поля ввода с данными, введенными пользователем, эти значения сохраняются при переключении на компонент SettingsTab несмотря на то, что это совершенно разные элементы.


Данный паттерн может быть полезен для сохранения состояния ввода формы между вкладками или шагами, а также для создания визуальных эффектов перехода, когда необходимо сохранить определенное состояние при изменении визуального представления.


❯ Совместное размещение состояния: мощный паттерн производительности


Совместное размещение состояния (state colocation) — это паттерн, который заключается в том, чтобы располагать состояние как можно ближе к месту его использования. Такой подход минимизирует ненужные повторные рендеринги, гарантируя обновление только тех компонентов, которые напрямую затрагиваются изменениями состояния.


Рассмотрим следующий пример:


// Плохая производительность – все компоненты повторно рендерятся при изменении фильтра
const App = () => {
  const [filterText, setFilterText] = useState("");
  const filteredUsers = users.filter((user) => user.name.includes(filterText));

  return (
    <>
      <SearchBox filterText={filterText} onChange={setFilterText} />
      <UserList users={filteredUsers} />
      <ExpensiveComponent />
    </>
  );
};

Когда изменяется filterText, происходит повторный рендеринг всего компонента App, включая ExpensiveComponent, не затрагиваемый фильтром.


Необходимо разместить состояние фильтра рядом только с теми компонентами, которые его используют:


const UserSection = () => {
  const [filterText, setFilterText] = useState("");
  const filteredUsers = users.filter((user) => user.name.includes(filterText));

  return (
    <>
      <SearchBox filterText={filterText} onChange={setFilterText} />
      <UserList users={filteredUsers} />
    </>
  );
};

const App = () => {
  return (
    <>
      <UserSection />
      <ExpensiveComponent />
    </>
  );
};

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


❯ Дизайн компонентов: оптимизация под изменения


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


Прежде чем использовать React.memo, необходимо ответить на следующие вопросы:


  1. У этого компонента есть смешанные обязанности? Компоненты, управляющие несколькими задачами, скорее всего, будут обновляться чаще.
  2. Находится ли состояние слишком высоко в иерархии компонентов? Размещение состояния выше, чем необходимо, приводит к рендерингу большего количества компонентов.

Рассмотрим следующий пример:


// Сомнительный дизайн - смешанные обязанности
// (нарушение принципа единственной ответственности; см. ниже)
const ProductPage = ({ productId }) => {
  const [selectedSize, setSelectedSize] = useState("medium");
  const [quantity, setQuantity] = useState(1);
  const [shipping, setShipping] = useState("express");
  const [reviews, setReviews] = useState([]);

  // Извлекает как подробную информацию, так и отзывы о продукте
  useEffect(() => {
    fetchProductDetails(productId);
    fetchReviews(productId).then(setReviews);
  }, [productId]);

  return (
    <div>
      <ProductInfo
        selectedSize={selectedSize}
        onSizeChange={setSelectedSize}
        quantity={quantity}
        onQuantityChange={setQuantity}
      />
      <ShippingOptions shipping={shipping} onShippingChange={setShipping} />
      <Reviews reviews={reviews} />
    </div>
  );
};

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


Лучший дизайн разделяет эти задачи:


const ProductPage = ({ productId }) => {
  return (
    <div>
      <ProductConfig productId={productId} />
      <ReviewsSection productId={productId} />
    </div>
  );
};

const ProductConfig = ({ productId }) => {
  const [selectedSize, setSelectedSize] = useState("medium");
  const [quantity, setQuantity] = useState(1);
  const [shipping, setShipping] = useState("express");

  // Логика

  return (
    <>
      <ProductInfo
        selectedSize={selectedSize}
        onSizeChange={setSelectedSize}
        quantity={quantity}
        onQuantityChange={setQuantity}
      />
      <ShippingOptions shipping={shipping} onShippingChange={setShipping} />
    </>
  );
};

const ReviewsSection = ({ productId }) => {
  const [reviews, setReviews] = useState([]);

  useEffect(() => {
    fetchReviews(productId).then(setReviews);
  }, [productId]);

  return <Reviews reviews={reviews} />;
};

Такая структура гарантирует, что изменение размера не вызывает повторного рендеринга отзывов. Мемоизация здесь не требуется — достаточно правильно настроить границы (boundaries) компонентов.


❯ Согласование и чистая архитектура


Понимание механизма согласования отлично сочетается с принципами чистой архитектуры:


  1. Принцип единственной ответственности (Single Responsibility Principle): каждый компонент должен иметь только одну причину для изменения. Чем меньше у компонента задач, тем ниже вероятность, что он будет повторно рендериться без необходимости.
  2. Инверсия зависимостей (Dependency Inversion): компоненты должны зависеть от абстракций, а не от конкретных реализаций. Это упрощает оптимизацию производительности за счет композиции.
  3. Принцип разделения интерфейса (Interface Segregation): интерфейсы компонентов должны быть минимальными и четко определенными. Чем меньше и чище пропы, тем ниже риск ненужных повторных рендерингов из-за их изменений.

❯ Практические рекомендации


Несколько полезных советов:


  1. Размещайте определения компонентов вне родительских компонентов, чтобы избежать их повторного монтирования.
  2. Опускайте состояние как можно ниже в иерархии компонентов, чтобы ограничить зону повторного рендеринга.
  3. Используйте компоненты одного типа в одном и том же месте дерева, чтобы избежать размонтирования.
  4. Используйте ключи не только в списках — это способ управления идентичностью компонента.
  5. При отладке нежелательных повторных рендерингов смотрите на структуру дерева элементов и то, как React определяет идентичность компонента.
  6. Помните, что React.memo — всего лишь инструмент, работающий в рамках общей логики согласования. Он не меняет сам алгоритм.

❯ Заключение


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


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






Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Теги:
Хабы:
+15
Комментарии2

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud

Истории