В сети много статей и докладов, которые описывают React Fiber, но, к сожалению, они сейчас уже не актуальны. Заметив это, я решил разобраться и актуализировать информацию. Моими основными помощниками были исходники и отладчик, поэтому здесь вы увидите множество ссылок на код из репозитория React. Теперь я хочу поделиться результатами своей работы с вами.

Тема разделена на две статьи. Первая статья рассказывает о процессе обновления и внесения изменений в DOM. Вторая статья посвящена реализации не блокирующего рендеринга - Concurrent React. Данная статья является второй. Первая доступна по ссылке.

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

React Fiber

React Fiber решает две основные задачи:

  1. Инкрементальный процесс рендеринга — способность разделять работу рендера на части. Здесь концепция и реализация во многом не поменялись, хотя и претерпели некоторые изменения. Как устроен инкрементальный процесс рендеринга, я рассказываю в предыдущей статье.

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

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

История

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

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

Например, об этом рассказывает Lin Clark в докладе A Cartoon Intro to Fiber - React Conf 2017.

Но первый результат работы был показан только лишь в 2018 году: Dan Abramov - Beyond React 16. Тогда Dan показал прототип современного startTransition. Кстати, пример, используемый в докладе, можно посмотреть вот тут. Тогда он это назвал Async Rendering.
Далее в том же 2018 году было название Concurrent React, озвученное Andrew Clark в докладе Concurrent Rendering in React.

Позже, в 2019, это переросло в Concurrent Mode, и впервые за все это время предоставили экспериментальный пакет, с помощью которого можно было потрогать Concurrency.

И наконец, в 2021 году анонсировали 18 версию React и вместе с ней Concurrent Features. То есть спустя много лет выступлений и показов того, над чем они работают, мы наконец-то смогли это использовать с 18 версии React. И то, какой вид это приобрело, во многом отличается от того, какой был анонсирован при выпуске React Fiber.

Проведем черту между старой теорией не блокирующего рендеринга и современной реализацией:

В современном коде React не используется requestIdleCallback и requestAnimationFrame для приоритезации задач. Также нет внутренней логики по приоритезации обновлений за вас. Есть возможность вручную пометить прерываемые и несрочные обновления с помощью Concurrent Features.

Concurrent Features

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

Concurrent Features:

  • startTransition / useTransition

  • useDeferredValue

  • Suspense

К примеру, я могу разделить обновления с высоким приоритетом и с низким, обернув обновление с низким приоритетом в startTransition:

function handleChange(e){
  setHighPriorityUpdate(e.target.value);

  React.startTransition(() => {
    setLowPriorityUpdate(e.target.value)
  })
}

Давайте разберем как это работает изнутри.

React Concurrent

Как я уже говорил, по дефолту рендеринг в React осталось непрерывным и синхронным. Как происходит рендеринг, я подробно описывал в предыдущей статье. Для этого используется цикл workLoopSync, который пробегается по всем узлам дерева workInProgress и выполняет рендеринг в функции performUnitOfWork. Рендеринг - это процесс обновления компонентов и вычисления изменений между предыдущим и текущим рендерами для последующего внесения изменений в DOM.

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

В случае рендеринга, вызванного с помощью Concurrent Features, ситуация немного отличается. Рендеринг выполняется в цикле workLoopConcurrent

function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

Отличие от workLoopSync заключается в том, что вызывается проверка shouldYield - должны ли мы уступить. Если уступать не должны, то выполняем дальше работу по рендерингу узла. Но если shouldYield вернет true, то мы должны прерывать рендеринг. Давайте теперь разберем, в какой момент это происходит.

function shouldYield(): boolean {
  const timeElapsed = getCurrentTime() - startTime;
  if (timeElapsed < frameInterval) {
      return false;
  }
  return true;
}

timeElapsed - это время выполнения текущего рендеринга. frameInterval - это константа, которая задается в конфигурационном файле и равно 5 по дефолту. То есть React дает 5мс на выполнение рендеринга, а затем этот процесс приостанавливается.

Почему 5мс?

Я могу только предположить, что это связано с рекомендациями RAIL в части анимации https://web.dev/rail/#animaciya-sozdavajte-kadr-za-10-ms

Для того чтобы понять, что происходит, когда мы прерываем работу по рендерингу, надо подняться выше по call stack к функции performWorkUntilDeadline.

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime();
    startTime = currentTime;

    let hasMoreWork = true;
    try {
      hasMoreWork = scheduledHostCallback(currentTime);
    } finally {
      if (hasMoreWork) {
        schedulePerformWorkUntilDeadline();
      } else {
        scheduledHostCallback = null
      }
    }
  }
};

