perf: optimize portfolio runtime delivery
Replace the blocking asset loader with progressive rendering, defer expensive media and Three.js work, and reduce hydration and animation costs. Move deployment from a static Nginx export to the standalone Next.js runtime with optimized dynamic images.
This commit is contained in:
@@ -269,7 +269,7 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function AboutRiverScene() {
|
||||
export function AboutRiverScene({ onReady }: { onReady?: () => void }) {
|
||||
const { theme } = useTheme();
|
||||
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} />;
|
||||
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} onReady={onReady} />;
|
||||
}
|
||||
|
||||
40
src/components/scenes/DeferredAboutRiverScene.tsx
Normal file
40
src/components/scenes/DeferredAboutRiverScene.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||
import styles from './Scenes.module.css';
|
||||
|
||||
const AboutRiverScene = lazy(() =>
|
||||
import('./AboutRiverScene').then((module) => ({ default: module.AboutRiverScene })),
|
||||
);
|
||||
|
||||
export function DeferredAboutRiverScene() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host || shouldLoad) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
setShouldLoad(true);
|
||||
observer.disconnect();
|
||||
},
|
||||
{ rootMargin: '600px 0px' },
|
||||
);
|
||||
observer.observe(host);
|
||||
return () => observer.disconnect();
|
||||
}, [shouldLoad]);
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className={styles.aboutSceneBoundary} aria-busy={!isReady} data-looping-motion>
|
||||
{!isReady && <span className={styles.sceneSkeleton} aria-hidden="true" />}
|
||||
{shouldLoad && (
|
||||
<Suspense fallback={null}>
|
||||
<AboutRiverScene onReady={() => setIsReady(true)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,29 @@
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.aboutSceneBoundary {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sceneSkeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(100deg, transparent 24%, color-mix(in srgb, var(--bg-raised) 68%, transparent) 50%, transparent 76%),
|
||||
var(--bg-page);
|
||||
background-size: 200% 100%, auto;
|
||||
animation: sceneSkeletonShimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes sceneSkeletonShimmer {
|
||||
to {
|
||||
background-position: -200% 0, 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.aboutScene::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -61,12 +84,21 @@
|
||||
height: 40rem;
|
||||
}
|
||||
|
||||
.aboutSceneBoundary {
|
||||
bottom: auto;
|
||||
height: 40rem;
|
||||
}
|
||||
|
||||
.aboutScene canvas {
|
||||
opacity: 0.56;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1441px) {
|
||||
.aboutSceneBoundary {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.aboutScene {
|
||||
overflow: visible;
|
||||
left: 50%;
|
||||
@@ -82,3 +114,9 @@
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sceneSkeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface ThreeCanvasProps {
|
||||
className: string;
|
||||
createScene: SceneFactory;
|
||||
theme: SceneTheme;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D) {
|
||||
@@ -49,8 +50,13 @@ function disposeObject(object: THREE.Object3D) {
|
||||
});
|
||||
}
|
||||
|
||||
export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps) {
|
||||
export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCanvasProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => {
|
||||
onReadyRef.current = onReady;
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -63,6 +69,8 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
|
||||
const clock = new THREE.Clock();
|
||||
let frame = 0;
|
||||
let visible = true;
|
||||
let pageVisible = !document.hidden;
|
||||
let hasRendered = false;
|
||||
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
@@ -71,7 +79,13 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
|
||||
renderer.domElement.setAttribute('aria-hidden', 'true');
|
||||
host.appendChild(renderer.domElement);
|
||||
|
||||
const render = () => renderer.render(controller.scene, controller.camera);
|
||||
const render = () => {
|
||||
renderer.render(controller.scene, controller.camera);
|
||||
if (!hasRendered) {
|
||||
hasRendered = true;
|
||||
onReadyRef.current?.();
|
||||
}
|
||||
};
|
||||
const resize = () => {
|
||||
const bounds = host.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
@@ -83,7 +97,7 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
if (!visible || reduceMotion) return;
|
||||
if (!visible || !pageVisible || reduceMotion) return;
|
||||
const delta = Math.min(clock.getDelta(), 0.05);
|
||||
controller.update?.(clock.elapsedTime, delta);
|
||||
render();
|
||||
@@ -97,7 +111,7 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
|
||||
([entry]) => {
|
||||
visible = entry.isIntersecting;
|
||||
window.cancelAnimationFrame(frame);
|
||||
if (visible && !reduceMotion) {
|
||||
if (visible && pageVisible && !reduceMotion) {
|
||||
clock.getDelta();
|
||||
frame = window.requestAnimationFrame(animate);
|
||||
}
|
||||
@@ -106,6 +120,16 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
|
||||
);
|
||||
visibilityObserver.observe(host);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
pageVisible = !document.hidden;
|
||||
window.cancelAnimationFrame(frame);
|
||||
if (visible && pageVisible && !reduceMotion) {
|
||||
clock.getDelta();
|
||||
frame = window.requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (reduceMotion || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
||||
pointer.set(
|
||||
@@ -121,6 +145,7 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
resizeObserver.disconnect();
|
||||
visibilityObserver.disconnect();
|
||||
controller.dispose?.();
|
||||
|
||||
Reference in New Issue
Block a user