Skip to content

Commit 4b458c4

Browse files
authored
refactor: Optimize Largest Contentful Paint (#8)
* refactor: Optimize images to improve page load performance - Compress and resize large images - Convert to modern formats (WebP/AVIF) * refactor: Improve audio performance with metadata attribute * refactor: Implement progressive image loading pattern - Add low quality image placeholder - Implement smooth transition to high-res images - Support multiple formats (AVIF/WebP/PNG) with picture element * refactor: Implement lazy/preloading strategy for optimized page loading - Configure code splitting for game-related pages in GameLayout loader - Add preloading for Lobby, GameRoom and Result pages on MainPage button hover - Implement lazy loading for help modal component with hover-based initialization * refactor: Optimize font loading to eliminate render-blocking resources - Add font-display: swap for system font fallback during loading - Implement preload for woff2 font to prioritize loading * fix: Update index.html with proper formatting * refactor: Optimize asynchronous imports - Changed critical imports to use �wait to ensure they are loaded during the loader's execution. - Handled non-critical imports with �oid Promise.all for asynchronous preloading. * fix: Adjust z-index stacking for modal animations and background
1 parent 916a55c commit 4b458c4

File tree

14 files changed

+185
-47
lines changed

14 files changed

+185
-47
lines changed

client/index.html

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@
4242
</script>
4343
<meta charset="UTF-8">
4444
<meta name="viewport" content="width=device-width, initial-scale=1.0">
45+
46+
<link
47+
rel="preload"
48+
href="https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.woff2"
49+
as="font"
50+
type="font/woff2"
51+
crossorigin="anonymous"
52+
>
53+
4554
<meta name="theme-color" content="#7A38FF"> <!-- 브라우저 테마 색상 (violet-500) -->
4655
<meta name="description" content="잘 그렸나? 망쳤나? 정체를 숨긴 방해꾼이 쏘아올린 혼돈 속에서, 그림꾼들의 진실을 찾아내는 구경꾼들의 훈수가 시작됩니다! 🎨🕵️‍♀️">
4756
<meta name="keywords" content="게임, 드로잉, 실시간, 멀티플레이어, 웹게임, 온라인게임, 퀴즈게임">
@@ -91,8 +100,6 @@
91100
<!-- 웹 매니페스트 -->
92101
<link rel="manifest" href="/site.webmanifest">
93102

94-
<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/neodgm/neodgm-pro-webfont@latest/neodgm_pro/style.css" />
95-
96103
<title>방해꾼은 못말려 : 그림꾼들의 역습</title>
97104
</head>
98105

@@ -102,4 +109,4 @@
102109
<script type="module" src="/src/main.tsx"></script>
103110
</body>
104111

105-
</html>
112+
</html>
18.2 KB
Loading
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import tiny from '@/assets/background-tiny.png';
3+
import { CDN } from '@/constants/cdn';
4+
import { cn } from '@/utils/cn';
5+
6+
interface BackgroundImageProps {
7+
className?: string;
8+
}
9+
10+
const BackgroundImage = ({ className }: BackgroundImageProps) => {
11+
const [isLoaded, setIsLoaded] = useState(false);
12+
const imgRef = useRef<HTMLImageElement>(null);
13+
14+
useEffect(() => {
15+
const img = imgRef.current;
16+
if (img) {
17+
img.onload = () => setIsLoaded(true);
18+
}
19+
}, [imgRef.current]);
20+
21+
return (
22+
<>
23+
<div className={cn('absolute inset-0', className)}>
24+
<img
25+
src={tiny}
26+
alt="배경 패턴"
27+
className={cn(
28+
'h-full w-full object-cover transition-opacity duration-300',
29+
isLoaded ? 'opacity-0' : 'opacity-100',
30+
)}
31+
/>
32+
</div>
33+
<picture className={cn('absolute inset-0', className)}>
34+
<source srcSet={CDN.BACKGROUND_IMAGE_AVIF} type="image/avif" />
35+
<source srcSet={CDN.BACKGROUND_IMAGE_WEBP} type="image/webp" />
36+
<img
37+
src={CDN.BACKGROUND_IMAGE_PNG}
38+
alt="배경 패턴"
39+
className={cn(
40+
'h-full w-full object-cover transition-opacity duration-300',
41+
isLoaded ? 'opacity-100' : 'opacity-0',
42+
)}
43+
ref={imgRef}
44+
loading="lazy"
45+
decoding="async"
46+
/>
47+
</picture>
48+
</>
49+
);
50+
};
51+
52+
export default BackgroundImage;

client/src/components/ui/Button.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as React from 'react';
1+
import { ButtonHTMLAttributes, forwardRef } from 'react';
22
import { cva, type VariantProps } from 'class-variance-authority';
33
import { cn } from '@/utils/cn';
44

@@ -23,13 +23,11 @@ const buttonVariants = cva(
2323
},
2424
);
2525

