Skip to content

Commit

Permalink
refactor: Optimize Largest Contentful Paint (#8)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
choiseona authored Jan 17, 2025
1 parent 916a55c commit 4b458c4
Show file tree
Hide file tree
Showing 14 changed files with 185 additions and 47 deletions.
13 changes: 10 additions & 3 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<link
rel="preload"
href="https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
>

<meta name="theme-color" content="#7A38FF"> <!-- 브라우저 테마 색상 (violet-500) -->
<meta name="description" content="잘 그렸나? 망쳤나? 정체를 숨긴 방해꾼이 쏘아올린 혼돈 속에서, 그림꾼들의 진실을 찾아내는 구경꾼들의 훈수가 시작됩니다! 🎨🕵️‍♀️">
<meta name="keywords" content="게임, 드로잉, 실시간, 멀티플레이어, 웹게임, 온라인게임, 퀴즈게임">
Expand Down Expand Up @@ -91,8 +100,6 @@
<!-- 웹 매니페스트 -->
<link rel="manifest" href="/site.webmanifest">

<link rel="stylesheet" href="//cdn.jsdelivr.net/gh/neodgm/neodgm-pro-webfont@latest/neodgm_pro/style.css" />

<title>방해꾼은 못말려 : 그림꾼들의 역습</title>
</head>

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

</html>
</html>
Binary file added client/src/assets/background-tiny.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions client/src/components/ui/BackgroundImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react';
import tiny from '@/assets/background-tiny.png';
import { CDN } from '@/constants/cdn';
import { cn } from '@/utils/cn';

interface BackgroundImageProps {
className?: string;
}

const BackgroundImage = ({ className }: BackgroundImageProps) => {
const [isLoaded, setIsLoaded] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);

useEffect(() => {
const img = imgRef.current;
if (img) {
img.onload = () => setIsLoaded(true);
}
}, [imgRef.current]);

return (
<>
<div className={cn('absolute inset-0', className)}>
<img
src={tiny}
alt="배경 패턴"
className={cn(
'h-full w-full object-cover transition-opacity duration-300',
isLoaded ? 'opacity-0' : 'opacity-100',
)}
/>
</div>
<picture className={cn('absolute inset-0', className)}>
<source srcSet={CDN.BACKGROUND_IMAGE_AVIF} type="image/avif" />
<source srcSet={CDN.BACKGROUND_IMAGE_WEBP} type="image/webp" />
<img
src={CDN.BACKGROUND_IMAGE_PNG}
alt="배경 패턴"
className={cn(
'h-full w-full object-cover transition-opacity duration-300',
isLoaded ? 'opacity-100' : 'opacity-0',
)}
ref={imgRef}
loading="lazy"
decoding="async"
/>
</picture>
</>
);
};

export default BackgroundImage;
8 changes: 3 additions & 5 deletions client/src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from 'react';
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/utils/cn';

Expand All @@ -23,13 +23,11 @@ const buttonVariants = cva(
},
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, ...props }, ref) => {
const Button = forwardRef<HTMLButtonElement, ButtonProps>(({ className, variant, size, ...props }, ref) => {
return <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
});
Button.displayName = 'Button';
Expand Down
22 changes: 17 additions & 5 deletions client/src/components/ui/HelpContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
import { MouseEvent } from 'react';
import { lazy, MouseEvent, Suspense, useState } from 'react';
import helpIcon from '@/assets/help-icon.svg';
import HelpRollingModal from '@/components/modal/HelpRollingModal';
import { Button } from '@/components/ui/Button';
import { useModal } from '@/hooks/useModal';

const HelpContainer = ({}) => {
const HelpRollingModal = lazy(() => import('@/components/modal/HelpRollingModal'));

const HelpContainer = () => {
const { isModalOpened, closeModal, openModal, handleKeyDown } = useModal();
const [shouldLoadModal, setShouldLoadModal] = useState(false);

const handleOpenHelpModal = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();

openModal();
};

const handleMouseEnter = () => {
setShouldLoadModal(true);
};

return (
<nav className="fixed right-4 top-4 z-30 xs:right-8 xs:top-8">
<Button
variant="transperent"
size="icon"
onClick={handleOpenHelpModal}
onPointerEnter={handleMouseEnter}
aria-label="도움말 보기"
className="hover:brightness-75"
>
<img src={helpIcon} alt="도움말 보기 버튼" />
</Button>
<HelpRollingModal isModalOpened={isModalOpened} handleCloseModal={closeModal} handleKeyDown={handleKeyDown} />

{shouldLoadModal && (
<Suspense fallback={null}>
<HelpRollingModal isModalOpened={isModalOpened} handleCloseModal={closeModal} handleKeyDown={handleKeyDown} />
</Suspense>
)}
</nav>
);
};
Expand Down
10 changes: 10 additions & 0 deletions client/src/components/ui/Loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DotLottieReact } from '@lottiefiles/dotlottie-react';
import loading from '@/assets/lottie/loading.lottie';

