16 сентября 2017 года вышла React Fiber — новая мажорная версия библиотеки. Помимо добавления новых фич, о которых вы можете почитать здесь, разработчики переписали архитектуру ядра библиотеки. Я как React-разработчик решил разобраться, что за зверь этот Fiber, какие задачи он решает и за счёт чего. Разобрался и пришёл к неоднозначным выводам.
Stack против Fiber
Чтобы понять, что поменяли в новой архитектуре, нужно разобраться в недостатках старой. Для примера рассмотрим следующее демо:
У нас имеется два компонента, исходный код которых вы можете посмотреть здесь Первый компонент работает на старой версии архитектуры, которая называлась Stack, второй — с помощью Fiber. Разница заметна невооруженным глазом: анимация второго компонента работает значительно плавнее анимации первого.
За счёт чего возникает задержка анимации компонента, реализованного на Stack? Давайте откроем вкладку Performance в браузере и посмотрим на поле Frames, а также на время выполнения функции SierpinskiTriangle (под ней мы подразумеваем выполнение метода render компонента SierpinskiTriangle). В этой функции происходит процесс сравнения старого и нового виртуального дерева. От того, насколько быстро выполняется этот процесс, зависит частота смены кадра. В данном случае она равняется 700 ms, и это долго.
Рисунок 1. Работа компонента на ядре Stack
Отсюда мы можем сделать вывод, что основной проблемой старой архитектуры было долгое выполнение метода render компонента SierpinskiTriangle. Ускорить его за счёт какой-то оптимизации самого алгоритма вряд ли удалось бы.
Рисунок 2 иллюстрирует, как React на ядре Fiber отрисовывает компонент. Мы видим, что кадры меняются с частотой один раз в 17 ms. Грубо говоря, Fiber каким-то образом разбивает функцию, которая выполняется долго, на небольшие функции, которые выполняются быстро.
Рисунок 2. Работа компонента на ядре Fiber
Fiber в теории
Как Fiber дробит функцию на части? Для этого необходимо управлять процессом выполнения этой функции, а также предоставлять возможность:
- приоритизировать разные типы работы;
- останавливать работу;
- прерывать работу, если она больше не нужна;
- использовать предыдущие расчёты.
Для реализации вышеперечисленного нам необходимо определить, как разделить работу по сравнению старого и нового DOM деревьев на части.
Если посмотреть на React, то все компоненты в нём являются функциями. А отрисовка React-приложения — это рекурсивный вызов функций от самого младшего компонента до старшего. Мы уже видели, что, если функция изменения нашего компонента долго отрабатывает, то возникает задержка. Для решения данной проблемы мы можем воспользоваться двумя методами, которые предоставляю браузеры:
- requestIdleCallback, который позволяет выполнять расчёты с малым приоритетом, пока главный поток браузера простаивает;
- requestAnimationFrame, которая позволяет сделать запрос на выполнение нашей анимации в следующем фрейме.
В итоге план такой: нам нужно просчитать часть изменения нашего интерфейса по событию requestIdleCallback, и, как только мы будем готовы отрисовать компонент, запросить requestAnimationFrame, в котором это произойдёт. Но всё ещё необходимо как-то прервать выполнение функции сравнения виртуальных деревьев и при этом сохранять промежуточные результаты. Для решения этой проблемы разработчики React решили разработать свою версию стека вызовов. Тогда у них будет возможность останавливать выполнение функций, самостоятельно давать приоритет выполнения функциям, которым он больше необходим, и так далее.
Реимплементации стека вызовов в рамках React-компонентов и есть новый алгоритм Fiber. Преимущество реимплементации стека вызовов в том, что вы можете хранить его в памяти, останавливать и запускать тогда, когда вам это необходимо.
Fiber на практике: поиск числа Фибоначчи
Стандартная реализация поиска
Реализацию поиска числа Фибоначчи с использованием стандартного стека вызовов можно увидеть ниже.
function fib(n) {
if(n <= 2) {
return 1;
} else {
var a = fib(n - 1);
var b = fib(n - 2);
return a + b;
}
}
Сначала разберём, как выполняется функция поиска числа Фибоначчи на обычном стеке вызовов. В качестве примера будем искать третье число.
Итак, в стеке вызовов создаётся кадр стека, в котором будут храниться локальные переменные и аргументы функции. В данном случае кадр стека изначально будет выглядеть таким образом:
Т.к. n > 2, то мы дойдем до следующей строки:
function fib(n) {
if(n <= 2) {
return 1;
} else {
var a = fib(n - 1); // мы находимся здесь
var b = fib(n - 2);
return a + b;
}
}
Здесь вновь будет вызвана функция fib. Создастся новый кадр стека, но n будет уже на единицу меньше, то есть 2. Локальные переменные всё так же будут undefined.
И т.к. n=2, то функция возвращает единицу, а мы возвращаемся обратно на строку 5
function fib(n) {
if(n <= 2) {
return 1;
} else {
var a = fib(n - 1); // а теперь здесь
var b = fib(n - 2);
return a + b;
}
}
Стек вызовов выглядит так:
Далее вызывается функция поиска числа Фибоначчи для переменной b, строка 6. Создаётся новый кадр стека:
function fib(n) {
if(n <= 2) {
return 1;
} else {
var a = fib(n - 1);
var b = fib(n - 2); // мы находимся здесь
return a + b;
}
}
Функция, как и в предыдущем случае, возвращает 1.
Кадр стека выглядит так:
После чего функция возвращает сумму a и b.
Реализация поиска на Fiber
Дисклеймер: В данном случае у нас показано, как исполняется поиск числа Фибоначчи с реимплементацией стека вызовов. Похожим способ реализован Fiber.
function fiberFibonacci(n) {
var fiber = { arg: n, returnAddr: null, a: 0 /* b is tail call */ };
rec: while (true) {
if (fiber.arg <= 2) {
var sum = 1;
while (fiber.returnAddr) {
fiber = fiber.returnAddr;
if (fiber.a === 0) {
fiber.a = sum;
fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
continue rec;
}
sum += fiber.a;
}
return sum;
} else {
fiber = { arg: fiber.arg - 1, returnAddr: fiber, a: 0 };
}
}
}
Изначально у нас создается переменная fiber, который в нашем случае является кадром стека. arg — аргумент нашей функции, returnAddr — адрес возврата, a — значение функции.
Т.к. fiber.arg в нашем случае равен 3, что больше 2, то мы переходим на строку 17,
function fiberFibonacci(n) {
var fiber = { arg: n, returnAddr: null, a: 0 /* b is tail call */ };
rec: while (true) {
if (fiber.arg <= 2) {
var sum = 1;
while (fiber.returnAddr) {
fiber = fiber.returnAddr;
if (fiber.a === 0) {
fiber.a = sum;
fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
continue rec;
}
sum += fiber.a;
}
return sum;
} else {
fiber = { arg: fiber.arg - 1, returnAddr: fiber, a: 0 }; // строка 17
}
}
}
где у нас создаётся новый fiber (кадр стека). В нём мы сохраняем ссылку на предыдущий кадр стека, аргумент на единицу меньше и начальное значение нашего результата. Таким образом, мы воссоздаём стек вызовов, который у нас создавался при рекурсивном вызове обычной функции поиска числа Фибоначчи.
После чего мы в обратную сторону итерируемся по нашему стеку и считаем наше число Фибоначчи. строки 7-15.
var sum = 1;
while (fiber.returnAddr) {
fiber = fiber.returnAddr;
if (fiber.a === 0) {
fiber.a = sum;
fiber = { arg: fiber.arg - 2, returnAddr: fiber, a: 0 };
continue rec;
}
sum += fiber.a;
}
return sum;
Вывод
Стал ли быстрее React после внедрения Fiber? Согласно этому тесту — нет. Он стал даже медленнее примерно в 1,5 раза. Но внедрение новой архитектуры дало возможность рациональнее пользоваться главным потоком браузера, за счёт чего работа анимаций стала плавнее.
Автор: Nikita Filatov