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;
}
Преимущества подхода
- Совместимость с Concurrent Mode: Нет блокирующих операций в useEffect
- Поддержка Suspense: Загрузка данных работает как в обычном React
- Интерактивность: Можно реализовать event delegation на Canvas
- Гибкость: Возможность кастомного рендеринга для специфичных сценариев
Практическое применение: 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>
);
};
Ограничения и подводные камни
- Производительность: Для сложных сцен потребуется оптимизация
- Отладка: Нет React DevTools для canvas-элементов
- Доступность: Необходимо реализовывать a11y вручную
- События: Требуется кастомная система обработки событий
Заключение: Когда это стоит использовать?
Этот подход - не silver bullet, но мощный инструмент для специфичных сценариев:
- Высокопроизводительные игры и анимации
- Кастомные визуализации (графы, диаграммы)
- Эксперименты с новыми парадигмами рендеринга
Эксперимент демонстрирует гибкость React и открывает интересные возможности для интеграции с низкоуровневыми API рендеринга. Хотя решение пока сырое, оно показывает перспективное направление для разработки высокопроизводительных React-приложений вне DOM.