Intercepting React Reconciler через Portal и Fake DOM для рендеринга UI на Canvas

#react#canvas#reconciler#performance#experimental

TL;DR: Исследуем нестандартный подход рендеринга React-компонентов на Canvas без react-reconciler, используя Portal и поддельный DOM. Это позволяет сохранить Suspense и Concurrent Mode, избегая традиционного подхода с useEffect для отрисовки.

Введение: Зачем это нужно?

В классическом React-приложении мы привыкли к Virtual DOM и его reconciliation process. Но что если нам нужно рендерить UI не в DOM, а на Canvas? Традиционные подходы с useEffect и ручной отрисовкой ломают Suspense и Concurrent Features.

Автор эксперимента предлагает альтернативу - перехватывать вызовы reconciler’а через React Portal и специально созданный fake DOM, сохраняя все преимущества React 18+.

Основная механика подхода

1. Создание Fake DOM

class FakeElement {
  constructor(type) {
    this.type = type;
    this.props = {};
    this.children = [];
    this._canvasCtx = null;
  }

  setAttribute(name, value) {
    this.props[name] = value;
  }

  appendChild(child) {
    this.children.push(child);
  }
}

const fakeDocument = {
  createElement: (type) => new FakeElement(type),
};

2. Перехват рендеринга через Portal

const CanvasRenderer = ({ children }) => {
  const [fakeRoot] = useState(() => fakeDocument.createElement('div'));
  const canvasRef = useRef(null);

  useLayoutEffect(() => {
    const ctx = canvasRef.current.getContext('2d');
    // Привязываем контекст ко всем элементам
    traverseFakeTree(fakeRoot, (el) => {
      el._canvasCtx = ctx;
    });
  }, []);

  return (
    <>
      <canvas ref={canvasRef} />
      {createPortal(children, fakeRoot)}
    </>
  );
};

3. Кастомный reconciliation

Когда React пытается обновить fake DOM, мы перехватываем эти изменения и конвертируем их в canvas-операции:

function applyFakeUpdate(fakeElement, newProps) {
  // Оптимизация: пропускаем ненужные обновления
  if (shallowEqual(fakeElement.props, newProps)) return;

  // Специальная логика для разных типов элементов
  switch (fakeElement.type) {
    case 'rect':
      drawRect(fakeElement._canvasCtx, newProps);
      break;
    case 'text':
      drawText(fakeElement._canvasCtx, newProps);
      break;
    // ... другие типы
  }

  fakeElement.props = newProps;
}

Преимущества подхода

  1. Совместимость с Concurrent Mode: Нет блокирующих операций в useEffect
  2. Поддержка Suspense: Загрузка данных работает как в обычном React
  3. Интерактивность: Можно реализовать event delegation на Canvas
  4. Гибкость: Возможность кастомного рендеринга для специфичных сценариев

Практическое применение: Game UI

Рассмотрим пример реализации игрового интерфейса:

const GameHUD = () => {
  const [health, setHealth] = useState(100);
  const [score, setScore] = useState(0);

  useGameLoop(() => {
    // Обновление состояния в реальном времени
    setHealth(player.health);
    setScore(player.score);
  });

  return (
    <CanvasRenderer>
      <rect x={10} y={10} width={health * 2} height={20} fill="red" />
      <text x={20} y={40} fill="white" fontSize={24}>
        Score: {score}
      </text>
      <Suspense fallback={<text x={20} y={70}>Loading achievements...</text>}>
        <AchievementsList />
      </Suspense>
    </CanvasRenderer>
  );
};

Ограничения и подводные камни

  1. Производительность: Для сложных сцен потребуется оптимизация
  2. Отладка: Нет React DevTools для canvas-элементов
  3. Доступность: Необходимо реализовывать a11y вручную
  4. События: Требуется кастомная система обработки событий

Заключение: Когда это стоит использовать?

Этот подход - не silver bullet, но мощный инструмент для специфичных сценариев:

Эксперимент демонстрирует гибкость React и открывает интересные возможности для интеграции с низкоуровневыми API рендеринга. Хотя решение пока сырое, оно показывает перспективное направление для разработки высокопроизводительных React-приложений вне DOM.


Источник: https://vezaynk.github.io/react-direct-canvas/