Возможно, вы слышали, что кто-то недавно упоминал React Fibers, и даже совсем недавно, что Suspense позволяет использовать асинхронные функции с React. Читайте дальше, чтобы узнать, что такое асинхронные функции и какое отношение они имеют к волокнам!

Асинхронный / Ожидание

В 2017 году JavaScript получил синтаксис _1 _ / _ 2_ в дополнение к функциям генератора 2015 года (_3 _ / _ 4_). В этом разделе рассматривается, что делают эти языковые функции и как они соотносятся с моделью рендеринга React.

Вызов функций

Давайте сделаем шаг назад и обсудим обычные вызовы функций. Возьмем следующую пару функций, представив, что sleep(ms, v) - это функция, для возврата v которой требуется ms миллисекунд:

function outer() {
  const y = inner();
  return y + 1; // <1>
}
function inner() {
  const w = sleep(1000, 4);
  return w + 2; // <2>
}
outer()  // after 1 second, returns 7

Каждый раз, когда вызывается функция, ее кадр стека добавляется в стек. Он состоит из локальных переменных функции и указателя возврата (r.p.), который указывает, куда в вызывающей стороне нужно вернуться. outer возвращается в цикл событий (помеченный &loop), а &<1> относится к строке, помеченной // <1>.

В дополнение к указателю возврата существует также глобальный указатель стека (s.p.), который отслеживает положение вершины стека. Все локальные переменные ссылаются на их расстояние до вершины стека, например, ссылки на y могут быть представлены как sp — 4 bytes. Следствием этого является то, что мы не можем запускать две функции одновременно, потому что обе ожидают, что указатель стека будет указывать на вершину соответствующего кадра стека. С практической точки зрения это означает, что браузер зависнет во время выполнения любой функции.

Обратные вызовы

Чтобы избежать этого, JavaScript использует обратные вызовы для обработки результатов длительно выполняющихся функций. В этом случае управление немедленно возвращается в цикл событий, и функция вызывается по усмотрению цикла событий, оставляя цикл свободным для выполнения других задач, таких как обработка пользовательского ввода, тем временем. Например, вместо sleep(1000, 4) мы бы назвали setTimeout(() => 4, 1000). Однако это все еще не совсем то, что мы хотим -- outer вызовет inner, который вызовет setTimeout, который вернет число, представляющее объект тайм-аута. Затем мы выполняем арифметические действия с этим числом, которое, вероятно, не равно 4. Затем, через секунду цикл обработки событий вызовет наш обратный вызов и выбросит возвращаемое значение (которое является 4, которое мы искали).

Обещания

Чтобы решить эту проблему, были введены обещания. Обещание представляет собой значение, которое еще не готово. В то же время обратные вызовы могут быть зарегистрированы с .then для выполнения, когда Promise завершается, что происходит при вызове его (внутреннего) resolve метода.

Разбиение кода на обратные вызовы (т.е. продолжения) не является интуитивно понятным способом структурирования функций:

function outer() {
  const y = inner()
  return y.then(value => value + 1);
}
function inner() {
  const w = sleep(1000, 4)
  return w.then(value => value + 2);
}
function sleep(ms, value) {
  const timeout = new Promise(resolve => setTimeout(
    () => resolve(value), ms
  ));
  return timeout;
}
outer()  // Returns a Promise; after 1 second, its internal value will be 7

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

Здесь мы создаем новое обещание w и создаем тайм-аут, который разрешит обещание через 1000 мс, установив для него значение 4. Перед истечением тайм-аута мы создаем цепочку дочерних обещаний, используя .then(fn). Каждый раз, когда вызывается этот метод, он создает новое обещание и добавляет обратный вызов во внутренний список родителя. Этот обратный вызов вызывает fn родительского значения и разрешает новое обещание с результатом. В результате, когда первое обещание в цепочке разрешается, все дочерние элементы следуют одно за другим, пока мы в конечном итоге не выполним последнее обещание в цепочке и не установим его значение.

Асинхронный / Ожидание

