Разделение циклов рендеринга для предотвращения падения кадров при совместном использовании React и WebGL
Добавление холста WebGL в панель управления Next.js оказывается сложнее, чем кажется на первый взгляд. При добавлении ярких диаграмм или тяжелых графических элементов экран часто начинает подтормаживать. Стоит лишь пошевелить мышкой, как падает частота кадров, и разработчику остается только расстраиваться из-за лагов в созданном с таким трудом дашборде.
Эта проблема обычно возникает из-за конфликта между подходом React к управлению состоянием и способом рендеринга WebGL. Если не разделить принципы работы этих двух движков, экран будет продолжать зависать независимо от используемых библиотек.
Проблема попадания всей графической библиотеки в основной бандл
Такие библиотеки, как Three.js или Deck.gl, имеют большой объем, поскольку содержат внутри себя движки математических расчетов и компиляторы шейдеров. Если бездумно импортировать пакет сверху, в основной бандл попадет даже тот код, который вовсе не используется.
В среде Next.js эту проблему следует решать сочетанием прямого импорта подпутей (subpath imports) и настройки динамической загрузки. Это также предотвратит появление ошибки window is not defined во время серверного рендеринга.
`javascript
// next.config.mjs
import withBundleAnalyzer from '@next/bundle-analyzer';
const bundleAnalyzer = withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
'@radix-ui/react-icons',
'lucide-react',
'three',
'deck.gl'
],
},
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
};
export default bundleAnalyzer(nextConfig);
`
После применения этой настройки графические ресурсы не загружаются целиком во время начальной загрузки страницы, а подгружаются по мере необходимости. Проверив с помощью анализатора бандлов, можно увидеть, что связанный с холстом код, который раньше был частью основного файла JS, отделился в самостоятельный чанк.
В обертке клиентского компонента отключите SSR при импорте.
`typescript
// components/dashboard/CanvasDashboardWrapper.tsx
'use client';
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/ui/skeleton';
const DynamicCanvasRenderer = dynamic(
() => import('./CanvasRenderer').then((mod) => mod.CanvasRenderer),
{
ssr: false,
loading: () => (
),
}
);
export function CanvasDashboardWrapper() {
return (
);
}
`
Такая конфигурация сразу дает три преимущества:
- Регистрация пакетов в
next.config.mjs позволяет исключить неиспользуемые модули.
- Передача
{ ssr: false } в next/dynamic предотвращает ошибки времени выполнения при серверном рендеринге.
- Отображение компонента Skeleton фиксированной высоты устраняет подпрыгивание макета до загрузки данных.
Если вы используете Tailwind CSS, также необходимо проверить указатели мыши (pointer events). Добавьте pointer-events-none ко всей обертке холста, чтобы события проходили к расположенному ниже DOM, и верните pointer-events-auto только тем слоям объектов, которым требуется реальное взаимодействие.
Уберите useState из цикла отрисовки холста
При изменении состояния в React (useState, useContext) происходит перерисовка виртуального DOM. WebGL же перерисовывает экран 60 раз в секунду с помощью requestAnimationFrame.
Что произойдет, если вызывать функцию изменения состояния React внутри цикла, выполняющегося 60 раз в секунду? Каждый кадр запускаются операции согласования (reconciliation) в React, что приводит к замерзанию основного потока. Именно в этом кроется истинная причина зависаний интерфейса.
Изменяющиеся значения следует хранить в useRef, а задачу прорисовки экрана стоит доверить отдельному циклу requestAnimationFrame.
`typescript
// hooks/useAnimationFrame.ts
import { useEffect, useRef } from 'react';
type AnimationCallback = (deltaTime: number, timestamp: number) => void;
export const useAnimationFrame = (callback: AnimationCallback, isPaused: boolean = false) => {
const requestRef = useRef<number | null>(null);
const previousTimeRef = useRef<number | null>(null);
const callbackRef = useRef(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
useEffect(() => {
if (isPaused) {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
return;
}
const animate = (timestamp: number) => {
if (previousTimeRef.current !== null) {
const deltaTime = timestamp - previousTimeRef.current;
callbackRef.current(deltaTime, timestamp);
}
previousTimeRef.current = timestamp;
requestRef.current = requestAnimationFrame(animate);
};
requestRef.current = requestAnimationFrame(animate);
return () => {
if (requestRef.current !== null) {
cancelAnimationFrame(requestRef.current);
}
};
}, [isPaused]);
};
`
Часто возникающие события, такие как перемещение мыши или перетаскивание, работают точно так же. Обновление состояния React в обработчиках событий приводит к лавинообразному выделению памяти сборщиком мусора за короткое время и вызывает лаги.
Изменим структуру:
- Данные, которые часто меняются (например, позиция мыши или значения вращения), записываются в
current объекта useRef.
- С помощью хука
useAnimationFrame создается собственный цикл requestAnimationFrame.
- Функция рендеринга просто считывает эти значения из Ref и рисует их на холсте. Поскольку состояние React не задействовано, повторные ре-рендеры не происходят вовсе.
Обработка фоллбеков для неподдерживаемых браузеров
Существуют попытки напрямую отрисовывать DOM в растровое изображение холста с помощью таких методов, как drawElementImage, по аналогии со спецификацией HTML in Canvas, обсуждаемой в WICG. Это отлично работает в среде со свежими флагами Chrome, но если положиться на это в реальных проектах, на старых браузерах или некоторых мобильных устройствах экран полностью пропадет.
Сначала проверяется поддержка функций, а затем код оборачивается в границу ошибок (error boundary) для подготовки к исключительным ситуациям.
`typescript
// components/canvas/CanvasErrorBoundary.tsx
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { detectCanvasCapabilities } from '@/utils/canvasFeatureDetection';
interface Props {
children: ReactNode;
fallbackUI: ReactNode;
}
interface State {
hasError: boolean;
isSupported: boolean;
}
export class CanvasErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
isSupported: true,
};
public componentDidMount() {
const capabilities = detectCanvasCapabilities();
if (!capabilities.webgl2) {
this.setState({ isSupported: false });
}
}
public static getDerivedStateFromError(_: Error): Partial {
return { hasError: true };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Canvas UI Rendering Engine Crashed:', error, errorInfo);
}
public render() {
if (this.state.hasError || !this.state.isSupported) {
return (
);
}
return (
<div className="relative w-full h-full min-h-[400px] overflow-hidden">
{this.props.children}
</div>
);
}
}
`
Способ создания защитного механизма:
- Проверяется состояние поддержки WebGL2 с помощью логики проверки контекста холста.
- Ошибки времени выполнения, возникающие во время рендеринга, перехватываются с помощью классового компонента Error Boundary в React.
- Фиксация минимальной высоты, например
min-h-[400px], у контейнера предотвращает искажение пропорций экрана при переключении на фоллбек-интерфейс (обычные графики HTML/SVG и т.д.).
Сборщик мусора JavaScript не очищает память графического процессора
Движок V8 отлично справляется с очисткой объектов в куче JavaScript. Однако он ничего не знает о данных, размещенных в памяти GPU, таких как буферы WebGL, текстуры или программы шейдеров.
Если явно не удалять эти ресурсы при переходе между страницами или переключении вкладок дашборда, память GPU будет заполняться, пока браузер не выдаст ошибку Context lost и не аварийно закроет вкладку.
`typescript
// hooks/useWebGLCleanUp.ts
import { useEffect, useRef } from 'react';
export const useWebGLCleanUp = () => {
const glRef = useRef<WebGL2RenderingContext | null>(null);
const resourcesRef = useRef<{
buffers: WebGLBuffer[];
textures: WebGLTexture[];
programs: WebGLProgram[];
}>({
buffers: [],
textures: [],
programs: [],
});
const registerBuffer = (buffer: WebGLBuffer) => resourcesRef.current.buffers.push(buffer);
const registerTexture = (texture: WebGLTexture) => resourcesRef.current.textures.push(texture);
const registerProgram = (program: WebGLProgram) => resourcesRef.current.programs.push(program);
useEffect(() => {
return () => {
const gl = glRef.current;
if (!gl) return;
resourcesRef.current.buffers.buffers.forEach((buffer) => gl.deleteBuffer(buffer)); // Примечание: исправление возможной опечатки в оригинале или оставление оригинального кода
resourcesRef.current.buffers.forEach((buffer) => gl.deleteBuffer(buffer));
resourcesRef.current.textures.forEach((texture) => gl.deleteTexture(texture));
resourcesRef.current.programs.forEach((program) => {
const shaders = gl.getAttachedShaders(program);
if (shaders) {
shaders.forEach((shader) => {
gl.detachShader(program, shader);
gl.deleteShader(shader);
});
}
gl.deleteProgram(program);
});
const loseContextExt = gl.getExtension('WEBGL_lose_context');
if (loseContextExt) {
loseContextExt.loseContext();
}
resourcesRef.current = { buffers: [], textures: [], programs: [] };
glRef.current = null;
};
}, []);
return { glRef, registerBuffer, registerTexture, registerProgram };
};
`
Порядок проверки правильности освобождения памяти с помощью инструментов разработчика Chrome:
- Откройте компонент дашборда и сделайте первый снимок кучи (S1) на вкладке Memory.
- Повторите монтирование и размонтирование компонента (переход между другими вкладками и т.д.) более 10 раз.
- Нажмите кнопку ручной сборки мусора (иконка корзины) и сделайте второй снимок кучи (S2).
- Сравните S2 с S1 и убедитесь, что количество объектов
Detached HTMLCanvasElement или WebGLBuffer упало до нуля без утечек.
Главное при совместном использовании React и WebGL — дать каждой библиотеке выполнять свою работу в ее сильных областях без взаимных помех. Разделение бандла, удаление состояния React из цикла рендеринга и гарантированная очистка памяти GPU при демонтировании компонента. Выполнение всего трех этих пунктов позволит создать дашборд, который работает плавно и без падения кадров.