Trennen der Rendering-Schleife zur Vermeidung von Framedrops bei der Verwendung von React und WebGL
TuBrief 편집팀
2026년 8월 7일
0
Computing/Software원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
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.
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:
next.config.mjs wird das Weglassen unnötiger Module gefördert.{ ssr: false } in next/dynamic blockiert Laufzeitfehler zum Zeitpunkt des Server-Renderings.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 verbannenWenn 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:
current eines useRef geschrieben.useAnimationFrame wird eine eigene requestAnimationFrame-Schleife erstellt.Ä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:
min-h-[400px] im Wrapper-Container verhindert ein Zusammenbrechen des Seitenverhältnisses beim Umschalten auf die Fallback-UI (normale HTML/SVG-Charts etc.).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:
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.