Возвращаясь к нашему первому примеру, мы действительно хотели приостановить выполнение нашей текущей функции, когда нам нужно значение, которое еще не готово, а затем продолжить, когда оно станет доступным. _30 _ / _ 31_ позволяет нам сделать именно это - нам все еще нужно обернуть вызов верхнего уровня в обещание, поскольку точка входа не является асинхронной функцией (до тех пор, пока не будет готов ожидание верхнего уровня):

async function outer() {
  const y = await inner();
  return y + 1;
}
async function inner() {
  const w = await sleep(1000, 4);
  return w + 2;
}
function sleep(ms, value) { /* as above */ }
outer()  // Returns a Promise, as above.

Генераторы

Игнорируя частую обработку ошибок и предполагая, что мы уступаем только тогда, когда это абсолютно необходимо (т.е. когда у нас есть обещание, которое мы ждем), мы можем заменить _35 _ / _ 36_ генераторами и вспомогательной функцией:

function* outer() {
  const y = yield* inner();
  return y + 1;
}
function* inner() {
  const w = yield sleep(ms, value)
  return w + 2;
}
function sleep(ms, value) { /* as above */ }
// Helper function to drive the generator
function step(g, next) {
  let {done, value} = g.next(next)
  // We always `yield` a `Promise` and `return` a concrete value
  return done ? value.then(next => step(g, next)) : value;
}
step(outer())  // Returns a Promise, as above

Чем эти функции отличаются от нашей первоначальной реализации? Когда генератор достигает точки yield (или yield*), он сохраняет свое внутреннее состояние и указатель инструкции в куче и возвращает вызывающей стороне функцию, которая возобновляет работу из этого сохраненного состояния (_43 _) [1]. Мы можем использовать это, чтобы приостановить выполнение нашей функции всякий раз, когда мы ждем обещания, а затем возобновить с того места, где мы остановились, когда оно будет готово - функция step делает именно это, регистрируя обратный вызов, который возобновляет работу генератора всякий раз, когда он дает Обещать.

Мы достигли нашей первоначальной цели - цикл обработки событий находится под контролем, пока мы ждем, и может обрабатывать ввод данных пользователем, а наши функции по-прежнему написаны в более или менее одинаковом стиле. Существуют некоторые ограничения, например, генераторы могут подчиняться только своим вызывающим объектам и, как таковые, могут приостанавливать только себя, а не какие-либо функции далее вверх или вниз по стеку вызовов. В нашем примере inner приостанавливается, и управление возвращается к outer. outer видит, что оператор yield* вернул неполный генератор, и приостанавливает свою работу, возвращая управление шагу.

Заключение

Генераторы в JavaScript - это возобновляемые функции, также известные как сопрограммы. В частности, они асимметричные и бесстековые. Асимметричные сопрограммы поддерживают отношения вызывающий / вызываемый и могут передавать управление только своим вызывающим, в то время как более редкие симметричные сопрограммы способны переходить к произвольным другим сопрограммам (например, гринлетам Python). Бесстековые сопрограммы могут только приостанавливаться - в приведенном выше примере inner уступает место циклу событий через outer, вместо того, чтобы напрямую приостанавливать весь стек, как в многостековых сопрограммах (например, Ruby Fiber и Lua coroutine).

Реагировать

ПРИМЕЧАНИЕ. Этот раздел посвящен внутреннему устройству React и некоторому обсуждению того, почему он может быть спроектирован таким образом. Для использования React эти знания не требуются.

Что такое React?

React - это библиотека JavaScript для создания интерактивных пользовательских интерфейсов. Пользовательский интерфейс, написанный с помощью React, принимает входной набор свойств и отображает дерево элементов (обычно HTML-элементы). В ответ на взаимодействие с пользователем пользовательский интерфейс может быть повторно отображен из различных входных свойств. Ответственность React в этом заключается в том, чтобы как можно быстрее отобразить пользовательский интерфейс с заданным состоянием ввода. Это происходит в два этапа:

  1. согласователь вычисляет минимальный набор изменений, необходимых для того, чтобы текущая страница была равна результату рендеринга.
  2. средство визуализации применяет эти изменения, например, с помощью API браузера, например Node.appendChild

