perf: optimize portfolio runtime delivery
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

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:
2026-09-09 12:43:37 +03:30
parent 9143ba8504
commit f34bea05cd
63 changed files with 1296 additions and 1048 deletions

View File

@@ -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} />;
}

View 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>
);
}

View File

@@ -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;
}
}

View File

@@ -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?.();