export const Loading = () => {
return (
<div className="flex h-screen w-full items-center justify-center">
<DotLottieReact src={loading} loop autoplay className="h-96 w-96" />
</div>
);
};
39 changes: 25 additions & 14 deletions client/src/components/ui/Logo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as React from 'react';
import { forwardRef, ImgHTMLAttributes } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { CDN } from '@/constants/cdn';
import { cn } from '@/utils/cn';
Expand All @@ -18,47 +18,58 @@ const logoVariants = cva('w-auto', {
export type LogoVariant = 'main' | 'side';

interface LogoInfo {
src: string;
avif: string;
webp: string;
png: string;
alt: string;
description: string;
}

const LOGO_INFO: Record<LogoVariant, LogoInfo> = {
main: {
src: CDN.MAIN_LOGO,
avif: CDN.MAIN_LOGO_AVIF,
webp: CDN.MAIN_LOGO_WEBP,
png: CDN.MAIN_LOGO_PNG,
alt: '메인 로고',
description: '우리 프로젝트를 대표하는 메인 로고 이미지입니다',
},
side: {
src: CDN.SIDE_LOGO,
avif: CDN.SIDE_LOGO_AVIF,
webp: CDN.SIDE_LOGO_WEBP,
png: CDN.MAIN_LOGO_PNG,
alt: '보조 로고',
description: '우리 프로젝트를 대표하는 보조 로고 이미지입니다',
},
} as const;

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

const Logo = React.forwardRef<HTMLImageElement, LogoProps>(
({ className, variant = 'main', ariaLabel, ...props }, ref) => {
return (
const Logo = forwardRef<HTMLImageElement, LogoProps>(({ className, variant = 'main', ariaLabel, ...props }, ref) => {
const logoInfo = LOGO_INFO[variant as LogoVariant];

return (
<picture>
<source srcSet={logoInfo.avif} type="image/avif" />
<source srcSet={logoInfo.webp} type="image/webp" />
<img
src={LOGO_INFO[variant as LogoVariant].src}
alt={LOGO_INFO[variant as LogoVariant].alt}
aria-label={ariaLabel ?? LOGO_INFO[variant as LogoVariant].description}
src={logoInfo.png}
alt={logoInfo.alt}
aria-label={ariaLabel ?? logoInfo.description}
className={cn(logoVariants({ variant, className }))}
ref={ref}
{...props}
/>
);
},
);
</picture>
);
});

Logo.displayName = 'Logo';

export { Logo, logoVariants };
15 changes: 13 additions & 2 deletions client/src/constants/cdn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@ const CDN_BASE = 'https://kr.object.ncloudstorage.com/troublepainter-assets';

export const CDN = {
BACKGROUND_MUSIC: `${CDN_BASE}/sounds/background-music.mp3`,
MAIN_LOGO: `${CDN_BASE}/logo/main-logo.png`,
SIDE_LOGO: `${CDN_BASE}/logo/side-logo.png`,

BACKGROUND_IMAGE_PNG: `${CDN_BASE}/patterns/background.png`,
BACKGROUND_IMAGE_WEBP: `${CDN_BASE}/patterns/background.webp`,
BACKGROUND_IMAGE_AVIF: `${CDN_BASE}/patterns/background.avif`,

MAIN_LOGO_PNG: `${CDN_BASE}/logo/main-logo.png`,
MAIN_LOGO_WEBP: `${CDN_BASE}/logo/main-logo.webp`,
MAIN_LOGO_AVIF: `${CDN_BASE}/logo/main-logo.avif`,

SIDE_LOGO_PNG: `${CDN_BASE}/logo/side-logo.png`,
SIDE_LOGO_WEBP: `${CDN_BASE}/logo/side-logo.webp`,
SIDE_LOGO_AVIF: `${CDN_BASE}/logo/side-logo.avif`,

// tailwind config 설정
// BACKGROUND: `${CDN_BASE}/patterns/background.png`,
} as const;
1 change: 1 addition & 0 deletions client/src/hooks/useBackgroundMusic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const useBackgroundMusic = () => {

useEffect(() => {
audioRef.current = new Audio(CDN.BACKGROUND_MUSIC);
audioRef.current.preload = 'metadata';
audioRef.current.loop = true;
audioRef.current.volume = volume;

Expand Down
10 changes: 10 additions & 0 deletions client/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
@tailwind components;
@tailwind utilities;

@font-face {
font-family: 'NeoDunggeunmo Pro';
src:
url('https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.woff2') format('woff2'),
url('https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.woff') format('woff'),
url('https://cdn.jsdelivr.net/gh/neodgm/[email protected]/neodgm_pro/neodgm_pro.ttf') format('truetype');
font-weight: normal;
font-display: swap;
}

@layer components {
/* 스크롤바 hide 기능 */
/* Hide scrollbar for Chrome, Safari and Opera */
Expand Down
5 changes: 3 additions & 2 deletions client/src/layouts/GameLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import loading from '@/assets/lottie/loading.lottie';
import { ChatContatiner } from '@/components/chat/ChatContatiner';
import { NavigationModal } from '@/components/modal/NavigationModal';
import { PlayerCardList } from '@/components/player/PlayerCardList';
import BackgroundImage from '@/components/ui/BackgroundImage';
import { useGameSocket } from '@/hooks/socket/useGameSocket';
import BrowserNavigationGuard from '@/layouts/BrowserNavigationGuard';
import GameHeader from '@/layouts/GameHeader';
Expand All @@ -30,11 +31,11 @@ const GameLayout = () => {
<BrowserNavigationGuard />
<NavigationModal />
<div
className={`relative flex min-h-screen flex-col justify-start bg-gradient-to-b from-violet-950 via-violet-800 to-fuchsia-800 before:absolute before:left-0 before:top-0 before:h-full before:w-full before:bg-patternImg before:bg-cover before:bg-center lg:py-5`}
className={`relative flex min-h-screen flex-col justify-start bg-gradient-to-b from-violet-950 via-violet-800 to-fuchsia-800 before:absolute before:left-0 before:top-0 before:h-full before:w-full before:bg-cover before:bg-center lg:py-5`}
>
<BackgroundImage />
{/* 상단 헤더 */}
<GameHeader />

<main className="mx-auto">
<div
className={cn(
Expand Down
6 changes: 4 additions & 2 deletions client/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { StrictMode } from 'react';
import { StrictMode, Suspense } from 'react';
import { createRoot } from 'react-dom/client';
import '@/index.css';
import { RouterProvider } from 'react-router-dom';
Expand All @@ -8,7 +8,9 @@ import { router } from '@/routes';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
<Suspense fallback={null}>
<RouterProvider router={router} future={{ v7_startTransition: true }} />
</Suspense>
</App>
</StrictMode>,
);
25 changes: 17 additions & 8 deletions client/src/pages/MainPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import Background from '@/components/ui/BackgroundCanvas';
import BackgroundCanvas from '@/components/ui/BackgroundCanvas';
import BackgroundImage from '@/components/ui/BackgroundImage';
import { Button } from '@/components/ui/Button';
import { Logo } from '@/components/ui/Logo';
import { PixelTransitionContainer } from '@/components/ui/PixelTransitionContainer';
Expand All @@ -11,6 +12,15 @@ const MainPage = () => {
const { createRoom, isLoading } = useCreateRoom();
const { isExiting, transitionTo } = usePageTransition();

const preloadGamePage = async () => {
await Promise.all([
import('@/layouts/GameLayout'),
import('@/pages/LobbyPage'),
import('@/pages/GameRoomPage'),
import('@/pages/ResultPage'),
]);
};

useEffect(() => {
// 현재 URL을 루트로 변경
window.history.replaceState(null, '', '/');
Expand All @@ -32,19 +42,18 @@ const MainPage = () => {
isExiting ? 'bg-transparent' : 'bg-gradient-to-b from-violet-950 via-violet-800 to-fuchsia-700',
)}
>
<Background
className={cn(
'before:contents-[""] absolute -z-10 h-full w-full before:absolute before:left-0 before:top-0 before:h-full before:w-full before:bg-patternImg before:bg-cover before:bg-center',
)}
/>
<div className="duration-1000 animate-in fade-in slide-in-from-top-8">
<BackgroundImage className="-z-30" />
<BackgroundCanvas className="pointer-events-auto absolute inset-0 -z-20" />

<div className="-z-10 duration-1000 animate-in fade-in slide-in-from-top-8">
<Logo variant="main" className="w-full transition duration-300 hover:scale-110 hover:brightness-[1.12]" />
</div>

<Button
onClick={() => void handleCreateRoom()}
disabled={isLoading || isExiting}
className="h-12 max-w-72 animate-pulse"
className="-z-10 h-12 max-w-72 animate-pulse"
onPointerEnter={() => void preloadGamePage()}
>
{isLoading || isExiting ? '방 생성중...' : '방 만들기'}
</Button>
Expand Down
Loading

0 comments on commit 4b458c4

Please sign in to comment.