26-
export interface ButtonProps
27-
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
28-
VariantProps<typeof buttonVariants> {
26+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
2927
asChild?: boolean;
3028
}
3129

32-
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, ...props }, ref) => {
30+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, ...props }, ref) => {
3331
return <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
3432
});
3533
Button.displayName = 'Button';

client/src/components/ui/HelpContainer.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,41 @@
1-
import { MouseEvent } from 'react';
1+
import { lazy, MouseEvent, Suspense, useState } from 'react';
22
import helpIcon from '@/assets/help-icon.svg';
3-
import HelpRollingModal from '@/components/modal/HelpRollingModal';
43
import { Button } from '@/components/ui/Button';
54
import { useModal } from '@/hooks/useModal';
65

7-
const HelpContainer = ({}) => {
6+
const HelpRollingModal = lazy(() => import('@/components/modal/HelpRollingModal'));
7+
8+
const HelpContainer = () => {
89
const { isModalOpened, closeModal, openModal, handleKeyDown } = useModal();
10+
const [shouldLoadModal, setShouldLoadModal] = useState(false);
911

1012
const handleOpenHelpModal = (e: MouseEvent<HTMLButtonElement>) => {
1113
e.preventDefault();
12-
1314
openModal();
1415
};
16+
17+
const handleMouseEnter = () => {
18+
setShouldLoadModal(true);
19+
};
20+
1521
return (
1622
<nav className="fixed right-4 top-4 z-30 xs:right-8 xs:top-8">
1723
<Button
1824
variant="transperent"
1925
size="icon"
2026
onClick={handleOpenHelpModal}
27+
onPointerEnter={handleMouseEnter}
2128
aria-label="도움말 보기"
2229
className="hover:brightness-75"
2330
>
2431
<img src={helpIcon} alt="도움말 보기 버튼" />
2532
</Button>
26-
<HelpRollingModal isModalOpened={isModalOpened} handleCloseModal={closeModal} handleKeyDown={handleKeyDown} />
33+
34+
{shouldLoadModal && (
35+
<Suspense fallback={null}>
36+
<HelpRollingModal isModalOpened={isModalOpened} handleCloseModal={closeModal} handleKeyDown={handleKeyDown} />
37+
</Suspense>
38+
)}
2739
</nav>
2840
);
2941
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
2+
import loading from '@/assets/lottie/loading.lottie';
3+
4+
export const Loading = () => {
5+
return (
6+
<div className="flex h-screen w-full items-center justify-center">
7+
<DotLottieReact src={loading} loop autoplay className="h-96 w-96" />
8+
</div>
9+
);
10+
};

client/src/components/ui/Logo.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import * as React from 'react';
1+
import { forwardRef, ImgHTMLAttributes } from 'react';
22
import { cva, type VariantProps } from 'class-variance-authority';
33
import { CDN } from '@/constants/cdn';
44
import { cn } from '@/utils/cn';
@@ -18,47 +18,58 @@ const logoVariants = cva('w-auto', {
1818
export type LogoVariant = 'main' | 'side';
1919

2020
interface LogoInfo {
21-
src: string;
21+
avif: string;
22+
webp: string;
23+
png: string;
2224
alt: string;
2325
description: string;
2426
}
2527

2628
const LOGO_INFO: Record<LogoVariant, LogoInfo> = {
2729
main: {
28-
src: CDN.MAIN_LOGO,
30+
avif: CDN.MAIN_LOGO_AVIF,
31+
webp: CDN.MAIN_LOGO_WEBP,
32+
png: CDN.MAIN_LOGO_PNG,
2933
alt: '메인 로고',
3034
description: '우리 프로젝트를 대표하는 메인 로고 이미지입니다',
3135
},
3236
side: {
33-
src: CDN.SIDE_LOGO,
37+
avif: CDN.SIDE_LOGO_AVIF,
38+
webp: CDN.SIDE_LOGO_WEBP,
39+
png: CDN.MAIN_LOGO_PNG,
3440
alt: '보조 로고',
3541
description: '우리 프로젝트를 대표하는 보조 로고 이미지입니다',
3642
},
3743
} as const;
3844

3945
export interface LogoProps
40-
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'aria-label'>,
46+
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt' | 'aria-label'>,
4147
VariantProps<typeof logoVariants> {
4248
/**
4349
* 로고 이미지 설명을 위한 사용자 정의 aria-label
4450
*/
4551
ariaLabel?: string;
4652
}
4753

48-
const Logo = React.forwardRef<HTMLImageElement, LogoProps>(
49-
({ className, variant = 'main', ariaLabel, ...props }, ref) => {
50-
return (
54+
const Logo = forwardRef<HTMLImageElement, LogoProps>(({ className, variant = 'main', ariaLabel, ...props }, ref) => {
55+
const logoInfo = LOGO_INFO[variant as LogoVariant];
56+
57+
return (
58+
<picture>
59+
<source srcSet={logoInfo.avif} type="image/avif" />
60+
<source srcSet={logoInfo.webp} type="image/webp" />
5161
<img
52-
src={LOGO_INFO[variant as LogoVariant].src}
53-
alt={LOGO_INFO[variant as LogoVariant].alt}
54-
aria-label={ariaLabel ?? LOGO_INFO[variant as LogoVariant].description}
62+
src={logoInfo.png}
63+
alt={logoInfo.alt}
64+
aria-label={ariaLabel ?? logoInfo.description}
5565
className={cn(logoVariants({ variant, className }))}
5666
ref={ref}
5767
{...props}
5868
/>
59-
);
60-
},
61-
);
69+
</picture>
70+
);
71+
});
72+
6273
Logo.displayName = 'Logo';
6374

6475
export { Logo, logoVariants };

client/src/constants/cdn.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,19 @@ const CDN_BASE = 'https://kr.object.ncloudstorage.com/troublepainter-assets';
22

33
export const CDN = {
44
BACKGROUND_MUSIC: `${CDN_BASE}/sounds/background-music.mp3`,
5-
MAIN_LOGO: `${CDN_BASE}/logo/main-logo.png`,
6-
SIDE_LOGO: `${CDN_BASE}/logo/side-logo.png`,
5+
6+
BACKGROUND_IMAGE_PNG: `${CDN_BASE}/patterns/background.png`,
7+
BACKGROUND_IMAGE_WEBP: `${CDN_BASE}/patterns/background.webp`,
8+
BACKGROUND_IMAGE_AVIF: `${CDN_BASE}/patterns/background.avif`,
9+
10+
MAIN_LOGO_PNG: `${CDN_BASE}/logo/main-logo.png`,
11+
MAIN_LOGO_WEBP: `${CDN_BASE}/logo/main-logo.webp`,
12+
MAIN_LOGO_AVIF: `${CDN_BASE}/logo/main-logo.avif`,
13+
14+
SIDE_LOGO_PNG: `${CDN_BASE}/logo/side-logo.png`,
15+
SIDE_LOGO_WEBP: `${CDN_BASE}/logo/side-logo.webp`,
16+
SIDE_LOGO_AVIF: `${CDN_BASE}/logo/side-logo.avif`,
17+
718
// tailwind config 설정
819
// BACKGROUND: `${CDN_BASE}/patterns/background.png`,
920
} as const;

client/src/hooks/useBackgroundMusic.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export const useBackgroundMusic = () => {
4545

4646
useEffect(() => {
4747
audioRef.current = new Audio(CDN.BACKGROUND_MUSIC);
48+
audioRef.current.preload = 'metadata';
4849
audioRef.current.loop = true;
4950
audioRef.current.volume = volume;
5051

client/src/index.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
@tailwind components;
33
@tailwind utilities;
44

5+
@font-face {
6+
font-family: 'NeoDunggeunmo Pro';
7+
src:
8+
url('https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.woff2') format('woff2'),
9+
url('https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.woff') format('woff'),
10+
url('https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.ttf') format('truetype');
11+
font-weight: normal;
12+
font-display: swap;
13+
}
14+
515
@layer components {
616
/* 스크롤바 hide 기능 */
717
/* Hide scrollbar for Chrome, Safari and Opera */

0 commit comments

Comments
 (0)