diff --git a/packages/app/404.html b/packages/app/404.html new file mode 100644 index 00000000..40c0a77f --- /dev/null +++ b/packages/app/404.html @@ -0,0 +1,26 @@ + + + + + + 상품 쇼핑몰 + + + + + +
+ + + \ No newline at end of file diff --git a/packages/app/src/components/toast/ToastProvider.tsx b/packages/app/src/components/toast/ToastProvider.tsx index 0aed9451..3b5ecf93 100644 --- a/packages/app/src/components/toast/ToastProvider.tsx +++ b/packages/app/src/components/toast/ToastProvider.tsx @@ -1,52 +1,61 @@ -/* eslint-disable react-refresh/only-export-components */ import { createContext, memo, type PropsWithChildren, useContext, useReducer } from "react"; import { createPortal } from "react-dom"; import { Toast } from "./Toast"; -import { createActions, initialState, toastReducer, type ToastType } from "./toastReducer"; +import { initialState, toastReducer, type ToastType } from "./toastReducer"; import { debounce } from "../../utils"; +import { useCallback, useMemo } from "@hanghae-plus/lib/src/hooks"; type ShowToast = (message: string, type: ToastType) => void; type Hide = () => void; -const ToastContext = createContext<{ - message: string; - type: ToastType; +const ToastCommandContext = createContext<{ show: ShowToast; hide: Hide; }>({ - ...initialState, show: () => null, hide: () => null, }); +const ToastStateContext = createContext<{ + message: string; + type: ToastType; +}>({ + ...initialState, +}); + const DEFAULT_DELAY = 3000; -const useToastContext = () => useContext(ToastContext); -export const useToastCommand = () => { - const { show, hide } = useToastContext(); - return { show, hide }; -}; -export const useToastState = () => { - const { message, type } = useToastContext(); - return { message, type }; -}; +export const useToastCommand = () => useContext(ToastCommandContext); + +export const useToastState = () => useContext(ToastStateContext); export const ToastProvider = memo(({ children }: PropsWithChildren) => { const [state, dispatch] = useReducer(toastReducer, initialState); - const { show, hide } = createActions(dispatch); const visible = state.message !== ""; - const hideAfter = debounce(hide, DEFAULT_DELAY); + const hide = useCallback(() => { + dispatch({ type: "hide" }); + }, []); + + const hideAfter = useMemo(() => debounce(hide, DEFAULT_DELAY), [hide]); + + const show = useCallback( + (message: string, type: ToastType) => { + dispatch({ type: "show", payload: { message, type } }); + hideAfter(); + }, + [hideAfter], + ); - const showWithHide: ShowToast = (...args) => { - show(...args); - hideAfter(); - }; + const commandValue = useMemo(() => ({ show, hide }), [show, hide]); + const stateValue = useMemo(() => ({ message: state.message, type: state.type }), [state.message, state.type]); return ( - - {children} - {visible && createPortal(, document.body)} - + + + {children} + {visible && createPortal(, document.body)} + + ); }); diff --git a/packages/lib/src/createObserver.ts b/packages/lib/src/createObserver.ts index fe97bf6b..36a31be9 100644 --- a/packages/lib/src/createObserver.ts +++ b/packages/lib/src/createObserver.ts @@ -1,11 +1,17 @@ type Listener = () => void; +/** + * 옵저버 패턴을 사용하여 상태 변경을 관리하는 함수 + * @returns 옵저버 객체 + */ export const createObserver = () => { const listeners = new Set(); - // useSyncExternalStore 에서 활용할 수 있도록 subscribe 함수를 수정합니다. const subscribe = (fn: Listener) => { listeners.add(fn); + return () => { + listeners.delete(fn); + }; }; const unsubscribe = (fn: Listener) => { diff --git a/packages/lib/src/equals/deepEquals.ts b/packages/lib/src/equals/deepEquals.ts index f5f4ebd5..00546051 100644 --- a/packages/lib/src/equals/deepEquals.ts +++ b/packages/lib/src/equals/deepEquals.ts @@ -1,3 +1,20 @@ -export const deepEquals = (a: unknown, b: unknown) => { - return a === b; +import { compareObject, isObject } from "./util"; + +/** + * 두 값의 깊은 비교 결과를 반환 + * @param {unknown} a 비교할 첫 번째 값 + * @param {unknown} b 비교할 두 번째 값 + * @returns {boolean} 두 값의 깊은 비교 결과 + */ +export const deepEquals = (a: unknown, b: unknown): boolean => { + // 1. 기본 타입이거나 null인 경우 처리 + if (a === b) return true; + + // 2. 둘 다 객체인 경우: + // - 배열인지 확인 + // - 객체의 키 개수가 다른 경우 처리 + // - 재귀적으로 각 속성에 대해 deepEquals 호출 + if (!isObject(a) || !isObject(b)) return false; + + return compareObject(a, b, deepEquals); }; diff --git a/packages/lib/src/equals/shallowEquals.ts b/packages/lib/src/equals/shallowEquals.ts index 1cf22130..6e915660 100644 --- a/packages/lib/src/equals/shallowEquals.ts +++ b/packages/lib/src/equals/shallowEquals.ts @@ -1,3 +1,17 @@ +import { compareObject, isObject } from "./util"; + +/** + * 두 값의 얕은 비교 결과를 반환 + * @param {unknown} a 비교할 첫 번째 값 + * @param {unknown} b 비교할 두 번째 값 + * @returns {boolean} 두 값이 얕은 비교 결과 + */ export const shallowEquals = (a: unknown, b: unknown) => { - return a === b; + // 1. 두 값이 정확히 같은지 확인 (참조가 같은 경우) + if (Object.is(a, b)) return true; + + // 2. 둘 중 하나라도 객체가 아닌 경우 처리 + if (!isObject(a) || !isObject(b)) return false; + + return compareObject(a, b); }; diff --git a/packages/lib/src/equals/util.ts b/packages/lib/src/equals/util.ts new file mode 100644 index 00000000..1da12bec --- /dev/null +++ b/packages/lib/src/equals/util.ts @@ -0,0 +1,29 @@ +/** + * 주어진 값이 객체인지 확인하는 함수 + * @reference https://github.com/toss/es-toolkit/blob/main/src/compat/predicate/isObject.ts + * @param {unknown} value - 객체인지 확인할 값 + * @returns {value is Record} 객체인지 확인 결과 + */ +export function isObject(value?: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +/** + * 두 객체를 비교하는 함수 + * @param a 비교할 첫 번째 객체 + * @param b 비교할 두 번째 객체 + * @returns 두 객체가 같은지 확인 결과 + */ +export function compareObject( + a: Record, + b: Record, + compareFn: (a: unknown, b: unknown) => boolean = (a, b) => a === b, +): boolean { + // 객체의 키 개수가 다른 경우 처리 + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + + // 모든 키에 대해 비교 수행 + return keysA.every((key) => compareFn(a[key], b[key])); +} diff --git a/packages/lib/src/hocs/deepMemo.ts b/packages/lib/src/hocs/deepMemo.ts index 13f7f18e..b5c18a95 100644 --- a/packages/lib/src/hocs/deepMemo.ts +++ b/packages/lib/src/hocs/deepMemo.ts @@ -1,5 +1,14 @@ import type { FunctionComponent } from "react"; +import { memo } from "./memo"; +import { deepEquals } from "../equals"; +/** + * deepMemo HOC는 컴포넌트의 props를 깊은 비교하여 불필요한 리렌더링을 방지합니다. + * @param Component 메모이제이션할 컴포넌트 + * @returns 메모이제이션된 컴포넌트 + */ export function deepMemo

(Component: FunctionComponent

) { - return Component; + // deepEquals 함수를 사용하여 props 비교 + // 앞에서 만든 memo를 사용 + return memo(Component, deepEquals); } diff --git a/packages/lib/src/hocs/memo.ts b/packages/lib/src/hocs/memo.ts index 532c3a5c..f604817e 100644 --- a/packages/lib/src/hocs/memo.ts +++ b/packages/lib/src/hocs/memo.ts @@ -1,6 +1,28 @@ -import { type FunctionComponent } from "react"; +import { type FunctionComponent, type JSX } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "../hooks"; +/** + * memo HOC는 컴포넌트의 props를 얕은 비교하여 불필요한 리렌더링을 방지합니다. + * @param Component 메모이제이션할 컴포넌트 + * @param equals 비교 함수 + * @returns 메모이제이션된 컴포넌트 + */ export function memo

(Component: FunctionComponent

, equals = shallowEquals) { - return Component; + const MemoizedComponent = (props: P) => { + // Hook을 컴포넌트 내부에서 호출 + const prevPropsRef = useRef

(null); + const prevResultRef = useRef(null); + + const shouldUpdate = prevPropsRef.current === null || !equals(prevPropsRef.current, props); + + if (shouldUpdate) { + prevPropsRef.current = props; + prevResultRef.current = Component(props) as JSX.Element; + } + + return prevResultRef.current!; + }; + + return MemoizedComponent; } diff --git a/packages/lib/src/hooks/useAutoCallback.ts b/packages/lib/src/hooks/useAutoCallback.ts index 23100162..ace2ab56 100644 --- a/packages/lib/src/hooks/useAutoCallback.ts +++ b/packages/lib/src/hooks/useAutoCallback.ts @@ -2,6 +2,18 @@ import type { AnyFunction } from "../types"; import { useCallback } from "./useCallback"; import { useRef } from "./useRef"; +/** + * 콜백 함수를 메모이제이션하는 훅 + * @param fn 메모이제이션할 콜백 함수 + * @returns 메모이제이션된 콜백 함수 + */ export const useAutoCallback = (fn: T): T => { - return fn; + // 1. 콜백함수가 참조하는 값은 항상 렌더링 시점에 최신화 되어야한다. ← 이 부분을 어떻게 해결할 수 있을지 고민해보세요! + const fnRef = useRef(fn); + fnRef.current = fn; + + // 2. 대신 항상 동일한 참조를 유지해야 한다 (useCallback 활용) + return useCallback((...args: unknown[]) => { + return fnRef.current(...args); + }, []) as T; }; diff --git a/packages/lib/src/hooks/useCallback.ts b/packages/lib/src/hooks/useCallback.ts index 712bc898..5d58131f 100644 --- a/packages/lib/src/hooks/useCallback.ts +++ b/packages/lib/src/hooks/useCallback.ts @@ -1,7 +1,13 @@ -/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-unsafe-function-type */ +/* eslint-disable @typescript-eslint/no-unsafe-function-type */ import type { DependencyList } from "react"; +import { useMemo } from "./useMemo"; +/** + * 콜백 함수를 메모이제이션하는 훅 + * @param factory 메모이제이션할 콜백 함수 + * @param _deps 의존성 배열 + * @returns 메모이제이션된 콜백 함수 + */ export function useCallback(factory: T, _deps: DependencyList) { - // 직접 작성한 useMemo를 통해서 만들어보세요. - return factory as T; + return useMemo(() => factory, _deps); } diff --git a/packages/lib/src/hooks/useDeepMemo.ts b/packages/lib/src/hooks/useDeepMemo.ts index 3157958c..6cd79d33 100644 --- a/packages/lib/src/hooks/useDeepMemo.ts +++ b/packages/lib/src/hooks/useDeepMemo.ts @@ -3,7 +3,13 @@ import type { DependencyList } from "react"; import { useMemo } from "./useMemo"; import { deepEquals } from "../equals"; +/** + * useDeepMemo 훅은 의존성이 변경되었을 때만 함수를 실행하여 깊은 비교를 수행한 메모이제이션된 값을 반환합니다. + * @param factory 메모이제이션할 함수 + * @param deps 의존성 배열 + * @returns 메모이제이션된 값 + */ export function useDeepMemo(factory: () => T, deps: DependencyList): T { - // 직접 작성한 useMemo를 참고해서 만들어보세요. + // 1. useMemo를 사용하되, 비교 함수로 deepEquals를 사용 return useMemo(factory, deps, deepEquals); } diff --git a/packages/lib/src/hooks/useMemo.ts b/packages/lib/src/hooks/useMemo.ts index e80692e2..988c15d1 100644 --- a/packages/lib/src/hooks/useMemo.ts +++ b/packages/lib/src/hooks/useMemo.ts @@ -1,8 +1,26 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ import type { DependencyList } from "react"; import { shallowEquals } from "../equals"; +import { useRef } from "./useRef"; +/** + * useMemo 훅은 의존성이 변경되었을 때만 함수를 실행하여 메모이제이션된 값을 반환합니다. + * @param factory 메모이제이션할 함수 + * @param _deps 의존성 배열 + * @param _equals 비교 함수 + * @returns 메모이제이션된 값 + */ export function useMemo(factory: () => T, _deps: DependencyList, _equals = shallowEquals): T { - // 직접 작성한 useRef를 통해서 만들어보세요. - return factory(); + // 1. 이전 의존성과 결과를 저장할 ref 생성 + const ref = useRef<{ deps: DependencyList; result: T } | null>(null); + if (!ref.current) ref.current = { deps: _deps, result: factory() }; + + // 2. 현재 의존성과 이전 의존성 비교 + if (!_equals(ref.current.deps, _deps)) { + // 3. 의존성이 변경된 경우 factory 함수 실행 및 결과 저장 + ref.current.result = factory(); + ref.current.deps = _deps; + } + + // 4. 메모이제이션된 값 반환 + return ref.current.result; } diff --git a/packages/lib/src/hooks/useRef.ts b/packages/lib/src/hooks/useRef.ts index 285d4ae7..ec043915 100644 --- a/packages/lib/src/hooks/useRef.ts +++ b/packages/lib/src/hooks/useRef.ts @@ -1,4 +1,16 @@ +import { useState } from "react"; + +/** + * 렌더링 사이에 값을 유지하는 가변 ref 객체를 생성 + * - 반환된 ref 객체는 컴포넌트의 전체 생명주기 동안 유지됩니다. + * - ref 객체의 .current 속성을 변경해도 리렌더링이 트리거되지 않습니다. + * - DOM 요소에 접근하거나 이전 상태를 저장하는 등 다양한 용도로 사용될 수 있습니다. + * @param initialValue 초기값 + * @returns ref 객체 + */ + export function useRef(initialValue: T): { current: T } { - // useState를 이용해서 만들어보세요. - return { current: initialValue }; + //useState를 사용하여 초기값을 저장하고, 이후 값을 변경할 때 리렌더링을 트리거하지 않도록 합니다. + const [refObject] = useState(() => ({ current: initialValue })); + return refObject; } diff --git a/packages/lib/src/hooks/useRouter.ts b/packages/lib/src/hooks/useRouter.ts index a18ae01f..3afc88da 100644 --- a/packages/lib/src/hooks/useRouter.ts +++ b/packages/lib/src/hooks/useRouter.ts @@ -5,8 +5,17 @@ import { useShallowSelector } from "./useShallowSelector"; const defaultSelector = (state: T) => state as unknown as S; +/** + * useSyncExternalStore를 사용해서 router의 상태를 구독하고 가져오는 훅 + * @param router 라우터 + * @param selector 선택자 함수 + * @returns 라우터의 상태 + */ export const useRouter = , S>(router: T, selector = defaultSelector) => { - // useSyncExternalStore를 사용하여 router의 상태를 구독하고 가져오는 훅을 구현합니다. const shallowSelector = useShallowSelector(selector); - return shallowSelector(router); + return useSyncExternalStore( + router.subscribe, + () => shallowSelector(router), + () => shallowSelector(router), + ); }; diff --git a/packages/lib/src/hooks/useShallowSelector.ts b/packages/lib/src/hooks/useShallowSelector.ts index b75ed791..ef8da9e1 100644 --- a/packages/lib/src/hooks/useShallowSelector.ts +++ b/packages/lib/src/hooks/useShallowSelector.ts @@ -3,7 +3,17 @@ import { shallowEquals } from "../equals"; type Selector = (state: T) => S; +/** + * 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅 + * @reference https://github.com/pmndrs/zustand/blob/main/src/react/shallow.ts + * @param selector 선택자 함수 + * @returns 상태가 변경되었을 때만 선택자 함수의 결과를 반환 + */ export const useShallowSelector = (selector: Selector) => { - // 이전 상태를 저장하고, shallowEquals를 사용하여 상태가 변경되었는지 확인하는 훅을 구현합니다. - return (state: T): S => selector(state); + const prev = useRef(undefined); + + return (state: T) => { + const next = selector(state); + return shallowEquals(prev.current, next) ? (prev.current as S) : (prev.current = next); + }; }; diff --git a/packages/lib/src/hooks/useShallowState.ts b/packages/lib/src/hooks/useShallowState.ts index 7b589748..5df79fc1 100644 --- a/packages/lib/src/hooks/useShallowState.ts +++ b/packages/lib/src/hooks/useShallowState.ts @@ -1,7 +1,29 @@ import { useState } from "react"; import { shallowEquals } from "../equals"; +import { useCallback } from "./useCallback"; +/** + * useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅 + * @param initialValue 초기값 + * @returns 상태와 상태 변경 함수 + */ export const useShallowState = (initialValue: Parameters>[0]) => { - // useState를 사용하여 상태를 관리하고, shallowEquals를 사용하여 상태 변경을 감지하는 훅을 구현합니다. - return useState(initialValue); + const [state, setState] = useState(initialValue); + + // 얕은 비교를 수행하는 커스텀 setState 함수 + const setShallowState = useCallback((action: T | ((prevState: T) => T)) => { + setState((prevState) => { + // 최초 호출 시 초기값 반환 + if (prevState === undefined) return initialValue; + + // 함수형 업데이트인지 확인 + const nextValue = typeof action === "function" ? (action as (prev: T) => T)(prevState) : action; + + // shallowEquals로 비교 후 업데이트 결정 + return shallowEquals(prevState, nextValue) ? prevState : nextValue; + }); + }, []); + + // useState와 동일한 API 반환 + return [state, setShallowState]; }; diff --git a/packages/lib/src/hooks/useStorage.ts b/packages/lib/src/hooks/useStorage.ts index fdc97a6f..e295ee5a 100644 --- a/packages/lib/src/hooks/useStorage.ts +++ b/packages/lib/src/hooks/useStorage.ts @@ -3,7 +3,11 @@ import type { createStorage } from "../createStorage"; type Storage = ReturnType>; +/** + * useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅 + * @param storage 스토리지 + * @returns 스토리지의 상태 + */ export const useStorage = (storage: Storage) => { - // useSyncExternalStore를 사용해서 storage의 상태를 구독하고 가져오는 훅을 구현해보세요. - return storage.get(); + return useSyncExternalStore(storage.subscribe, storage.get, storage.get); }; diff --git a/packages/lib/src/hooks/useStore.ts b/packages/lib/src/hooks/useStore.ts index acf3ad79..d99dd098 100644 --- a/packages/lib/src/hooks/useStore.ts +++ b/packages/lib/src/hooks/useStore.ts @@ -6,8 +6,18 @@ type Store = ReturnType>; const defaultSelector = (state: T) => state as unknown as S; +/** + * useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅 + * @param store 스토어 + * @param selector 선택자 함수 + * @returns 스토어의 상태 + */ export const useStore = (store: Store, selector: (state: T) => S = defaultSelector) => { - // useSyncExternalStore와 useShallowSelector를 사용해서 store의 상태를 구독하고 가져오는 훅을 구현해보세요. const shallowSelector = useShallowSelector(selector); - return shallowSelector(store.getState()); + + return useSyncExternalStore( + store.subscribe, + () => shallowSelector(store.getState()), + () => shallowSelector(store.getState()), + ); };