Мы можем заметить, что тут участвует переменная hasMoreWork, которая сообщает о том, есть ли работа по рендерингу, которую необходимо совершить. Значение этой переменной зависит от результата выполнения scheduledHostCallback, внутри которого вызывается наш знакомый workLoopConcurrent. И если мы вспомним workLoopConcurrent и shouldYield, то станет понятно, когда hasMoreWork будет равен true или false. Если в результате работы по рендерингу прошло более 5мс, то shouldYield вернет true, и мы должны прервать цикл workLoopConcurrent. Если мы прервали цикл, но при этом еще осталась работа по рендерингу, то переменная hasMoreWork будет равна true, иначе false.

Что же происходит дальше? Если работа еще есть, то мы планируем ее выполнить с помощью функции schedulePerformWorkUntilDeadline, иначе очищаем данные. Далее посмотрим на функцию планирования новой работы:

if (typeof localSetImmediate === 'function') {
  // Node.js and old IE.
  schedulePerformWorkUntilDeadline = () => {
    localSetImmediate(performWorkUntilDeadline);
  };
} else if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;
  schedulePerformWorkUntilDeadline = () => {
    port.postMessage(null);
  };
} else {
  // We should only fallback here in non-browser environments.
  schedulePerformWorkUntilDeadline = () => {
    localSetTimeout(performWorkUntilDeadline, 0);
  };
}

localSetImmediate выполнится в Node.js среде, MessageChannel используется в воркерах и в современных браузерах. Он работает точно так же, как и setTimeout с параметром 0, только нет минимальной задержки в 4мс.

При помощи schedulePerformWorkUntilDeadline мы можем отложить очередное выполнение performWorkUntilDeadline и дать браузеру перерисовать страницу. Таким образом, React реализует рендеринг, который не блокирует перерисовку содержимого окна браузера, и пользователь не замечает подлагиваний страницы.

Lanes

Concurrent features также позволяют прервать текущий рендеринг для выполнения другого, запущенного синхронным обновлением. Для определения приоритетности используются lanes, значение которого хранится в каждой Fiber Node. Значение в lanes хранятся в двоичном виде. Таким образом, у каждой Fiber Node может содержаться не одно значение lane. lanes выставляются в момент выполнения beginWork. Также заполняется поле childLanes в момент всплытия в completeWork. В итоге у Root есть информация о значениях lanes всех его потомков. Для понимания, что такое beginWork и completeWork , предлагаю обратиться к первой статье этой серии.

С помощью функции getNextLanes мы определяем наиболее приоритетный рендеринг, который необходимо выполнить. Это определение происходит на этапе рендера Root узла. На этом этапе мы либо продолжаем работу по рендерингу с того места, где остановились в прошлый раз, либо прерываем текущий рендеринг и выполняем более приоритетный.


Подводя итог по всему вышесказанному, можно отметить следующее:

  1. Concurrent Features помогают отметить обновления как прерываемые и несрочные.

  2. Рендеринг мы разбиваем на интервалы по 5мс, между которыми происходит перерисовка браузера и проверка на существование срочных обновлений.

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

  4. Таким образом, долгий процесс рендеринга не заблокирует браузер пользователя, и он сможет продолжить взаимодействовать с интерфейсом.

Профилирование

Теперь предлагаю посмотреть как то, что мы разобрали работает на практике. За основу я взял пример из доклада Dan Abramov в 2018 году.

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

Метод ввода выглядит так:

handleChange = e => {
  const value = e.target.value;
  const { strategy } = this.state;
  switch (strategy) {
    case 'sync':
        this.setState({value});
        break;
    case 'concurrent':
        this.setState({ inputValue: value });
        startTransition(() => {
        this.setState({ graphicValue: value });
    });
        break;
    default:
        break;
  }
};

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

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

Далее результат профилирования с использованием Concurrent Features. Если еще раз посмотреть на код функции handleChange выше, то можно заметить, что в режиме Concurrent обновление значений для input (inputValue) и для графиков (graphicValue) выполняет раздельно. Таким образом, обновление значения в input будет непрерывным и синхронным, а обновление значения для графиков и обновление самих графиков - прерывно и не срочно (из-за того, что обернуто в startTransition).

Можно заметить, что теперь процесс рендеринга разбит на части примерно 5-10мс и между ними происходит перерисовка содержимого окна браузера. 

Здесь я увеличил предыдущий результат, чтобы было заметно, как сначала выполняется синхронный рендеринг (обновление input), а затем выполняется рендеринг в режиме Concurrent.

Полезные материалы

  1. React Concurrency, Explained: What useTransition and Suspense Hydration Actually Do
    Видео
    Текст

    Очень рекомендую данный доклад. Также в нем указаны недостатки Concurrent React, я не буду повторяться и рекомендую вам посмотреть доклад или прочитать статью, либо перейти к разделу Drawbacks

  2. The Story of Concurrent React

  3. What happened to concurrent "mode"?
    Рассказывается то, почему отказались от идеи Concurrent Mode и перешли на Concurrent Features

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