Я хотел улучшить React

Igor Sukharev - May 1 '23 - - Dev Community

Привет

Я давно пишу код, а React использую более пяти лет.

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

К реализации этих идей я приступил около трех лет назад. Сначала проверил концепцию, потом решил оформить всё в виде библиотеки.

А о том, что из этого вышло, я бы хотел рассказать в этой статье.

Чем хорош React

Во-первых, это подход React в объединении Javascript и HTML в одном коде. У остальных это получилось не так хорошо.

Например, для некоторых фреймворков были изобретены свои микроязыки программирования шаблонов, которые дублируют конструкции языка JavaScript, как: if, else, each...

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

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

Что не так с React

Создание компонента

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

Рассмотрим такой пример:

function CounterButton() {
  const [count, setCount] = useState(0);
  const handleClick = () => setCount(count + 1);
  return (
    <button onClick={handleClick}>
      You clicked {count} times
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Функция CounterButton является конструктором компонента и функцией, обновляющей данные. Такое смешение сфер ответственности - плохая практика, и это порождает множество проблем, о которых сейчас и поговорим.

Обновление компонента

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

В примере функция handleClick является таким объектом. Можно было бы сэкономить ресурсы, если бы эта функция создавалась один раз в конструкторе. Но его нет в функциональных компонентах.

Также функция CounterButton возвращает новый объект виртуального DOM при каждом вызове.

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

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

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

Хуки

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

Кстати, в одной компании, где я работал, была политика - всегда использовать хуки.

Итак:

const handleClick = useCallback(
  () => setCount(count + 1), 
  [count]
);
Enter fullscreen mode Exit fullscreen mode

С одной стороны, хуки позволяют забыть об избыточных обновлениях компонентов, находящихся в дереве ниже. Но с другой стороны, они никак не решают проблемы создания "мусорных" объектов. Так, функция () => setCount(count + 1) создается, чтобы быть отброшенной хуком, если count не изменился. Более того, создается новый объект массива [count].

То же самое происходит и с другими хуками. Например, сравните: constructor(){code();} и useEffect(() => {code();}, []). В первом случае код выполнится один раз в конструкторе. Во втором - на каждом обновлении будут создаваться два лишних объекта.

Также сам механизм хуков является дополнительной логикой, которая влияет на производительность. Но самый главный недостаток хуков, по моему мнению, это их многословность. Как же это невероятно скучно - писать обертки для простейших операции. А еще читабельность кода страдает.

Что в итоге

  • Много лишних объектов создается и удаляется на куче, вызывая ее фрагментацию, перерасход памяти и дефрагментацию, что ухудшает производительность.
  • Вводится больше логики для параллельного режима, которая ухудшает производительность.
  • Дополнительная логика работы хуков также ухудшает производительность.
  • Хуки делают код более многословным и сложным для понимания.

Как улучшить

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

Итак, если упрощенно, нам надо:

  • Улучшить производительность.
  • Уменьшить многословность.
  • Сделать код более явным и простым для понимания.

Конструктор компонента

Начнем с гипотетического примера:

function CounterButton() {
  let count = 0;
  const handleClick = () => {
    count++;
    btn.update();
  };
  const btn = button(
    { click$e: handleClick }, // props
    () => `You clicked ${count} times`, // child
  );
  return btn;
}
Enter fullscreen mode Exit fullscreen mode

Выглядит очень похоже на пример, написанный на React. Для простоты пока опустим JSX.

Единственная неизвестная составляющая здесь - это функция button, в которой происходит вся "магия". Можно представить, как она могла бы работать, исходя из нашего примера.

Нужно, чтобы функция button выполняла следующие действия:

  • Создавала объект DOM HTMLButtonElement.
  • Устанавливала для него обработчик события клика мышки handleClick.
  • Инициировала текст кнопки возвращённым значением лямбда-функции () => `You clicked ${count} times` .
  • Предоставляла возможность обновлять текст кнопки результатом вызова лямбда-функции.

Логично, что функция button должна создать и вернуть объект с двумя свойствами: element и update.

Класс этого объекта мог бы выглядеть так:

class Component {
  get element() {}; // вернуть объект DOM Element
  update() {}; // обновить динамические данные
}
Enter fullscreen mode Exit fullscreen mode

При клике на кнопке происходит увеличение счетчика на единицу count++. Затем вызывается метод btn.update(), который выполняет лямбда-функцию и обновляет текст кнопки.

Использование компонента

Теперь присоединим этот компонент к дереву DOM:

document.body.append(
  CounterButton().element,
);
Enter fullscreen mode Exit fullscreen mode

Сначала вызваем функцию CounterButton, которая создает и возвращает компонент, а затем присоединяем его элемент к дереву DOM.

Теперь кнопка со счетчиком должна отобразиться и корректно считать количество кликов.

Хорошо, еще предположим, что:

  • Помимо button, есть весь набор HTML-функции: h1, div, span...
  • Созданные этими функциями компоненты могут содержать дочерние компоненты и так до бесконечности.
  • При обновлении родительского компонента будут обновлены его дочерние компоненты.

Всё! 🤗

Миссия выполнена.

Результат

Готов поспорить, вы ожидали нечто большее.

Эта концепция работы с компонентами решает все вышеизложенные проблемы, а также сохраняет хорошие черты React.

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

Кстати, в React также нужно вызывать обновления явно. Функция setState и хук useState служат этой цели. Только менее гибко и более ресурсозатратно. Например, вызов setCount(count + 1) вызовет установку переменной через механизм состояния, затем добавит в очередь необходимость обновления через механизм обновлений. Как видно, тут снова происходит смешение сфер ответственности.

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

  • Во-первых, функция создания компонента является его конструктором и вызывается лишь один раз. Соответственно, объекты, созданные внутри функции, также создаются один раз и не нуждаются в таких хаках, как хуки.
  • Нет хуков. Нет дополнительной логики. Все происходит наглядно и читаемо.
  • Параллельный режим становится не нужен, так как, во-первых, логики стало значительно меньше, а во-вторых, мы полностью контролируем процессы создания и обновления и можем легко вставить прорисовку интерфейса там, где нужно.

Fusor (Фьюзор)

Что это такое

Fusor - это простая библиотека, помогающая декларативно создавать и обновлять элементы DOM.

Во Fusor нет дополнительных механизмов для:

  • Свойств
  • Состояния
  • Контекста
  • Жизненного цикла

Fusor - это максимально "легкий" и прозрачный подход "почти без библиотеки" по полной использующий конструкций самого языка Javascript и функции DOM.

Но тем не менее Fusor может полноценно заменить собой React! Как такое возможно? Давайте разбираться.

Экономика должна быть экономной

Fusor - это экономичная библиотека.

Fusor не порождает кучу лишних объектов на куче.

Если взять такой пример:

import { div, p } from '@fusorjs/dom/html';
const wrapper = div(
  p('I am the static text')
);
Enter fullscreen mode Exit fullscreen mode

То переменная wrapper будет содержать объект HTMLDivElement, а не Component, как в примере с кнопкой-счетчиком, так как здесь нет динамических частей.

Если же взять пример с кнопкой и немного его изменить:

import { button } from '@fusorjs/dom/html';
function CounterButton() {
  let count = 0;
  const handleClick = () => {
    count++;
    btn.update();
  };
  const btn = button(
    // props:
    { click$e: handleClick }, 

    // child text nodes:
    'You clicked ', // static
    () => count, // dynamic 
    ' times', // static
  );
  return btn;
}
Enter fullscreen mode Exit fullscreen mode

То можно увидеть, что теперь только один из трех дочерних элементов кнопки является динамическим. А переменная btn будет уже объектом класса Component.

При обновлении точечно будет изменено значение только одной текстовой ноды, к которой привязана лямбда-функция () => count, и только если значение будет отличаться от уже находящегося там.

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

Также динамические данные могут быть в свойствах. Например {class: () => selected ? 'selected' : 'unselected'}.

Жизненный цикл

Жизненный цикл компонентов - это единственный механизм, которого недостаёт для полноценной замены React.

Так как Fusor делает одну вещь и делает её хорошо, то в нём нет логики жизненного цикла. Зато такая логика есть в нативных кастомных элементах.

А во Fusor есть 100% поддержка всех вэб стандартов, в том числе и вэб компонентов. Поэтому можно использовать их для подключения событий жизненного цикла.

Тем не менее для удобства во Fusor добавлен кастомный элемент fusor-life и его компонент-обёртка Life:

import { Life } from '@fusorjs/dom/life';
const wrapper = Life(
  {
    connected$e: () => {},
    disconnected$e: () => {},
    // ... other props
  },
  // ... children
);
Enter fullscreen mode Exit fullscreen mode

Сравните это с механикой жизненного цикла React и обходом дерева компонентов O(n).

Fusor fusor-life React
Mounting constructor connected constructor, getDerivedStateFromProps, render, componentDidMount
Updating update attributeChanged getDerivedStateFromProps, shouldComponentUpdate, render, getSnapshotBeforeUpdate, componentDidUpdate
Unmounting disconnected componentWillUnmount

Кастомизация

Необязательно использовать функции, находящиеся в html, svg, или life. Они существуют, чтобы не пришлось создавать их самостоятельно, а также чтобы показать на их примере, как это делается.

Например, если нужно создать определенный набор HTML-тэгов, можно легко сделать это.

Если нужно использовать одну функцию для всех элементов, то можно использовать функции h для HTML или s для SVG. Например: h('div', props, children). Либо написать другие вариации.

Существует и более гибкая функция create(element, props, children). Используя ее, можно настроить работу JSX с Fusor.

О JSX

Поддержка JSX будет.

Функциональная нотация тоже хороша. Потому что:

  • Это чистый JavaScript с обычными комментариями.
  • Не нужно преобразования, сборки или компиляции.
  • Можно использовать любое количество props и children в любой последовательности.

Ссылки

Полноценные приложения и другие ресурсы:

Заключение

Приложения есть. Примеры основных кейсов использования есть. Покрытие тестами тоже есть.
АПИ стабилизировался достаточно давно. Можно использовать Fusor в продакшене.

npm install @fusorjs/dom
Enter fullscreen mode Exit fullscreen mode

PS: Спасибо всем, кто дочитал до конца! 🤗 ❤️

Fusor vs React

Fusor React
Component constructor Explicit, function Combined with updater in funtion components
Objects in Component Created once Re-created on each update even with memoization
State, effects, refs Variables and functions Complex, hooks subsystem, verbose
Updating components Explicit, flexible Implicit, complex, diffing
DOM Real Virtual
Events Native Synthetic
Life-cycle Native, custom elements Complex, tree walking
. . . .