На данный момент мы рассмотрим только функциональные компоненты и сосредоточимся на стадии согласования. React чаще всего используется с помощью JSX, сокращенного HTML-кода:

<div {...props}>{children}</div> // is like:
React.createElement("div", props, children);
<Foo {...props}>{children}</Foo> // is like:
React.createElement(Foo, props, children)
// Usage:
ReactDOM.render(
  <Foo {...props}>{children}</Foo>,
  document.getElementById("root"),
)

Ключевым моментом здесь является то, что <Foo /> на самом деле не вызывает функцию Foo, а просто передает ее React.createElement. ReactDOM.render сообщает согласователю, какой элемент React отрисовывать и в какой элемент DOM он должен быть отрисован (с этого момента элемент относится к элементам React, если не указано иное). К сожалению, программе согласования предстоит проделать много работы, которая при больших обновлениях может привести к нежелательному зависанию браузера, пока он выясняет, что нужно сделать модулю визуализации.

Волокна

Цель волокон - разбить большую операцию сравнения на маленькие фрагменты, возвращая управление браузеру между каждым фрагментом, чтобы приложение могло оставаться интерактивным. Давайте рассмотрим визуализацию простого набора компонентов:

const Inner = ({text}) => {
  const [bold, setBold] = React.useState(false);
  // return a host component
  return <span
    className={bold ? "bold" : "normal" }
    onClick={() => setBold(bold => !bold)}
  >
    Go {text}
  </span>;
}
const Outer = () => {
  // return a host component
  return <div>
    // with some function components as children
    <Inner text="left" />
    <Inner text="forward" />
    <Inner text="right" />
  </div>;
}
ReactDOM.render(<Outer />, document.getElementById("root"));

Это отобразит следующий HTML:

<div>
  <span class="normal">Go left</span>
  <span class="normal">Go forward</span>
  <span class="normal">Go right</span>
</div>

Когда мы вызываем ReactDOM.render, это запускает создание графика волокон, по одному для каждого элемента (как функциональных компонентов, так и основных компонентов). Каждое волокно содержит информацию, необходимую для рендеринга (например, его props), а также от одной до трех ссылок на другие волокна -- return на его родительский элемент, child на его первый дочерний элемент (если он существует) и sibling на следующего брата (если он существует). Волокна также хранят информацию об обновлениях в очереди, запомненных результатах и ​​т. Д., Но эти атрибуты не имеют отношения к потоку управления через граф.

Для этого мы берем дескриптор элемента <div id=”root”/> DOM (наш хост-контейнер) и оборачиваем его как корень волокна. Затем он вызывает updateContainer, который вызывает enqueueUpdate, сохраняя элемент для рендеринга на Fiber, а затем scheduleWork для запуска рабочего цикла.

Помимо первоначального рендеринга, согласователь также активируется в ответ на перехватчик (например, вызов setState из [state, setState] = useState(…)). Когда компонент визуализируется из родительского вызова beginWork, setState получает ссылку на Fiber, использованный для его визуализации. Когда вызывается setState, обновление добавляется в очередь обновлений Fiber, и корневой Fiber планируется, и мы входим в рабочий цикл (через обратный вызов в основном цикле событий).

Рабочий цикл

Рабочий цикл отвечает за обработку отдельных узлов волокна в правильном порядке до тех пор, пока не будет достигнут крайний срок, после чего он отключается и возвращает управление обратно в цикл обработки событий браузера, прежде чем возобновить работу со следующим участком волокна. Он запускает work = performUnitOfWork(work) в цикле, который возвращает следующую единицу работы, которую нужно выполнить, пока не истечет тайм-аут или не вернет работу.

performUnitOfWork(work), в свою очередь, запускает next = beginWork(work), который вызывает renderWithHooks(work) - это та часть, где фактически вводится код пользователя (т.е. тип элемента вызывается в его реквизитах). Потомки передаются в reconcileChildFibers, а результат присваивается work.child. Если метод рендеринга возвращает единственный дочерний элемент, дочерний элемент является одним волокном, в противном случае это его связанный список. Наконец, первый дочерний элемент возвращается как следующая единица работы.

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

