Trennen der Rendering-Schleife zur Vermeidung von Framedrops bei der Verwendung von React und WebGL
Das Einbinden einer WebGL-Canvas in ein Next.js-Dashboard ist kniffliger als gedacht. Bei bunten Charts oder schweren Grafikelementen fängt die Anzeige schnell an zu stottern. Schon das Bewegen der Maus lässt die Framerate einbrechen, und wenn das mühsam erstellte Dashboard ruckelt, ist das für Entwickler natürlich frustrierend.
Dieses Problem entsteht meist dadurch, dass die State-Management-Logik von React und die Rendering-Logik von WebGL miteinander kollidieren. Wenn man die Arbeitsweisen dieser beiden Engines nicht trennt, stottert das Bild weiter, egal wie gut die verwendete Bibliothek ist.
Das Problem, dass die Grafikbibliothek komplett im Haupt-Bundle landet
Bibliotheken wie Three.js oder Deck.gl sind recht groß, da sie intern eine mathematische Rechen-Engine und einen Shader-Compiler mitbringen. Wenn man das Paket unüberlegt ganz oben importiert, landet am Ende auch ungenutzter Code im Haupt-Bundle.
In einer Next.js-Umgebung sollte man diese Last durch eine Kombination aus Direktimporten von Subpfaden und dynamischem Laden reduzieren. Dadurch lässt sich gleichzeitig verhindern, dass während des Server-Side-Renderings der Fehler window is not defined auftritt.
`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);
`
Nach dieser Konfiguration werden die Grafikressourcen beim initialen Laden der Seite nicht mehr auf einmal, sondern bedarfsgerecht geladen. Ein Blick in den Bundle-Analyzer zeigt, dass der zuvor in der Haupt-JS-Datei gebündelte Canvas-Code in einen eigenständigen Chunk ausgelagert wurde.
Der Client-Component-Wrapper wird ohne SSR importiert:
`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 (
);
}
`
Diese Struktur bringt drei sofortige Vorteile:
- Durch die Registrierung der Pakete in
next.config.mjs wird das Weglassen unnötiger Module gefördert.
- Die Angabe von
{ ssr: false } in next/dynamic blockiert Laufzeitfehler zum Zeitpunkt des Server-Renderings.
- Ein Skeleton mit fester Höhe verhindert ein Layout-Springen, bevor die Daten geladen sind.
Bei der Verwendung von Tailwind CSS müssen auch Pointer-Events berücksichtigt werden. Durch das Setzen von pointer-events-none auf den gesamten Canvas-Wrapper werden Ereignisse an das dahinterliegende DOM weitergeleitet, während nur die tatsächlich zu bedienenden Objektschichten wieder pointer-events-auto erhalten.
useState aus der Canvas-Schleife verbannen
Wenn sich in React States (useState, useContext) ändern, wird das Virtual DOM neu gerendert. WebGL hingegen aktualisiert den Bildschirm 60 Mal pro Sekunde über requestAnimationFrame.
Was passiert, wenn man innerhalb einer Schleife, die 60 Mal pro Sekunde läuft, eine React-State-Änderungsfunktion aufruft? Bei jedem Frame greift die Reconciliation-Berechnung von React, wodurch der Main Thread einfriert. Das ist der wahre Grund für ruckelnde Bildschirme.
Sich verändernde Werte sollten in einem useRef gespeichert werden, während die Zeichenaufgabe einer separaten requestAnimationFrame-Schleife überlassen wird.
`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]);
};
`
Häufige Ereignisse wie Mausbewegungen oder Drag-Aktionen funktionieren genauso. Wenn man im Event-Handler den React-State aktualisiert, kommt es zu einer Ballung der Garbage Collection in kürzester Zeit und somit zu Lags.
Die Struktur wird wie folgt angepasst:
- Häufig wechselnde Daten wie Mausposition oder Rotationswerte werden in
current eines useRef geschrieben.
- Mit dem Hook
useAnimationFrame wird eine eigene requestAnimationFrame-Schleife erstellt.
- Die Rendering-Funktion liest diesen Ref-Wert aus und zeichnet ihn schlicht auf das Canvas. Da der React-State nicht berührt wird, findet gar kein Re-Rendering statt.
Fallback-Behandlung für nicht unterstützte Browser
Ähnlich wie bei dem von WICG diskutierten HTML-in-Canvas-Standard gibt es Versuche, das DOM mit Methoden wie drawElementImage direkt auf eine Canvas-Bitmap zu zeichnen. Das funktioniert zwar in modernen Chrome-Flag-Umgebungen gut, bricht aber in der Praxis auf älteren Browsern oder bestimmten Mobilgeräten komplett ein.
Zuerst wird die Funktionsunterstützung geprüft und das Ganze mit einem Error Boundary umschlossen, um auf Ausnahmefälle vorbereitet zu sein.
`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>
);
}
}
`
So wird ein Sicherheitsnetz gespannt:
- Der Status der WebGL2-Unterstützung wird über eine Logik zur Überprüfung des Canvas-Kontexts getestet.
- Laufzeitfehler während des Renderings werden von einem React Class Error Boundary abgefangen.
- Eine feste Mindesthöhe wie
min-h-[400px] im Wrapper-Container verhindert ein Zusammenbrechen des Seitenverhältnisses beim Umschalten auf die Fallback-UI (normale HTML/SVG-Charts etc.).
GPU-Speicher wird nicht vom JavaScript-Garbage-Collector bereinigt
Die V8-Engine räumt Objekte im JavaScript-Heap zuverlässig auf. Sie weiß jedoch nichts von Daten, die im GPU-Speicher abgelegt wurden, wie WebGL-Buffer, Texturen oder Shader-Programme.
Werden diese Ressourcen beim Seitenwechsel oder beim Verlassen eines Dashboard-Tabs nicht explizit gelöscht, läuft der GPU-Speicher voll, bis schließlich ein Context lost-Fehler auftritt und der Browser-Tab abstürzt.
`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.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 };
};
`
Die Schritte zur direkten Überprüfung der Speicherbereinigung über die Chrome DevTools:
- Das Dashboard-Component öffnen und im Memory-Tab einen ersten Heap-Snapshot (S1) erstellen.
- Das Mounten und Unmounten der Component (z. B. durch Wechseln der Tabs) mindestens 10 Mal wiederholen.
- Die manuelle Garbage Collection (Mülleimer-Symbol) ausführen und einen zweiten Heap-Snapshot (S2) aufnehmen.
- S2 mit S1 vergleichen und sicherstellen, dass keine
Detached HTMLCanvasElement- oder WebGLBuffer-Instanzen übrig geblieben sind und die Anzahl auf 0 gesunken ist.
Bei der gemeinsamen Verwendung von React und WebGL ist es der Schlüssel, die jeweiligen Stärken beider Bibliotheken nicht zu beeinträchtigen. Bundles aufteilen, den React-State aus der Rendering-Schleife verbannen und den GPU-Speicher beim Verschwinden einer Component verlässlich leeren. Schon mit diesen drei Schritten lässt sich ein flüssig laufendes Dashboard ganz ohne Framedrops erstellen.