diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..27f0d58 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,57 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/README.md b/README.md new file mode 100644 index 0000000..3208f9b --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# 리액트 성능 최적화 +Promise.all의 await을 제거하고 컴포넌트를 쪼개고 Context를 작게 가져가고 메모이제이션을 해 렌더링 최적화를 진행했다. +렌더링시마다 실행되는 무거운 연산도 메모이제이션하고 중복 호출 제거, api 호출 캐싱 처리 등. +드래그&드롭 역시 최적화를 진행했다. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 79e1e76..50b4d61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ +packages: + - '.' + onlyBuiltDependencies: - '@swc/core' - msw diff --git a/src/App.tsx b/src/App.tsx index 664bf6d..3878669 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,13 @@ import { ChakraProvider } from "@chakra-ui/react"; import { ScheduleProvider } from "./ScheduleContext.tsx"; import { ScheduleTables } from "./ScheduleTables.tsx"; -import ScheduleDndProvider from "./ScheduleDndProvider.tsx"; function App() { return ( - - - + ); diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx index 529f0dd..fcc98d5 100644 --- a/src/ScheduleContext.tsx +++ b/src/ScheduleContext.tsx @@ -1,4 +1,5 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; +/* eslint-disable react-refresh/only-export-components */ +import React, { createContext, PropsWithChildren, useContext, useMemo, useState } from "react"; import { Schedule } from "./types.ts"; import dummyScheduleMap from "./dummyScheduleMap.ts"; @@ -20,8 +21,10 @@ export const useScheduleContext = () => { export const ScheduleProvider = ({ children }: PropsWithChildren) => { const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); + const value = useMemo(() => ({ schedulesMap, setSchedulesMap }), [schedulesMap]); + return ( - + {children} ); diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx index ca15f52..f4489ef 100644 --- a/src/ScheduleDndProvider.tsx +++ b/src/ScheduleDndProvider.tsx @@ -1,5 +1,11 @@ -import { DndContext, Modifier, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { PropsWithChildren } from "react"; +import { + DndContext, + Modifier, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { PropsWithChildren, useCallback } from "react"; import { CellSize, DAY_LABELS } from "./constants.ts"; import { useScheduleContext } from "./ScheduleContext.tsx"; @@ -17,19 +23,30 @@ function createSnapModifier(): Modifier { const maxX = containerRight - right; const maxY = containerBottom - bottom; - - return ({ + return { ...transform, - x: Math.min(Math.max(Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, minX), maxX), - y: Math.min(Math.max(Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, minY), maxY), - }) + x: Math.min( + Math.max( + Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, + minX + ), + maxX + ), + y: Math.min( + Math.max( + Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, + minY + ), + maxY + ), + }; }; } -const modifiers = [createSnapModifier()] +const modifiers = [createSnapModifier()]; export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); + const { setSchedulesMap } = useScheduleContext(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -38,33 +55,45 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) { }) ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleDragEnd = (event: any) => { - const { active, delta } = event; - const { x, y } = delta; - const [tableId, index] = active.id.split(':'); - const schedule = schedulesMap[tableId][index]; - const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]) - const moveDayIndex = Math.floor(x / 80); - const moveTimeIndex = Math.floor(y / 30); + const handleDragEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event: any) => { + const { active, delta } = event; + const { x, y } = delta; + const [tableId, index] = active.id.split(":"); + const moveDayIndex = Math.floor(x / 80); + const moveTimeIndex = Math.floor(y / 30); + + setSchedulesMap((prev) => { + const schedule = prev[tableId][index]; + const nowDayIndex = DAY_LABELS.indexOf( + schedule.day as (typeof DAY_LABELS)[number] + ); - setSchedulesMap({ - ...schedulesMap, - [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => { - if (targetIndex !== Number(index)) { - return { ...targetSchedule } - } return { - ...targetSchedule, - day: DAY_LABELS[nowDayIndex + moveDayIndex], - range: targetSchedule.range.map(time => time + moveTimeIndex), - } - }) - }) - }; + ...prev, + [tableId]: prev[tableId].map((targetSchedule, targetIndex) => { + if (targetIndex !== Number(index)) { + return targetSchedule; + } + return { + ...targetSchedule, + day: DAY_LABELS[nowDayIndex + moveDayIndex], + range: targetSchedule.range.map((time) => time + moveTimeIndex), + }; + }), + }; + }); + }, + [setSchedulesMap] + ); return ( - + {children} ); diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx index ea17b6a..01fcb35 100644 --- a/src/ScheduleTable.tsx +++ b/src/ScheduleTable.tsx @@ -17,13 +17,13 @@ import { Schedule } from "./types.ts"; import { fill2, parseHnM } from "./utils.ts"; import { useDndContext, useDraggable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; +import { ComponentProps, Fragment, memo, useCallback, useMemo } from "react"; interface Props { tableId: string; schedules: Schedule[]; - onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void; - onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void; + onScheduleTimeClick?: (timeInfo: { day: string; time: number }) => void; + onDeleteButtonClick?: (timeInfo: { day: string; time: number }) => void; } const TIMES = [ @@ -38,143 +38,191 @@ const TIMES = [ .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), ] as const; -const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { +const TableOutline = memo( + ({ tableId, children }: { tableId: string; children: React.ReactNode }) => { + const dndContext = useDndContext(); - const getColor = (lectureId: string): string => { - const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; - const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; + const activeTableId = useMemo(() => { + const activeId = dndContext.active?.id; + if (activeId) { + return String(activeId).split(":")[0]; + } + return null; + }, [dndContext.active?.id]); - const dndContext = useDndContext(); - - const getActiveTableId = () => { - const activeId = dndContext.active?.id; - if (activeId) { - return String(activeId).split(":")[0]; - } - return null; + return ( + + {children} + + ); } +); - const activeTableId = getActiveTableId(); - +// 정적인 테이블 그리드를 별도 컴포넌트로 분리 - 절대 리렌더링되지 않음 +const TableGrid = memo(({ + onScheduleTimeClick +}: { + onScheduleTimeClick?: (timeInfo: { day: string; time: number }) => void; +}) => { return ( - - - - - 교시 + + + 교시 + + + {DAY_LABELS.map((day) => ( + + + {day} - {DAY_LABELS.map((day) => ( - + ))} + {TIMES.map((time, timeIndex) => ( + + 17 ? "gray.200" : "gray.100"} + > - {day} + + {fill2(timeIndex + 1)} ({time}) + - ))} - {TIMES.map((time, timeIndex) => ( - + {DAY_LABELS.map((day) => ( 17 ? 'gray.200' : 'gray.100'} - > - - {fill2(timeIndex + 1)} ({time}) - - - {DAY_LABELS.map((day) => ( - 17 ? 'gray.100' : 'white'} - cursor="pointer" - _hover={{ bg: 'yellow.100' }} - onClick={() => onScheduleTimeClick?.({ day, time: timeIndex + 1 })} - /> - ))} - - ))} - - - {schedules.map((schedule, index) => ( - onDeleteButtonClick?.({ - day: schedule.day, - time: schedule.range[0], - })} - /> + bg={timeIndex > 17 ? "gray.100" : "white"} + cursor="pointer" + _hover={{ bg: "yellow.100" }} + onClick={() => + onScheduleTimeClick?.({ day, time: timeIndex + 1 }) + } + /> + ))} + ))} - + ); -}; +}); -const DraggableSchedule = ({ - id, - data, - bg, - onDeleteButtonClick -}: { id: string; data: Schedule } & ComponentProps & { - onDeleteButtonClick: () => void -}) => { - const { day, range, room, lecture } = data; - const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); - const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); - const topIndex = range[0] - 1; - const size = range.length; +const ScheduleTable = memo( + ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + const lectureColors = useMemo(() => { + const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; + const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; + const colorMap: Record = {}; + lectures.forEach((lectureId, index) => { + colorMap[lectureId] = colors[index % colors.length]; + }); + return colorMap; + }, [schedules]); - return ( - - - - {lecture.title} - {room} - - - event.stopPropagation()}> - - - - 강의를 삭제하시겠습니까? - - 삭제 - - - - - ); -} + const getColor = useCallback( + (lectureId: string): string => { + return lectureColors[lectureId]; + }, + [lectureColors] + ); + + return ( + + + + {schedules.map((schedule, index) => ( + + onDeleteButtonClick?.({ + day: schedule.day, + time: schedule.range[0], + }) + } + /> + ))} + + ); + } +); + +const DraggableSchedule = memo( + ({ + id, + data, + bg, + onDeleteButtonClick, + }: { id: string; data: Schedule } & ComponentProps & { + onDeleteButtonClick: () => void; + }) => { + const { day, range, room, lecture } = data; + const { attributes, setNodeRef, listeners, transform } = useDraggable({ + id, + }); + const leftIndex = DAY_LABELS.indexOf(day as (typeof DAY_LABELS)[number]); + const topIndex = range[0] - 1; + const size = range.length; + + return ( + + + + + {lecture.title} + + {room} + + + event.stopPropagation()}> + + + + 강의를 삭제하시겠습니까? + + 삭제 + + + + + ); + } +); export default ScheduleTable; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx index 44dbd7a..54afa39 100644 --- a/src/ScheduleTables.tsx +++ b/src/ScheduleTables.tsx @@ -2,7 +2,161 @@ import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; import ScheduleTable from "./ScheduleTable.tsx"; import { useScheduleContext } from "./ScheduleContext.tsx"; import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; +import { memo, useCallback, useMemo, useState } from "react"; +import { Schedule } from "./types.ts"; +import { + DndContext, + DragEndEvent, + Modifier, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { CellSize, DAY_LABELS } from "./constants.ts"; + +function createSnapModifier(): Modifier { + return ({ transform, containerNodeRect, draggingNodeRect }) => { + const containerTop = containerNodeRect?.top ?? 0; + const containerLeft = containerNodeRect?.left ?? 0; + const containerBottom = containerNodeRect?.bottom ?? 0; + const containerRight = containerNodeRect?.right ?? 0; + + const { top = 0, left = 0, bottom = 0, right = 0 } = draggingNodeRect ?? {}; + + const minX = containerLeft - left + 120 + 1; + const minY = containerTop - top + 40 + 1; + const maxX = containerRight - right; + const maxY = containerBottom - bottom; + + return { + ...transform, + x: Math.min( + Math.max( + Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, + minX + ), + maxX + ), + y: Math.min( + Math.max( + Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, + minY + ), + maxY + ), + }; + }; +} + +const modifiers = [createSnapModifier()]; + +const ScheduleTableItem = memo( + ({ + tableId, + schedules, + index, + disabledRemoveButton, + onOpenSearch, + onDuplicate, + onRemove, + onScheduleTimeClick, + onDeleteSchedule, + onDragEnd, + }: { + tableId: string; + schedules: Schedule[]; + index: number; + disabledRemoveButton: boolean; + onOpenSearch: (tableId: string) => void; + onDuplicate: (tableId: string) => void; + onRemove: (tableId: string) => void; + onScheduleTimeClick: ( + tableId: string, + timeInfo: { day: string; time: number } + ) => void; + onDeleteSchedule: (tableId: string, day: string, time: number) => void; + onDragEnd: (tableId: string, event: DragEndEvent) => void; + }) => { + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const handleDragEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event: any) => { + onDragEnd(tableId, event); + }, + [tableId, onDragEnd] + ); + + const handleOpenSearch = useCallback(() => { + onOpenSearch(tableId); + }, [tableId, onOpenSearch]); + + const handleDuplicate = useCallback(() => { + onDuplicate(tableId); + }, [tableId, onDuplicate]); + + const handleRemove = useCallback(() => { + onRemove(tableId); + }, [tableId, onRemove]); + + const handleScheduleTimeClick = useCallback( + (timeInfo: { day: string; time: number }) => { + onScheduleTimeClick(tableId, timeInfo); + }, + [tableId, onScheduleTimeClick] + ); + + const handleDeleteButtonClick = useCallback( + ({ day, time }: { day: string; time: number }) => { + onDeleteSchedule(tableId, day, time); + }, + [tableId, onDeleteSchedule] + ); + + return ( + + + + 시간표 {index + 1} + + + + 시간표 추가 + + + 복제 + + + 삭제 + + + + + + + + ); + } +); export const ScheduleTables = () => { const { schedulesMap, setSchedulesMap } = useScheduleContext(); @@ -12,50 +166,111 @@ export const ScheduleTables = () => { time?: number; } | null>(null); - const disabledRemoveButton = Object.keys(schedulesMap).length === 1; + const disabledRemoveButton = useMemo( + () => Object.keys(schedulesMap).length === 1, + [schedulesMap] + ); - const duplicate = (targetId: string) => { - setSchedulesMap(prev => ({ - ...prev, - [`schedule-${Date.now()}`]: [...prev[targetId]] - })) - }; + const duplicate = useCallback( + (targetId: string) => { + setSchedulesMap((prev) => ({ + ...prev, + [`schedule-${Date.now()}`]: [...prev[targetId]], + })); + }, + [setSchedulesMap] + ); - const remove = (targetId: string) => { - setSchedulesMap(prev => { - delete prev[targetId]; - return { ...prev }; - }) - }; + const remove = useCallback( + (targetId: string) => { + setSchedulesMap((prev) => { + delete prev[targetId]; + return { ...prev }; + }); + }, + [setSchedulesMap] + ); + + const handleOpenSearch = useCallback((tableId: string) => { + setSearchInfo({ tableId }); + }, []); + + const handleScheduleTimeClick = useCallback( + (tableId: string, timeInfo: { day: string; time: number }) => { + setSearchInfo({ tableId, ...timeInfo }); + }, + [] + ); + + const handleDeleteSchedule = useCallback( + (tableId: string, day: string, time: number) => { + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].filter( + (schedule) => schedule.day !== day || !schedule.range.includes(time) + ), + })); + }, + [setSchedulesMap] + ); + + const handleDragEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (tableId: string, event: any) => { + const { active, delta } = event; + const { x, y } = delta; + const [, scheduleIndex] = active.id.split(":"); + const moveDayIndex = Math.floor(x / 80); + const moveTimeIndex = Math.floor(y / 30); + + setSchedulesMap((prev) => { + const schedule = prev[tableId][scheduleIndex]; + const nowDayIndex = DAY_LABELS.indexOf( + schedule.day as (typeof DAY_LABELS)[number] + ); + + return { + ...prev, + [tableId]: prev[tableId].map((targetSchedule, targetIndex) => { + if (targetIndex !== Number(scheduleIndex)) { + return targetSchedule; + } + return { + ...targetSchedule, + day: DAY_LABELS[nowDayIndex + moveDayIndex], + range: targetSchedule.range.map((time) => time + moveTimeIndex), + }; + }), + }; + }); + }, + [setSchedulesMap] + ); + + const handleCloseSearch = useCallback(() => { + setSearchInfo(null); + }, []); return ( <> {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( - - - 시간표 {index + 1} - - setSearchInfo({ tableId })}>시간표 추가 - duplicate(tableId)}>복제 - remove(tableId)}>삭제 - - - setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} - /> - + ))} - setSearchInfo(null)}/> + > ); -} +}; diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx index 593951f..5252978 100644 --- a/src/SearchDialog.tsx +++ b/src/SearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, @@ -45,12 +45,12 @@ interface Props { } interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; } const TIME_SLOTS = [ @@ -82,18 +82,280 @@ const TIME_SLOTS = [ const PAGE_SIZE = 100; -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); +const MajorCheckboxItem = memo(({ major }: { major: string }) => { + return ( + + + {major.replace(//gi, " ")} + + + ); +}); + +const LectureTableRow = memo( + ({ + lecture, + onAddSchedule, + }: { + lecture: Lecture; + onAddSchedule: (lecture: Lecture) => void; + }) => { + return ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + onAddSchedule(lecture)} + > + 추가 + + + + ); + } +); + +const SearchQueryInput = memo(({ + value, + onChange +}: { + value: string; + onChange: (value: string) => void; +}) => ( + + 검색어 + onChange(e.target.value)} + /> + +)); + +const CreditsSelect = memo(({ + value, + onChange +}: { + value?: number; + onChange: (value: string) => void; +}) => ( + + 학점 + onChange(e.target.value)} + > + 전체 + 1학점 + 2학점 + 3학점 + + +)); + +const GradesCheckbox = memo(({ + value, + onChange +}: { + value: number[]; + onChange: (value: number[]) => void; +}) => ( + + 학년 + onChange(value.map(Number))} + > + + {[1, 2, 3, 4].map((grade) => ( + + {grade}학년 + + ))} + + + +)); + +const DaysCheckbox = memo(({ + value, + onChange +}: { + value: string[]; + onChange: (value: string[]) => void; +}) => ( + + 요일 + onChange(value as string[])} + > + + {DAY_LABELS.map((day) => ( + + {day} + + ))} + + + +)); + +const TimesCheckbox = memo(({ + value, + onChange +}: { + value: number[]; + onChange: (value: number[]) => void; +}) => ( + + 시간 + onChange(values.map(Number))} + > + + {value + .sort((a, b) => a - b) + .map((time) => ( + + {time}교시 + onChange(value.filter((v) => v !== time))} + /> + + ))} + + + {TIME_SLOTS.map(({ id, label }) => ( + + + {id}교시({label}) + + + ))} + + + +)); + +const MajorsCheckbox = memo(({ + value, + onChange, + allMajors +}: { + value: string[]; + onChange: (value: string[]) => void; + allMajors: string[]; +}) => ( + + 전공 + onChange(values as string[])} + > + + {value.map((major) => ( + + {major.split("").pop()} + onChange(value.filter((v) => v !== major))} + /> + + ))} + + + {allMajors.map((major) => ( + + ))} + + + +)); + +const fetchMajors = (() => { + let cache: Lecture[] | null = null; + let pending: Promise | null = null; + + return async () => { + if (cache !== null) return cache; + + if (pending !== null) return pending; -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); + pending = axios.get(`${import.meta.env.BASE_URL}schedules-majors.json`).then((response) => { + cache = response.data as Lecture[]; + pending = null; + return cache; + }); + + return pending; + }; +})(); + +const fetchLiberalArts = (() => { + let cache: Lecture[] | null = null; + let pending: Promise | null = null; + + return async () => { + if (cache !== null) { + return cache; + } + + if (pending !== null) return pending; + + pending = axios.get(`${import.meta.env.BASE_URL}schedules-liberal-arts.json`).then((response) => { + cache = response.data as Lecture[]; + pending = null; + return cache; + }); + + return pending; + }; +})(); + +const fetchAllLectures = async () => + await Promise.all([ + (console.log("API Call 1", performance.now()), fetchMajors()), + (console.log("API Call 2", performance.now()), fetchLiberalArts()), + ]); // TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. const SearchDialog = ({ searchInfo, onClose }: Props) => { @@ -104,77 +366,134 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { const [lectures, setLectures] = useState([]); const [page, setPage] = useState(1); const [searchOptions, setSearchOptions] = useState({ - query: '', + query: "", grades: [], days: [], times: [], majors: [], }); - const getFilteredLectures = () => { - const { query = '', credits, grades, days, times, majors } = searchOptions; - return lectures - .filter(lecture => - lecture.title.toLowerCase().includes(query.toLowerCase()) || - lecture.id.toLowerCase().includes(query.toLowerCase()) - ) - .filter(lecture => grades.length === 0 || grades.includes(lecture.grade)) - .filter(lecture => majors.length === 0 || majors.includes(lecture.major)) - .filter(lecture => !credits || lecture.credits.startsWith(String(credits))) - .filter(lecture => { - if (days.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); - }) - .filter(lecture => { - if (times.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); - }); - } + // 검색 + const filteredLectures = useMemo(() => { + { + const { + query = "", + credits, + grades, + days, + times, + majors, + } = searchOptions; + return lectures + .filter( + (lecture) => + lecture.title.toLowerCase().includes(query.toLowerCase()) || + lecture.id.toLowerCase().includes(query.toLowerCase()) + ) + .filter( + (lecture) => grades.length === 0 || grades.includes(lecture.grade) + ) + .filter( + (lecture) => majors.length === 0 || majors.includes(lecture.major) + ) + .filter( + (lecture) => !credits || lecture.credits.startsWith(String(credits)) + ) + .filter((lecture) => { + if (days.length === 0 && times.length === 0) { + return true; + } - const filteredLectures = getFilteredLectures(); + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + + const dayMatch = + days.length === 0 || schedules.some((s) => days.includes(s.day)); + + const timeMatch = + times.length === 0 || + schedules.some((s) => s.range.some((time) => times.includes(time))); + + return dayMatch && timeMatch; + }); + } + }, [searchOptions, lectures]); + + // before: 렌더링 될 때마다 검색 + // after: 검색 옵션이 변경될 때마다 검색? + // const filteredLectures = getFilteredLectures(); const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; + const allMajors = useMemo( + () => [...new Set(lectures.map((lecture) => lecture.major))], + [lectures] + ); - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { - setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); - loaderWrapperRef.current?.scrollTo(0, 0); - }; + const changeSearchOption = useCallback( + (field: keyof SearchOption, value: SearchOption[typeof field]) => { + setPage(1); + setSearchOptions((prev) => ({ ...prev, [field]: value })); + loaderWrapperRef.current?.scrollTo(0, 0); + }, + [] + ); - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; + const handleQueryChange = useCallback((value: string) => { + changeSearchOption("query", value); + }, [changeSearchOption]); - const { tableId } = searchInfo; + const handleCreditsChange = useCallback((value: string) => { + changeSearchOption("credits", value); + }, [changeSearchOption]); - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); + const handleGradesChange = useCallback((value: number[]) => { + changeSearchOption("grades", value); + }, [changeSearchOption]); - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); + const handleDaysChange = useCallback((value: string[]) => { + changeSearchOption("days", value); + }, [changeSearchOption]); - onClose(); - }; + const handleTimesChange = useCallback((value: number[]) => { + changeSearchOption("times", value); + }, [changeSearchOption]); + + const handleMajorsChange = useCallback((value: string[]) => { + changeSearchOption("majors", value); + }, [changeSearchOption]); + + const addSchedule = useCallback( + (lecture: Lecture) => { + if (!searchInfo) return; + + const { tableId } = searchInfo; + + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); + + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); + + onClose(); + }, + [searchInfo, setSchedulesMap, onClose] + ); useEffect(() => { const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { + console.log("API 호출 시작: ", start); + fetchAllLectures().then((results) => { + console.log("API 호출 결과: ", results); const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) + console.log("모든 API 호출 완료 ", end); + console.log("API 호출에 걸린 시간(ms): ", end - start); + setLectures(results.flatMap((result) => result)); + }); }, []); useEffect(() => { @@ -186,9 +505,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { } const observer = new IntersectionObserver( - entries => { + (entries) => { if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); + setPage((prevPage) => Math.min(lastPage, prevPage + 1)); } }, { threshold: 0, root: $loaderWrapper } @@ -200,138 +519,56 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { }, [lastPage]); useEffect(() => { - setSearchOptions(prev => ({ + setSearchOptions((prev) => ({ ...prev, days: searchInfo?.day ? [searchInfo.day] : [], times: searchInfo?.time ? [searchInfo.time] : [], - })) + })); setPage(1); }, [searchInfo]); return ( - + 수업 검색 - + - - 검색어 - changeSearchOption('query', e.target.value)} - /> - - - - 학점 - changeSearchOption('credits', e.target.value)} - > - 전체 - 1학점 - 2학점 - 3학점 - - + + - - 학년 - changeSearchOption('grades', value.map(Number))} - > - - {[1, 2, 3, 4].map(grade => ( - {grade}학년 - ))} - - - - - - 요일 - changeSearchOption('days', value as string[])} - > - - {DAY_LABELS.map(day => ( - {day} - ))} - - - + + - - 시간 - changeSearchOption('times', values.map(Number))} - > - - {searchOptions.times.sort((a, b) => a - b).map(time => ( - - {time}교시 - changeSearchOption('times', searchOptions.times.filter(v => v !== time))}/> - - ))} - - - {TIME_SLOTS.map(({ id, label }) => ( - - - {id}교시({label}) - - - ))} - - - - - - 전공 - changeSearchOption('majors', values as string[])} - > - - {searchOptions.majors.map(major => ( - - {major.split("").pop()} - changeSearchOption('majors', searchOptions.majors.filter(v => v !== major))}/> - - ))} - - - {allMajors.map(major => ( - - - {major.replace(//gi, ' ')} - - - ))} - - - + + - - 검색결과: {filteredLectures.length}개 - + 검색결과: {filteredLectures.length}개 @@ -351,21 +588,15 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { {visibleLectures.map((lecture, index) => ( - - {lecture.id} - {lecture.grade} - {lecture.title} - {lecture.credits} - - - - addSchedule(lecture)}>추가 - - + ))} - + @@ -375,4 +606,4 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { ); }; -export default SearchDialog; \ No newline at end of file +export default SearchDialog; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vite.config.ts b/vite.config.ts index 1cdac55..3a52d68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_7th_chapter4-2/', }), defineTestConfig({ test: {
/gi, " ")} +
").pop()}
/gi, ' ')} -