-
Notifications
You must be signed in to change notification settings - Fork 1
차트 컴포넌트 Lazy Loading 및 성능 최적화 리팩토링 #341
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
6335ed4
b5ba8d3
78ba46e
392285b
f2989ae
6836bf0
86f9211
718e5ae
c5d346f
78217b6
e054b7c
197b2c9
44b0a70
fa40264
bcb9978
3feae00
1f68876
df942fc
39f5ac7
87c1da7
59b7c3f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # 📊 @allcll/charts | ||
|
|
||
| `@allcll/charts`는 ALLCLL 서비스에서 사용되는 모든 차트 컴포넌트를 정의하는 공유 라이브러리입니다. `chart.js`와 `react-chartjs-2`를 기반으로 하며, 애플리케이션의 번들 사이즈 최적화를 위해 내부적으로 `React.lazy`와 `Suspense`를 활용합니다. | ||
|
|
||
| ## 🚀 주요 특징 | ||
|
|
||
| - **Lazy Loading 기본 탑재**: 모든 차트는 지연 로딩 처리되어 메인 번들 사이즈를 줄입니다. | ||
| - **자동 스켈레톤(Skeleton) 제공**: 차트 로딩 중에 발생할 수 있는 레이아웃 흔들림(CLS)을 방지합니다. | ||
| - **표준화된 API**: 일관된 인터페이스를 제공합니다. | ||
|
|
||
| ## 📦 패키지 구조 | ||
|
|
||
| ```plaintext | ||
| charts/ | ||
| ├── src/ | ||
| │ ├── components/ # 차트 로직 (Lazy, Suspense 로직 포함) | ||
| │ ├── skeletons/ # 로딩 상태 표시용 스켈레톤 UI | ||
| │ └── index.ts # 엔트리 포인트 (Public API) | ||
| └── package.json | ||
| ``` | ||
|
|
||
| ## 🛠 사용 방법 | ||
|
|
||
| ```tsx | ||
| import { BarChart } from '@allcll/charts'; | ||
|
|
||
| function MyPage() { | ||
| return <BarChart data={data} className="w-full" />; | ||
| } | ||
| ``` | ||
|
|
||
| ## ⚙️ 애플리케이션 빌드 설정 (필수) | ||
|
|
||
| 새로운 애플리케이션(Vite 기반)을 추가할 때, `@allcll/charts` 패키지의 라이브러리 코드를 단일 청크(`vendor-chartjs`)로 묶어 성능을 최적화하려면 각 애플리케이션의 `vite.config.ts`에 아래 설정을 추가하세요. | ||
|
|
||
| ```typescript | ||
| // vite.config.ts | ||
| export default defineConfig({ | ||
| build: { | ||
| rollupOptions: { | ||
| output: { | ||
| manualChunks(id) { | ||
| // chart.js 라이브러리를 별도 청크로 분리 | ||
| if (id.includes('chart.js') || id.includes('react-chartjs-2')) { | ||
| return 'vendor-chartjs'; | ||
| } | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## 💡 개발 가이드 | ||
|
|
||
| - **새로운 차트 추가**: `src/components/`에 차트 구현체를, `src/skeletons/`에 대응되는 스켈레톤을 작성합니다. | ||
| - **엔트리 등록**: `src/components/LazyCharts.tsx`에서 `createLazyChart` 팩토리 함수를 사용하여 등록합니다. | ||
| - **코드 스플리팅**: 위와 같이 `vite.config.ts`에 `manualChunks` 설정을 추가하면, `@allcll/charts`의 Lazy 컴포넌트 호출 시 라이브러리 파일이 `vendor-chartjs`로 자동 분류됩니다. | ||
|
|
||
| ## 🤝 기여하기 | ||
|
|
||
| 자세한 내용은 모노레포 루트의 [기여 가이드라인](../../CONTRIBUTING.md)을 참조하세요. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| { | ||
| "name": "@allcll/charts", | ||
| "private": true, | ||
| "version": "0.0.1", | ||
| "type": "module", | ||
| "sideEffects": false, | ||
| "main": "./src/index.ts", | ||
| "exports": { | ||
| ".": "./src/index.ts" | ||
| }, | ||
| "dependencies": { | ||
| "chart.js": "^4.5.0", | ||
| "react-chartjs-2": "^5.3.0" | ||
| }, | ||
| "peerDependencies": { | ||
| "react": "^18.3.1" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/react": "^19.1.8" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { Bar } from 'react-chartjs-2'; | ||
| import { | ||
| Chart as ChartJS, | ||
| BarElement, | ||
| CategoryScale, | ||
| LinearScale, | ||
| Tooltip, | ||
| Legend, | ||
| type ChartData, | ||
| type ChartOptions, | ||
| } from 'chart.js/auto'; | ||
|
|
||
| ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend); | ||
|
|
||
| export interface BarChartProps { | ||
| data: ChartData<'bar'>; | ||
| options?: ChartOptions<'bar'>; | ||
| className?: string; | ||
| } | ||
|
|
||
| function BarChart({ data, options, className }: BarChartProps) { | ||
| return <Bar data={data} options={options} className={className} />; | ||
| } | ||
|
|
||
| export default BarChart; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { Doughnut } from 'react-chartjs-2'; | ||
| import { Chart as ChartJS, ArcElement, Tooltip, Legend, type ChartData, type ChartOptions } from 'chart.js/auto'; | ||
|
|
||
| ChartJS.register(ArcElement, Tooltip, Legend); | ||
|
|
||
| export interface DoughnutChartProps { | ||
| data: ChartData<'doughnut'>; | ||
| options?: ChartOptions<'doughnut'>; | ||
| className?: string; | ||
| } | ||
|
|
||
| function DoughnutChart({ data, options, className }: DoughnutChartProps) { | ||
| return <Doughnut data={data} options={options} className={className} />; | ||
| } | ||
|
|
||
| export default DoughnutChart; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { lazy, Suspense, ComponentType } from 'react'; | ||
| import { DoughnutChartSkeleton } from '../skeletons/DoughnutChartSkeleton'; | ||
| import { BarChartSkeleton } from '../skeletons/BarChartSkeleton'; | ||
| import { RadarChartSkeleton } from '../skeletons/RadarChartSkeleton'; | ||
| import { MixedChartSkeleton } from '../skeletons/MixedChartSkeleton'; | ||
|
|
||
| interface SkeletonProps { | ||
| className?: string; | ||
| height?: number; | ||
| } | ||
|
|
||
| const createLazyChart = <T extends object>( | ||
| importFn: () => Promise<{ default: ComponentType<T> }>, | ||
| Skeleton: ComponentType<SkeletonProps>, | ||
| ) => { | ||
| const LazyComponent = lazy(importFn); | ||
| return (props: T & SkeletonProps) => ( | ||
| <Suspense fallback={<Skeleton className={props.className} height={props.height} />}> | ||
| <LazyComponent {...props} /> | ||
| </Suspense> | ||
| ); | ||
| }; | ||
|
|
||
| export const DoughnutChart = createLazyChart(() => import('./DoughnutChart'), DoughnutChartSkeleton); | ||
| export const BarChart = createLazyChart(() => import('./BarChart'), BarChartSkeleton); | ||
| export const RadarChart = createLazyChart(() => import('./RadarChart'), RadarChartSkeleton); | ||
| export const MixedChart = createLazyChart(() => import('./MixedChart'), MixedChartSkeleton); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { Chart } from 'react-chartjs-2'; | ||
| import { | ||
| Chart as ChartJS, | ||
| CategoryScale, | ||
| LinearScale, | ||
| BarElement, | ||
| PointElement, | ||
| LineElement, | ||
| Title, | ||
| Tooltip, | ||
| Legend, | ||
| type ChartData, | ||
| type ChartOptions, | ||
| type TooltipItem, | ||
| } from 'chart.js/auto'; | ||
|
|
||
| ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, Title, Tooltip, Legend); | ||
|
|
||
| export type MixedChartType = 'bar' | 'line'; | ||
| export type MixedChartTooltipItem = TooltipItem<MixedChartType>; | ||
|
|
||
| export interface MixedChartProps { | ||
| data: ChartData<MixedChartType>; | ||
| options?: ChartOptions<MixedChartType>; | ||
| } | ||
|
|
||
| function MixedChart({ data, options }: MixedChartProps) { | ||
| return <Chart type="bar" data={data} options={options} />; | ||
| } | ||
|
|
||
| export default MixedChart; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { Radar } from 'react-chartjs-2'; | ||
| import { | ||
| Chart as ChartJS, | ||
| RadialLinearScale, | ||
| PointElement, | ||
| LineElement, | ||
| Filler, | ||
| Tooltip, | ||
| Legend, | ||
| type ChartData, | ||
| type ChartOptions, | ||
| } from 'chart.js/auto'; | ||
|
|
||
| ChartJS.register(RadialLinearScale, PointElement, LineElement, Filler, Tooltip, Legend); | ||
|
|
||
| export interface RadarChartProps { | ||
| data: ChartData<'radar'>; | ||
| options?: ChartOptions<'radar'>; | ||
| className?: string; | ||
| } | ||
|
|
||
| function RadarChart({ data, options, className }: RadarChartProps) { | ||
| return <Radar data={data} options={options} className={className} />; | ||
| } | ||
|
|
||
| export default RadarChart; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| export { DoughnutChart, BarChart, RadarChart, MixedChart } from './components/LazyCharts'; | ||
|
|
||
| export type { DoughnutChartProps } from './components/DoughnutChart'; | ||
| export type { BarChartProps } from './components/BarChart'; | ||
| export type { RadarChartProps } from './components/RadarChart'; | ||
| export type { MixedChartProps, MixedChartType, MixedChartTooltipItem } from './components/MixedChart'; | ||
|
|
||
| export { | ||
| DoughnutChartSkeleton, | ||
| BarChartSkeleton, | ||
| RadarChartSkeleton, | ||
| MixedChartSkeleton, | ||
| } from './skeletons/ChartSkeleton'; | ||
| export type { MixedChartSkeletonProps } from './skeletons/MixedChartSkeleton'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| export function BarChartSkeleton({ className }: { className?: string }) { | ||
|
Check warning on line 1 in packages/charts/src/skeletons/BarChartSkeleton.tsx
|
||
| return ( | ||
| <div | ||
| className={`w-full bg-gray-100 animate-pulse rounded ${className ?? ''}`} | ||
| style={{ aspectRatio: '16 / 9', minHeight: 120 }} | ||
| aria-busy="true" | ||
| aria-label="차트 로딩 중" | ||
| > | ||
| <div className="flex items-end gap-2 h-full px-6 pb-6 pt-4"> | ||
| {[55, 80, 40, 65].map(h => ( | ||
| <div key={h} className="flex-1 bg-gray-300 rounded-t" style={{ height: `${h}%` }} /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export { DoughnutChartSkeleton } from './DoughnutChartSkeleton'; | ||
| export { BarChartSkeleton } from './BarChartSkeleton'; | ||
| export { RadarChartSkeleton } from './RadarChartSkeleton'; | ||
| export { MixedChartSkeleton } from './MixedChartSkeleton'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| export function DoughnutChartSkeleton({ className }: { className?: string }) { | ||
|
Check warning on line 1 in packages/charts/src/skeletons/DoughnutChartSkeleton.tsx
|
||
| return ( | ||
| <div className={`flex items-center justify-center ${className ?? ''}`} aria-busy="true" aria-label="차트 로딩 중"> | ||
| <div className="rounded-full bg-gray-200 animate-pulse" style={{ width: '100%', aspectRatio: '1' }} /> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| export interface MixedChartSkeletonProps { | ||
| className?: string; | ||
| height?: number; | ||
| } | ||
|
|
||
| export function MixedChartSkeleton({ className, height = 384 }: MixedChartSkeletonProps) { | ||
|
Check warning on line 6 in packages/charts/src/skeletons/MixedChartSkeleton.tsx
|
||
| return ( | ||
| <div | ||
| className={`w-full bg-gray-100 animate-pulse rounded ${className ?? ''}`} | ||
| style={{ height }} | ||
| aria-busy="true" | ||
| aria-label="차트 로딩 중" | ||
| > | ||
| <div className="flex items-end gap-1 h-full px-12 pb-12 pt-4"> | ||
| {Array.from({ length: 10 }, (_, i) => ( | ||
| <div key={i} className="flex-1 bg-gray-300 rounded-t" style={{ height: `${25 + (i % 4) * 18}%` }} /> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| export function RadarChartSkeleton({ className }: { className?: string }) { | ||
|
Check warning on line 1 in packages/charts/src/skeletons/RadarChartSkeleton.tsx
|
||
| return ( | ||
| <div className={`flex items-center justify-center ${className ?? ''}`} aria-busy="true" aria-label="차트 로딩 중"> | ||
| <div | ||
| className="bg-gray-200 animate-pulse" | ||
| style={{ width: '100%', aspectRatio: '1', clipPath: 'polygon(50% 0%, 100% 38%, 82% 100%, 18% 100%, 0% 38%)' }} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "compilerOptions": { | ||
| "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", | ||
| "target": "ES2020", | ||
| "useDefineForClassFields": true, | ||
| "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||
| "module": "ESNext", | ||
| "skipLibCheck": true, | ||
|
|
||
| /* Bundler mode */ | ||
| "moduleResolution": "bundler", | ||
| "allowImportingTsExtensions": true, | ||
| "isolatedModules": true, | ||
| "moduleDetection": "force", | ||
| "noEmit": true, | ||
| "jsx": "react-jsx", | ||
|
|
||
| /* Linting */ | ||
| "strict": true, | ||
| "noUnusedLocals": true, | ||
| "noUnusedParameters": true, | ||
| "noFallthroughCasesInSwitch": true, | ||
| "noUncheckedSideEffectImports": true | ||
| }, | ||
| "include": ["src"] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,32 +1,34 @@ | ||
| <!doctype html> | ||
| <html lang="ko"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
|
|
||
| <link rel="icon" href="/favicon.ico" sizes="any" /> | ||
| <link rel="icon" href="/ci.svg" type="image/svg+xml" /> | ||
| <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | ||
| <link rel="manifest" href="/manifest.webmanifest" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
|
|
||
| <title>올클(ALLCLL) | 세종대 수강신청 도우미</title> | ||
| <meta name="description" content="세종대 학생을 위한 수강신청 도우미 올클(ALLCLL). 시간표 관리, 과목 분석, 수강신청 연습, 실시간 여석 알림, 졸업요건 검사까지 한 번에 지원합니다." /> | ||
| <meta name="keywords" content="세종대 수강 신청 여석 관심과목 연습" /> | ||
| <link rel="icon" href="/favicon.ico" sizes="any" /> | ||
| <link rel="icon" href="/ci.svg" type="image/svg+xml" /> | ||
| <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | ||
| <link rel="manifest" href="/manifest.webmanifest" /> | ||
| <link rel="preload" href="/font/woff2/Pretendard-Regular.subset.woff2" as="font" type="font/woff2" crossorigin /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
|
|
||
| <meta property="og:url" content="https://allcll.kr" /> | ||
| <meta property="og:title" content="올클(ALLCLL) | 수강신청 도우미" /> | ||
| <meta property="og:type" content="website" /> | ||
| <meta property="og:image" content="/ogImg.png" /> | ||
| <meta property="og:description" content="세종대 수강신청의 어려움 ALLCLL이 도와드릴게요" /> | ||
| <meta name="google-adsense-account" content="ca-pub-3971325514117679" /> | ||
| <script | ||
| async | ||
| src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3971325514117679" | ||
| crossorigin="anonymous" | ||
| ></script> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="/src/app/main.tsx"></script> | ||
| </body> | ||
| </html> | ||
| <title>올클(ALLCLL) | 세종대 수강신청 도우미</title> | ||
| <meta name="description" | ||
| content="세종대 학생을 위한 수강신청 도우미 올클(ALLCLL). 시간표 관리, 과목 분석, 수강신청 연습, 실시간 여석 알림, 졸업요건 검사까지 한 번에 지원합니다." /> | ||
| <meta name="keywords" content="세종대 수강 신청 여석 관심과목 연습" /> | ||
|
|
||
| <meta property="og:url" content="https://allcll.kr" /> | ||
| <meta property="og:title" content="올클(ALLCLL) | 수강신청 도우미" /> | ||
| <meta property="og:type" content="website" /> | ||
| <meta property="og:image" content="/ogImg.png" /> | ||
| <meta property="og:description" content="세종대 수강신청의 어려움 ALLCLL이 도와드릴게요" /> | ||
| <meta name="google-adsense-account" content="ca-pub-3971325514117679" /> | ||
| <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3971325514117679" | ||
| crossorigin="anonymous"></script> | ||
| </head> | ||
|
|
||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="/src/app/main.tsx"></script> | ||
| </body> | ||
|
|
||
| </html> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StatisticsChartnow passesheight={288}, but this wrapper drops that prop when the lazy chunk resolves becauseMixedChartonly accepts{ data, options }and renders<Chart>without forwarding sizing props. This means the fallback skeleton honors the height while the real chart does not, causing avoidable layout shift and inconsistent chart sizing after load.Useful? React with 👍 / 👎.