Предполагая, что мы достигли самой глубокой точки в нашем дереве (поскольку потомков больше нет, next это null), теперь мы вызываем completeUnitOfWork(work). Это вызовет completeWork в текущем волокне и, если completeWork не вернет больше работы, всех его родителей, пока он не встретит один с оставшимся братом, после чего он вернет этого брата обратно в рабочий цикл. completeWork обычно возвращает null, за исключением случая Suspense Components - он может повторно визуализировать компонент с новым сроком действия, вызывая резервное содержимое.

Примирение

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

Как упоминалось выше, первый визит к компоненту происходит тогда, когда волокно начинает с ним работать. Если компонент является компонентом React, на этом этапе вызываются функции рендеринга его дочерних элементов и согласовывает их. Это сводится к тому, чтобы убедиться, что в родительском волокне есть правильное количество дочерних волокон правильного типа, удаляя старые и создавая новые из результата функции рендеринга по мере необходимости. Второй раз посещение компонента происходит тогда, когда все его дочерние элементы обработаны и могут быть выполнены. На этом этапе любое волокно, соответствующее компоненту хоста или тексту, помечается как имеющее обновления, если это необходимо, в то время как функциональные компоненты не требуют дальнейшей обработки.

Наконец, когда рабочий цикл завершается, мы входим в фазу фиксации, где волокна обрабатываются еще раз, а их обновления фиксируются в DOM.

Саспенс

Suspense - это новая функция в React, построенная на основе Fibers. Его цель - позволить компоненту указать, что он не готов к рендерингу, а затем запустить повторный рендеринг, когда он готов, что позволяет разработчикам использовать асинхронные функции для извлечения данных для компонента. Однако компоненты по-прежнему являются синхронными функциями - для этого React требует, чтобы асинхронная функция была заключена в createResource. Когда асинхронная функция возвращает Promise (строго thenable), ресурс выбрасывает Promise, прерывая оценку функции рендеринга. Promise перехватывается рабочим циклом и имеет зарегистрированный обратный вызов для повторной попытки рендеринга после его завершения, в то время как рабочий цикл продолжается с ближайшего <Suspense /> компонента.

Заключение

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

Волокна против генераторов

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

Одним из вариантов здесь было бы использование генераторов - каждый компонент yield, если он не готов, но решает приостановить себя, и затем, наконец, return завершенное согласование. Первая проблема связана с производительностью, поскольку генераторы JavaScript могут приостанавливаться только сами (они не имеют стека) - в глубоком дереве вам нужно будет вставлять и вставлять каждый промежуточный генератор в стек и из него. Альтернативный подход заключался бы в отслеживании ручного стека с использованием массива, при необходимости подталкивая и выталкивая элементы.

Во-вторых, невозможно сделать снимок состояния генератора [2]. Одна из реализаций может заключаться в том, что прогресс через дочерние элементы Fiber представлен генератором. Итак, выше, Outer будет генератором, который выполняет yield между своими дочерними элементами. Предположим, мы только что закончили согласование вперед, когда таймер истекает, и мы возвращаем управление циклу обработки событий. В этот момент пользователь щелкает вперед, и мы возвращаемся в цикл обработки событий. Реализация генератора не имеет возможности вернуться к предыдущему yield при сохранении состояния его дочерних элементов, в то время как в случае Fibers нам просто нужно изменить родственный указатель left.

Таким образом, Fibers - это гибкий способ реализации как возобновляемого, так и кешируемого выполнения функций. Они позволяют приостановить согласование компонента React, вернуться к предыдущему состоянию и изменить работающее дерево. Понимание структуры Fiber и того, как она соответствует дереву компонентов, также дает некоторое представление о возможных подводных камнях производительности - хотя вставка в дочерние элементы компонента только запускает O(1) манипуляции с DOM, общая операция по-прежнему O(n), поскольку связанный список дочерних элементов еще нужно пройти. Их гибкость открывает множество возможностей - мы уже видели границы неопределенности и ошибок. Следите за обновлениями, чтобы узнать больше о фреймворках пользовательского интерфейса и их реализации.