diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml new file mode 100644 index 00000000..7c63e331 --- /dev/null +++ b/.github/workflows/gh-pages.yml @@ -0,0 +1,50 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: "pnpm" + - name: Install dependencies + run: pnpm install + - name: Build Project + run: pnpm run build:prod + env: + NODE_ENV: production + - name: Upload Artifact + uses: actions/upload-pages-artifact@v4 + with: + path: ./packages/app/dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + permissions: + contents: read + pages: write + id-token: write + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index ad975ab4..7f1e9f22 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test:e2e:report": "npx playwright show-report", "test:generate": "playwright codegen localhost:5173", "prepare": "husky", - "gh-pages": "pnpm -F @hanghae-plus/shopping build && gh-pages -d ./packages/app/dist" + "build:prod": "pnpm -F @hanghae-plus/shopping build", + "gh-pages": "pnpm run build:prod && gh-pages -d ./packages/app/dist" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/packages/react/src/core/context.ts b/packages/react/src/core/context.ts index bf41d61d..0476cabe 100644 --- a/packages/react/src/core/context.ts +++ b/packages/react/src/core/context.ts @@ -13,8 +13,9 @@ export const context: Context = { node: null, instance: null, reset({ container, node }) { - // 여기를 구현하세요. - // container, node, instance를 전달받은 값으로 초기화합니다. + this.container = container; + this.node = node; + this.instance = null; }, }, @@ -32,36 +33,36 @@ export const context: Context = { * 모든 훅 관련 상태를 초기화합니다. */ clear() { - // 여기를 구현하세요. - // state, cursor, visited, componentStack을 모두 비웁니다. + this.state.clear(); + this.cursor.clear(); + this.visited.clear(); + this.componentStack.length = 0; }, /** * 현재 실행 중인 컴포넌트의 고유 경로를 반환합니다. */ get currentPath() { - // 여기를 구현하세요. - // componentStack의 마지막 요소를 반환해야 합니다. - // 스택이 비어있으면 '훅은 컴포넌트 내부에서만 호출되어야 한다'는 에러를 발생시켜야 합니다. - return ""; + const stack = this.componentStack; + + if (!stack.length) throw new Error("Hooks can only be called inside a component."); + return stack[stack.length - 1]; }, /** * 현재 컴포넌트에서 다음에 실행될 훅의 인덱스(커서)를 반환합니다. */ get currentCursor() { - // 여기를 구현하세요. - // cursor Map에서 현재 경로의 커서를 가져옵니다. 없으면 0을 반환합니다. - return 0; + const path = this.currentPath; + return this.cursor.get(path) ?? 0; }, /** * 현재 컴포넌트의 훅 상태 배열을 반환합니다. */ get currentHooks() { - // 여기를 구현하세요. - // state Map에서 현재 경로의 훅 배열을 가져옵니다. 없으면 빈 배열을 반환합니다. - return []; + const path = this.currentPath; + return this.state.get(path) ?? []; }, }, @@ -71,4 +72,4 @@ export const context: Context = { effects: { queue: [], }, -}; \ No newline at end of file +}; diff --git a/packages/react/src/core/dom.ts b/packages/react/src/core/dom.ts index f07fc5cb..7f4df261 100644 --- a/packages/react/src/core/dom.ts +++ b/packages/react/src/core/dom.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { NodeType, NodeTypes } from "./constants"; +import { NodeTypes } from "./constants"; import { Instance } from "./types"; /** @@ -7,7 +7,52 @@ import { Instance } from "./types"; * 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다. */ export const setDomProps = (dom: HTMLElement, props: Record): void => { - // 여기를 구현하세요. + if (!props) return; + Object.entries(props).forEach(([key, value]) => { + if (key === "children") return; + + // style 객체 처리 + if (key === "style" && value && typeof value === "object") { + const styleObject = value as Record; + Object.entries(styleObject).forEach(([styleName, styleValue]) => { + (dom.style as any)[styleName] = styleValue != null ? String(styleValue) : ""; + }); + return; + } + + // 이벤트 핸들러 처리 + if (key.startsWith("on") && typeof value === "function") { + const eventName = key.slice(2).toLowerCase(); + dom.addEventListener(eventName, value); + return; + } + + // className 처리 + if (key === "className") { + dom.className = value ?? ""; + return; + } + + // data-* 속성 처리 + if (key.startsWith("data-")) { + if (value != null) dom.setAttribute(key, String(value)); + return; + } + + // boolean 속성 처리 + if (typeof value === "boolean") { + if (key in dom) (dom as any)[key] = value; + else if (value) dom.setAttribute(key, ""); + return; + } + + // null/undefined 무시 + if (value == null) return; + + // 일반 속성 처리 + if (key in dom) (dom as any)[key] = value; + else dom.setAttribute(key, String(value)); + }); }; /** @@ -19,7 +64,120 @@ export const updateDomProps = ( prevProps: Record = {}, nextProps: Record = {}, ): void => { - // 여기를 구현하세요. + // 제거되거나 변경된 props 처리 + Object.entries(prevProps).forEach(([key, prevValue]) => { + if (key === "children" || key === "style") return; + + const nextValue = nextProps[key]; + const hasNext = Object.prototype.hasOwnProperty.call(nextProps, key); + const isUnchanged = hasNext && nextValue === prevValue; + + if (isUnchanged) return; + + // 이벤트 핸들러 제거 + if (key.startsWith("on") && typeof prevValue === "function") { + dom.removeEventListener(key.slice(2).toLowerCase(), prevValue); + return; + } + + // className 업데이트 + if (key === "className") { + dom.className = hasNext ? (nextValue ?? "") : ""; + return; + } + + // data-* 속성 제거 + if (key.startsWith("data-")) { + if (!hasNext || nextValue == null) dom.removeAttribute(key); + return; + } + + // boolean 속성 제거 + if (!hasNext && typeof prevValue === "boolean") { + if (key in dom) (dom as any)[key] = false; + dom.removeAttribute(key); + return; + } + + // 일반 속성 제거 + if (!hasNext) { + // 프로퍼티가 있으면 빈 문자열로 설정 시도 (읽기 전용 속성은 에러 발생 가능) + if (key in dom) { + try { + (dom as any)[key] = ""; + } catch { + // 읽기 전용 프로퍼티(innerHTML, tagName 등)는 무시하고 attribute만 제거 + } + } + // attribute 제거 + dom.removeAttribute(key); + } + }); + + // style 객체 비교/반영 + const prevStyle = prevProps.style ?? {}; + const nextStyle = nextProps.style ?? {}; + const allStyleKeys = new Set([...Object.keys(prevStyle), ...Object.keys(nextStyle)]); + + allStyleKeys.forEach((name) => { + const prevVal = prevStyle[name]; + const nextVal = nextStyle[name]; + if (prevVal === nextVal) return; + + (dom.style as any)[name] = nextVal != null ? String(nextVal) : ""; + }); + + // 추가되거나 변경된 props 설정 + Object.entries(nextProps).forEach(([key, value]) => { + if (key === "children" || key === "style") return; + + const prevValue = prevProps[key]; + const isUnchanged = value === prevValue; + + // 이벤트 핸들러 추가/교체 + if (key.startsWith("on") && typeof value === "function") { + const eventName = key.slice(2).toLowerCase(); + if (typeof prevValue === "function" && prevValue !== value) dom.removeEventListener(eventName, prevValue); + dom.addEventListener(eventName, value); + return; + } + + // 이벤트 핸들러가 null/undefined로 변경된 경우 제거 + if (key.startsWith("on") && typeof prevValue === "function" && (value == null || typeof value !== "function")) { + dom.removeEventListener(key.slice(2).toLowerCase(), prevValue); + return; + } + + // className 설정 + if (key === "className") { + if (!isUnchanged) dom.className = value ?? ""; + return; + } + + // data-* 속성 설정 + if (key.startsWith("data-")) { + if (value == null) dom.removeAttribute(key); + else if (!isUnchanged) dom.setAttribute(key, String(value)); + return; + } + + // boolean 속성 설정 + if (typeof value === "boolean") { + if (key in dom) { + if (!isUnchanged) (dom as any)[key] = value; + } else { + if (value) dom.setAttribute(key, ""); + else dom.removeAttribute(key); + } + return; + } + + // 일반 속성 설정 + if (value == null || isUnchanged) return; + + if (key in dom) (dom as any)[key] = value; + else dom.setAttribute(key, String(value)); + }); }; /** @@ -27,15 +185,34 @@ export const updateDomProps = ( * Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다. */ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { - // 여기를 구현하세요. - return []; + if (!instance) return []; + + const { dom, kind, children } = instance; + + // HOST/TEXT 노드는 자신이 가진 dom만 반환 + if (kind === NodeTypes.HOST || kind === NodeTypes.TEXT) return dom ? [dom] : []; + + // COMPONENT/FRAGMENT 노드는 자식 인스턴스에서 실제 DOM을 모아서 반환 + return children.flatMap((child) => getDomNodes(child)); }; /** * 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => { - // 여기를 구현하세요. + if (!instance) return null; + + const { dom, kind, children } = instance; + + // HOST/TEXT 노드는 자신의 dom이 첫 번째 실제 DOM 노드 + if ((kind === NodeTypes.HOST || kind === NodeTypes.TEXT) && dom) return dom; + + // COMPONENT/FRAGMENT 등은 자식 인스턴스들에서 첫 DOM 노드를 찾음 + for (const child of children) { + const found = getFirstDom(child); + if (found) return found; + } + return null; }; @@ -43,7 +220,10 @@ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | nul * 자식 인스턴스들로부터 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => { - // 여기를 구현하세요. + for (const child of children) { + const dom = getFirstDom(child); + if (dom) return dom; + } return null; }; @@ -56,12 +236,25 @@ export const insertInstance = ( instance: Instance | null, anchor: HTMLElement | Text | null = null, ): void => { - // 여기를 구현하세요. + if (!instance) return; + + const nodes = getDomNodes(instance); + for (const node of nodes) { + if (node.parentNode === parentDom && (anchor ? node.nextSibling === anchor : node.nextSibling === null)) continue; + + if (anchor) parentDom.insertBefore(node, anchor); + else parentDom.appendChild(node); + } }; /** * 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다. */ export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => { - // 여기를 구현하세요. + if (!instance) return; + + const nodes = getDomNodes(instance); + for (const node of nodes) { + if (node.parentNode === parentDom) parentDom.removeChild(node); + } }; diff --git a/packages/react/src/core/elements.ts b/packages/react/src/core/elements.ts index d04bce98..1f6ea000 100644 --- a/packages/react/src/core/elements.ts +++ b/packages/react/src/core/elements.ts @@ -1,24 +1,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { isEmptyValue } from "../utils"; -import { VNode } from "./types"; import { Fragment, TEXT_ELEMENT } from "./constants"; +import { VNode } from "./types"; /** * 주어진 노드를 VNode 형식으로 정규화합니다. * null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다. */ -export const normalizeNode = (node: VNode): VNode | null => { - // 여기를 구현하세요. - return null; +export const normalizeNode = (node: any): VNode | null => { + if (isEmptyValue(node)) return null; + if (Array.isArray(node)) return createElement(Fragment, null, ...node); + if (typeof node !== "object") return createTextElement(node); + + return { ...node, props: node.props ?? null, key: node.key ?? null } as VNode; }; /** * 텍스트 노드를 위한 VNode를 생성합니다. */ -const createTextElement = (node: VNode): VNode => { - // 여기를 구현하세요. - return {} as VNode; -}; +const createTextElement = (node: unknown): VNode => ({ + type: TEXT_ELEMENT, + key: null, + props: { + children: [], + nodeValue: String(node), + }, +}); /** * JSX로부터 전달된 인자를 VNode 객체로 변환합니다. @@ -29,12 +36,32 @@ export const createElement = ( originProps?: Record | null, ...rawChildren: any[] ) => { - // 여기를 구현하세요. + const { key = null, ...restProps } = originProps || {}; + const props: Record = { ...restProps }; + + const flatChildren = rawChildren.flat(Infinity); + const children: VNode[] = []; + + for (const child of flatChildren) { + const node = normalizeNode(child); + if (node) children.push(node); + } + + if (children.length) props.children = children; + + return { type, key, props } as VNode; }; /** * 부모 경로와 자식의 key/index를 기반으로 고유한 경로를 생성합니다. * 이는 훅의 상태를 유지하고 Reconciliation에서 컴포넌트를 식별하는 데 사용됩니다. + * + * 경로 형식: + * - key가 있으면: `parentPath.k${encodedKey}` + * - key가 없으면: + * - DOM 요소: `i${index}` (인덱스 기반) + * - Fragment: `f${count}` (같은 타입의 개수) + * - 함수 컴포넌트: `c${componentName}_${count}` (컴포넌트 이름 + 같은 타입의 개수) */ export const createChildPath = ( parentPath: string, @@ -43,6 +70,34 @@ export const createChildPath = ( nodeType?: string | symbol | React.ComponentType, siblings?: VNode[], ): string => { - // 여기를 구현하세요. - return ""; + // key가 있으면 key 기반 경로 생성 (특수 문자 인코딩) + if (key != null) { + const encodedKey = encodeURIComponent(String(key)); + return parentPath ? `${parentPath}.k${encodedKey}` : `k${encodedKey}`; + } + + // key가 없으면 타입에 따라 다른 형식 사용 + const prevSiblings = siblings?.slice(0, index) ?? []; + const targetType = nodeType === Fragment ? Fragment : nodeType; + + // Fragment는 'f' 접두사와 같은 타입의 개수 사용 + if (nodeType === Fragment) { + const count = prevSiblings.filter((sibling) => sibling && sibling.type === Fragment && sibling.key == null).length; + const token = `f${count}`; + return parentPath ? `${parentPath}.${token}` : token; + } + + // 함수 컴포넌트는 'c' 접두사와 컴포넌트 이름 사용 + if (nodeType && typeof nodeType === "function") { + const componentName = (nodeType as React.ComponentType).name || "Anonymous"; + const count = prevSiblings.filter( + (sibling) => sibling && sibling.type === targetType && sibling.key == null, + ).length; + const token = `c${componentName}_${count}`; + return parentPath ? `${parentPath}.${token}` : token; + } + + // DOM 요소나 기타 타입은 인덱스 기반 + const token = `i${index}`; + return parentPath ? `${parentPath}.${token}` : token; }; diff --git a/packages/react/src/core/hooks.ts b/packages/react/src/core/hooks.ts index ef35d0f6..07d0602a 100644 --- a/packages/react/src/core/hooks.ts +++ b/packages/react/src/core/hooks.ts @@ -1,14 +1,54 @@ import { shallowEquals, withEnqueue } from "../utils"; +import { HookTypes } from "./constants"; import { context } from "./context"; -import { EffectHook } from "./types"; import { enqueueRender } from "./render"; -import { HookTypes } from "./constants"; +import { EffectHook } from "./types"; + +// useEffect에서 예약된 이펙트들을 한 번에 비동기로 실행하기 위한 헬퍼 +const flushEffects = withEnqueue(() => { + const { + effects: { queue }, + hooks: { state }, + } = context; + + while (queue.length) { + const { path, cursor } = queue.shift()!; + const hooksForPath = state.get(path); + if (!hooksForPath) continue; + + const hook = hooksForPath[cursor] as EffectHook | undefined; + if (!hook || hook.kind !== HookTypes.EFFECT) continue; + + // 이전 cleanup 함수가 있으면 먼저 실행 + hook.cleanup?.(); + + // 새로운 effect 실행하고 cleanup 저장 + const result = hook.effect(); + hook.cleanup = typeof result === "function" ? result : null; + } +}); /** * 사용되지 않는 컴포넌트의 훅 상태와 이펙트 클린업 함수를 정리합니다. */ export const cleanupUnusedHooks = () => { - // 여기를 구현하세요. + const { + hooks: { state, cursor, visited }, + effects, + } = context; + + for (const [path, hooks] of state.entries()) { + if (!visited.has(path)) { + hooks.forEach((hook) => { + if ((hook as EffectHook)?.kind === HookTypes.EFFECT) (hook as EffectHook).cleanup?.(); + }); + state.delete(path); + cursor.delete(path); + } + } + + visited.clear(); + effects.queue = effects.queue.filter(({ path }) => state.has(path)); }; /** @@ -17,15 +57,42 @@ export const cleanupUnusedHooks = () => { * @returns [현재 상태, 상태를 업데이트하는 함수] */ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | ((prev: T) => T)) => void] => { - // 여기를 구현하세요. // 1. 현재 컴포넌트의 훅 커서와 상태 배열을 가져옵니다. + const { + hooks: { state, cursor, visited, currentPath, currentCursor, currentHooks }, + } = context; + const path = currentPath; + const hookIndex = currentCursor; + const hooksForPath = state.get(path) ?? currentHooks; + + if (!state.has(path)) state.set(path, hooksForPath); + // 2. 첫 렌더링이라면 초기값으로 상태를 설정합니다. + if (hookIndex >= hooksForPath.length) { + const value = typeof initialValue === "function" ? (initialValue as () => T)() : (initialValue as T); + hooksForPath.push(value); + } + + const currentState = hooksForPath[hookIndex] as T; + // 3. 상태 변경 함수(setter)를 생성합니다. // - 새 값이 이전 값과 같으면(Object.is) 재렌더링을 건너뜁니다. // - 값이 다르면 상태를 업데이트하고 재렌더링을 예약(enqueueRender)합니다. + const setState = (nextValue: T | ((prev: T) => T)) => { + const prev = hooksForPath![hookIndex] as T; + const resolved = typeof nextValue === "function" ? (nextValue as (prev: T) => T)(prev) : (nextValue as T); + + if (Object.is(prev, resolved)) return; + + hooksForPath[hookIndex] = resolved; + enqueueRender(); + }; + // 4. 훅 커서를 증가시키고 [상태, setter]를 반환합니다. - const setState = (nextValue: T | ((prev: T) => T)) => {}; - return [initialValue as T, setState]; + cursor.set(path, hookIndex + 1); + visited.add(path); + + return [currentState, setState]; }; /** @@ -34,9 +101,38 @@ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | (( * @param deps - 의존성 배열. 이 값들이 변경될 때만 이펙트가 다시 실행됩니다. */ export const useEffect = (effect: () => (() => void) | void, deps?: unknown[]): void => { - // 여기를 구현하세요. + const { + hooks: { state, cursor, visited, currentPath, currentCursor, currentHooks }, + effects, + } = context; + const path = currentPath; + const hookIndex = currentCursor; + const hooksForPath = state.get(path) ?? currentHooks; + + if (!state.has(path)) state.set(path, hooksForPath); + + const prevHook = hooksForPath[hookIndex] as EffectHook | undefined; + const prevDeps = prevHook?.deps; + // 1. 이전 훅의 의존성 배열과 현재 의존성 배열을 비교(shallowEquals)합니다. + const shouldRun = !deps || !prevHook || !prevDeps || !shallowEquals(prevDeps, deps); + // 2. 의존성이 변경되었거나 첫 렌더링일 경우, 이펙트 실행을 예약합니다. - // 3. 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행합니다. - // 4. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + if (shouldRun) { + effects.queue.push({ path, cursor: hookIndex }); + + // 3. 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행합니다. + // 4. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + flushEffects(); + } + + hooksForPath[hookIndex] = { + kind: HookTypes.EFFECT, + deps: deps ?? null, + cleanup: prevHook?.cleanup ?? null, + effect, + }; + + cursor.set(path, hookIndex + 1); + visited.add(path); }; diff --git a/packages/react/src/core/reconciler.ts b/packages/react/src/core/reconciler.ts index 12cbdd39..acfad323 100644 --- a/packages/react/src/core/reconciler.ts +++ b/packages/react/src/core/reconciler.ts @@ -1,6 +1,6 @@ -import { context } from "./context"; +import { isEmptyValue } from "../utils"; import { Fragment, NodeTypes, TEXT_ELEMENT } from "./constants"; -import { Instance, VNode } from "./types"; +import { context } from "./context"; import { getFirstDom, getFirstDomFromChildren, @@ -10,7 +10,7 @@ import { updateDomProps, } from "./dom"; import { createChildPath } from "./elements"; -import { isEmptyValue } from "../utils"; +import { Instance, VNode } from "./types"; /** * 이전 인스턴스와 새로운 VNode를 비교하여 DOM을 업데이트하는 재조정 과정을 수행합니다. @@ -27,12 +27,272 @@ export const reconcile = ( node: VNode | null, path: string, ): Instance | null => { - // 여기를 구현하세요. // 1. 새 노드가 null이면 기존 인스턴스를 제거합니다. (unmount) + if (node === null) { + if (instance) removeInstance(parentDom, instance); + return null; + } + // 2. 기존 인스턴스가 없으면 새 노드를 마운트합니다. (mount) + if (!instance) return mountNode(parentDom, node, path); + // 3. 타입이나 키가 다르면 기존 인스턴스를 제거하고 새로 마운트합니다. + if (instance.node.type !== node.type || !Object.is(instance.key ?? null, node.key ?? null)) { + removeInstance(parentDom, instance); + return mountNode(parentDom, node, path); + } + // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) - // - DOM 요소: updateDomProps로 속성 업데이트 후 자식 재조정 - // - 컴포넌트: 컴포넌트 함수 재실행 후 자식 재조정 - return null; + return updateInstance(parentDom, instance, node, path); +}; + +/** + * 새로운 노드를 마운트합니다. + */ +const mountNode = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const { type } = node; + + // TEXT 노드 + if (type === TEXT_ELEMENT) { + const dom = document.createTextNode(node.props?.nodeValue ?? ""); + const instance: Instance = { + kind: NodeTypes.TEXT, + dom, + node, + children: [], + key: node.key, + path, + }; + parentDom.appendChild(dom); + return instance; + } + + // Fragment + if (type === Fragment) { + const children = (node.props?.children ?? []).filter((child) => !isEmptyValue(child)); + const childInstances: (Instance | null)[] = []; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childPath = createChildPath(path, child.key, i, child.type, children); + const childInstance = reconcile(parentDom, null, child, childPath); + childInstances.push(childInstance); + } + + const instance: Instance = { + kind: NodeTypes.FRAGMENT, + dom: getFirstDomFromChildren(childInstances), + node, + children: childInstances, + key: node.key, + path, + }; + + return instance; + } + + // 함수 컴포넌트 + if (typeof type === "function") return mountComponent(parentDom, node, path); + + // HOST (DOM 요소) + const dom = document.createElement(type as string); + setDomProps(dom, node.props); + + const children = (node.props?.children ?? []).filter((child) => !isEmptyValue(child)); + const childInstances: (Instance | null)[] = []; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const childPath = createChildPath(path, child.key, i, child.type, children); + const childInstance = reconcile(dom, null, child, childPath); + + if (childInstance) insertInstance(dom, childInstance); + childInstances.push(childInstance); + } + + parentDom.appendChild(dom); + + const instance: Instance = { + kind: NodeTypes.HOST, + dom, + node, + children: childInstances, + key: node.key, + path, + }; + + return instance; +}; + +/** + * 함수 컴포넌트를 마운트합니다. + */ +const mountComponent = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const { type, props } = node; + const component = type as React.ComponentType; + + // 훅 컨텍스트 설정 + context.hooks.componentStack.push(path); + context.hooks.cursor.set(path, 0); + context.hooks.visited.add(path); + + try { + // 컴포넌트 함수 실행 + const childNode = component(props ?? {}); + + // 자식 노드 마운트 + const childPath = createChildPath(path, childNode?.key ?? null, 0, childNode?.type, childNode ? [childNode] : []); + const childInstance = reconcile(parentDom, null, childNode, childPath); + + const instance: Instance = { + kind: NodeTypes.COMPONENT, + dom: getFirstDom(childInstance), + node, + children: childInstance ? [childInstance] : [], + key: node.key, + path, + }; + + return instance; + } finally { + // 훅 컨텍스트 정리 + context.hooks.componentStack.pop(); + } +}; + +/** + * 기존 인스턴스를 업데이트합니다. + */ +const updateInstance = (parentDom: HTMLElement, instance: Instance, node: VNode, path: string): Instance => { + const { kind } = instance; + + // TEXT 노드 업데이트 + if (kind === NodeTypes.TEXT) { + const newValue = node.props?.nodeValue ?? ""; + if (instance.dom && instance.dom.nodeValue !== newValue) instance.dom.nodeValue = newValue; + instance.node = node; + return instance; + } + + // Fragment 업데이트 + if (kind === NodeTypes.FRAGMENT) { + reconcileChildren(parentDom, instance, node, path); + instance.dom = getFirstDomFromChildren(instance.children); + instance.node = node; + return instance; + } + + // 함수 컴포넌트 업데이트 + if (kind === NodeTypes.COMPONENT) return updateComponent(parentDom, instance, node, path); + + // HOST (DOM 요소) 업데이트 + updateDomProps(instance.dom as HTMLElement, instance.node.props ?? {}, node.props ?? {}); + reconcileChildren(instance.dom as HTMLElement, instance, node, path); + instance.node = node; + + return instance; +}; + +/** + * 함수 컴포넌트를 업데이트합니다. + */ +const updateComponent = (parentDom: HTMLElement, instance: Instance, node: VNode, path: string): Instance => { + const { type, props } = node; + const component = type as React.ComponentType; + + // 훅 컨텍스트 복원 + context.hooks.componentStack.push(path); + context.hooks.cursor.set(path, 0); + context.hooks.visited.add(path); + + try { + // 컴포넌트 함수 재실행 + const childNode = component(props ?? {}); + + // 자식 노드 재조정 + const oldChildInstance = instance.children[0] ?? null; + const childPath = createChildPath(path, childNode?.key ?? null, 0, childNode?.type, childNode ? [childNode] : []); + const newChildInstance = reconcile(parentDom, oldChildInstance, childNode, childPath); + + instance.dom = getFirstDom(newChildInstance); + instance.children = newChildInstance ? [newChildInstance] : []; + instance.node = node; + + return instance; + } finally { + // 훅 컨텍스트 정리 + context.hooks.componentStack.pop(); + } +}; + +/** + * 자식 노드들을 재조정합니다. + */ +const reconcileChildren = (parentDom: HTMLElement, instance: Instance, node: VNode, path: string): void => { + const oldChildren = instance.children; + const newChildren = node.props?.children ?? []; + + // key 기반 맵 생성 + const keyMap = new Map(); + const keylessOldChildren: Instance[] = []; + + for (const oldChild of oldChildren) { + if (!oldChild) continue; + if (oldChild.key != null) keyMap.set(oldChild.key, oldChild); + else keylessOldChildren.push(oldChild); + } + + const newChildInstances: (Instance | null)[] = []; + const usedOldInstances = new Set(); + + // 새로운 자식들을 순회하며 매칭 + for (let i = 0; i < newChildren.length; i++) { + const newChild = newChildren[i]; + const childPath = createChildPath(path, newChild.key, i, newChild.type, newChildren); + + let matchedInstance: Instance | null = null; + + // key로 매칭 시도 + if (newChild.key != null) { + matchedInstance = keyMap.get(newChild.key) ?? null; + if (matchedInstance) usedOldInstances.add(matchedInstance); + } + + // key 매칭 실패 시 타입과 위치로 매칭 시도 + if (!matchedInstance) { + for (const oldChild of keylessOldChildren) { + if (usedOldInstances.has(oldChild)) continue; + if (oldChild.node.type === newChild.type) { + matchedInstance = oldChild; + usedOldInstances.add(oldChild); + break; + } + } + } + + // 재조정 + const newChildInstance = reconcile(parentDom, matchedInstance, newChild, childPath); + newChildInstances.push(newChildInstance); + } + + // 사용되지 않은 기존 자식들 제거 + for (const oldChild of oldChildren) { + if (oldChild && !usedOldInstances.has(oldChild)) removeInstance(parentDom, oldChild); + } + + // 역순으로 DOM 재배치 + let anchor: HTMLElement | Text | null = null; + for (let i = newChildInstances.length - 1; i >= 0; i--) { + const newChildInstance = newChildInstances[i]; + + if (newChildInstance) { + const firstDom = getFirstDom(newChildInstance); + if (firstDom) { + insertInstance(parentDom, newChildInstance, anchor); + anchor = firstDom; + } + } + } + + instance.children = newChildInstances; }; diff --git a/packages/react/src/core/render.ts b/packages/react/src/core/render.ts index 79c4bbb8..12b8fec6 100644 --- a/packages/react/src/core/render.ts +++ b/packages/react/src/core/render.ts @@ -1,18 +1,36 @@ +import { withEnqueue } from "../utils"; import { context } from "./context"; import { getDomNodes, insertInstance } from "./dom"; -import { reconcile } from "./reconciler"; import { cleanupUnusedHooks } from "./hooks"; -import { withEnqueue } from "../utils"; +import { reconcile } from "./reconciler"; /** * 루트 컴포넌트의 렌더링을 수행하는 함수입니다. * `enqueueRender`에 의해 스케줄링되어 호출됩니다. */ export const render = (): void => { - // 여기를 구현하세요. // 1. 훅 컨텍스트를 초기화합니다. + // 렌더링 시작 시 방문 기록과 커서를 초기화하여 새로운 렌더링에서 + // 어떤 컴포넌트가 방문되었는지 추적할 수 있도록 합니다. + context.hooks.visited.clear(); + context.hooks.cursor.clear(); + // 2. reconcile 함수를 호출하여 루트 노드를 재조정합니다. + const { container, node, instance } = context.root; + if (!container || !node) return; + + const newInstance = reconcile(container, instance, node, "0"); + context.root.instance = newInstance; + + // reconcile 후 생성된 인스턴스의 DOM 노드들을 확인하고 필요하면 컨테이너에 삽입합니다. + if (newInstance) { + const domNodes = getDomNodes(newInstance); + // 실제로 컨테이너에 없는 노드가 있는지 확인합니다. + if (domNodes.some((node) => node && node.parentNode !== container)) insertInstance(container, newInstance); + } + // 3. 사용되지 않은 훅들을 정리(cleanupUnusedHooks)합니다. + cleanupUnusedHooks(); }; /** diff --git a/packages/react/src/core/setup.ts b/packages/react/src/core/setup.ts index 03813995..ae564a5f 100644 --- a/packages/react/src/core/setup.ts +++ b/packages/react/src/core/setup.ts @@ -1,8 +1,8 @@ import { context } from "./context"; -import { VNode } from "./types"; import { removeInstance } from "./dom"; import { cleanupUnusedHooks } from "./hooks"; import { render } from "./render"; +import { VNode } from "./types"; /** * Mini-React 애플리케이션의 루트를 설정하고 첫 렌더링을 시작합니다. @@ -11,9 +11,23 @@ import { render } from "./render"; * @param container - VNode가 렌더링될 DOM 컨테이너 */ export const setup = (rootNode: VNode | null, container: HTMLElement): void => { - // 여기를 구현하세요. // 1. 컨테이너 유효성을 검사합니다. + if (!container || !(container instanceof HTMLElement)) throw new Error("Container is required"); + + // null 루트 엘리먼트는 렌더할 수 없습니다. + if (rootNode === null) throw new Error("Cannot render null root element"); + // 2. 이전 렌더링 내용을 정리하고 컨테이너를 비웁니다. + const previousInstance = context.root.instance; + if (previousInstance) removeInstance(container, previousInstance); + container.textContent = ""; + // 3. 루트 컨텍스트와 훅 컨텍스트를 리셋합니다. + cleanupUnusedHooks(); + context.root.reset({ container, node: rootNode }); + context.hooks.clear(); + context.effects.queue.length = 0; + // 4. 첫 렌더링을 실행합니다. + render(); }; diff --git a/packages/react/src/hocs/deepMemo.ts b/packages/react/src/hocs/deepMemo.ts index 9f0177d6..133b535d 100644 --- a/packages/react/src/hocs/deepMemo.ts +++ b/packages/react/src/hocs/deepMemo.ts @@ -1,12 +1,11 @@ +import type { FunctionComponent } from "../core"; import { deepEquals } from "../utils"; import { memo } from "./memo"; -import type { FunctionComponent } from "../core"; /** * `deepEquals`를 사용하여 props를 깊게 비교하는 `memo` HOC입니다. */ export function deepMemo

(Component: FunctionComponent

) { - // 여기를 구현하세요. // memo HOC와 deepEquals 함수를 사용해야 합니다. return memo(Component, deepEquals); } diff --git a/packages/react/src/hocs/memo.ts b/packages/react/src/hocs/memo.ts index 24569ce4..16e39438 100644 --- a/packages/react/src/hocs/memo.ts +++ b/packages/react/src/hocs/memo.ts @@ -1,5 +1,5 @@ -import { useRef } from "../hooks"; import { type FunctionComponent, type VNode } from "../core"; +import { useRef } from "../hooks"; import { shallowEquals } from "../utils"; /** @@ -12,10 +12,19 @@ import { shallowEquals } from "../utils"; */ export function memo

(Component: FunctionComponent

, equals = shallowEquals) { const MemoizedComponent: FunctionComponent

= (props) => { - // 여기를 구현하세요. // useRef를 사용하여 이전 props와 렌더링 결과를 저장해야 합니다. + const ref = useRef<{ prevProps: P | null; result: VNode | null }>({ prevProps: null, result: null }); + // equals 함수로 이전 props와 현재 props를 비교하여 렌더링 여부를 결정합니다. - return Component(props); + if (!ref.current.prevProps || !equals(ref.current.prevProps, props)) { + ref.current.result = Component(props); + } + + // 이전 props를 항상 현재 props로 업데이트합니다. + ref.current.prevProps = props; + + // props가 동일하면 이전 렌더링 결과를 재사용합니다. + return ref.current.result; }; MemoizedComponent.displayName = `Memo(${Component.displayName || Component.name})`; diff --git a/packages/react/src/hooks/useAutoCallback.ts b/packages/react/src/hooks/useAutoCallback.ts index 19d48f72..116516ae 100644 --- a/packages/react/src/hooks/useAutoCallback.ts +++ b/packages/react/src/hooks/useAutoCallback.ts @@ -9,7 +9,11 @@ import { useRef } from "./useRef"; * @returns 참조가 안정적인 콜백 함수 */ export const useAutoCallback = (fn: T): T => { - // 여기를 구현하세요. - // useRef와 useCallback을 조합하여 구현해야 합니다. - return fn; + // useRef를 사용하여 최신 함수를 저장합니다. + const fnRef = useRef(fn); + fnRef.current = fn; + + // useCallback을 사용하여 안정적인 함수 참조를 생성합니다. + // 빈 의존성 배열을 사용하여 함수 참조는 변경되지 않지만, 내부에서 최신 fnRef.current를 호출합니다. + return useCallback((...args: Parameters) => fnRef.current(...args), []) as T; }; diff --git a/packages/react/src/hooks/useCallback.ts b/packages/react/src/hooks/useCallback.ts index c0043993..625bd98c 100644 --- a/packages/react/src/hooks/useCallback.ts +++ b/packages/react/src/hooks/useCallback.ts @@ -1,3 +1,4 @@ +import { AnyFunction } from "../types"; import { DependencyList } from "./types"; import { useMemo } from "./useMemo"; @@ -9,8 +10,8 @@ import { useMemo } from "./useMemo"; * @param deps - 의존성 배열 * @returns 메모이제이션된 콜백 함수 */ -export const useCallback = any>(callback: T, deps: DependencyList): T => { - // 여기를 구현하세요. - // useMemo를 사용하여 구현할 수 있습니다. - return callback; +export const useCallback = (callback: T, deps: DependencyList): T => { + // useMemo를 사용하여 함수를 메모이제이션합니다. + // 의존성이 변경되지 않으면 같은 함수 참조를 반환하고, 변경되면 새로운 함수를 반환합니다. + return useMemo(() => callback, deps); }; diff --git a/packages/react/src/hooks/useDeepMemo.ts b/packages/react/src/hooks/useDeepMemo.ts index f968d05a..16b47b7f 100644 --- a/packages/react/src/hooks/useDeepMemo.ts +++ b/packages/react/src/hooks/useDeepMemo.ts @@ -6,7 +6,6 @@ import { useMemo } from "./useMemo"; * `deepEquals`를 사용하여 의존성을 깊게 비교하는 `useMemo` 훅입니다. */ export const useDeepMemo = (factory: () => T, deps: DependencyList): T => { - // 여기를 구현하세요. - // useMemo와 deepEquals 함수를 사용해야 합니다. - return factory(); + // useMemo를 사용하되 deepEquals를 equals 함수로 전달하여 깊은 비교를 수행합니다. + return useMemo(factory, deps, deepEquals); }; diff --git a/packages/react/src/hooks/useMemo.ts b/packages/react/src/hooks/useMemo.ts index c275d0e1..84792402 100644 --- a/packages/react/src/hooks/useMemo.ts +++ b/packages/react/src/hooks/useMemo.ts @@ -1,6 +1,6 @@ +import { shallowEquals } from "../utils"; import { DependencyList } from "./types"; import { useRef } from "./useRef"; -import { shallowEquals } from "../utils"; /** * 계산 비용이 큰 함수의 결과를 메모이제이션합니다. @@ -12,8 +12,13 @@ import { shallowEquals } from "../utils"; * @returns 메모이제이션된 값 */ export const useMemo = (factory: () => T, deps: DependencyList, equals = shallowEquals): T => { - // 여기를 구현하세요. // useRef를 사용하여 이전 의존성 배열과 계산된 값을 저장해야 합니다. + const ref = useRef<{ deps: DependencyList; value: T } | null>(null); + // equals 함수로 의존성을 비교하여 factory 함수를 재실행할지 결정합니다. - return factory(); + if (!ref.current || !equals(ref.current.deps, deps)) { + ref.current = { deps, value: factory() }; + } + + return ref.current.value; }; diff --git a/packages/react/src/hooks/useRef.ts b/packages/react/src/hooks/useRef.ts index d5521ca1..ac7c3a8e 100644 --- a/packages/react/src/hooks/useRef.ts +++ b/packages/react/src/hooks/useRef.ts @@ -8,7 +8,8 @@ import { useState } from "../core"; * @returns `{ current: T }` 형태의 ref 객체 */ export const useRef = (initialValue: T): { current: T } => { - // 여기를 구현하세요. - // useState를 사용하여 ref 객체를 한 번만 생성하도록 해야 합니다. - return { current: initialValue }; + // useState의 lazy initialization을 사용하여 ref 객체를 한 번만 생성합니다. + // 이후 렌더링에서는 같은 객체를 반환하므로 참조가 유지됩니다. + const [ref] = useState(() => ({ current: initialValue })); + return ref; }; diff --git a/packages/react/src/utils/enqueue.ts b/packages/react/src/utils/enqueue.ts index a4957d53..899b3a7f 100644 --- a/packages/react/src/utils/enqueue.ts +++ b/packages/react/src/utils/enqueue.ts @@ -5,7 +5,11 @@ import type { AnyFunction } from "../types"; * 브라우저의 `queueMicrotask` 또는 `Promise.resolve().then()`을 사용합니다. */ export const enqueue = (callback: () => void) => { - // 여기를 구현하세요. + // 환경에 따라 queueMicrotask가 없을 수 있으므로 Promise로 폴리필 + const schedule = + typeof queueMicrotask === "function" ? queueMicrotask : (cb: () => void) => Promise.resolve().then(cb); + + schedule(callback); }; /** @@ -13,7 +17,17 @@ export const enqueue = (callback: () => void) => { * 렌더링이나 이펙트 실행과 같은 작업의 중복을 방지하는 데 사용됩니다. */ export const withEnqueue = (fn: AnyFunction) => { - // 여기를 구현하세요. - // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현합니다. - return () => {}; + // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현 + let scheduled = false; + + return (...args: Parameters): void => { + if (scheduled) return; + scheduled = true; + + enqueue(() => { + scheduled = false; + + fn(...args); + }); + }; }; diff --git a/packages/react/src/utils/equals.ts b/packages/react/src/utils/equals.ts index 31ec4ba5..ab860f5b 100644 --- a/packages/react/src/utils/equals.ts +++ b/packages/react/src/utils/equals.ts @@ -3,9 +3,37 @@ * 객체와 배열은 1단계 깊이까지만 비교합니다. */ export const shallowEquals = (a: unknown, b: unknown): boolean => { - // 여기를 구현하세요. - // Object.is(), Array.isArray(), Object.keys() 등을 활용하여 1단계 깊이의 비교를 구현합니다. - return a === b; + if (Object.is(a, b)) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return false; + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + if (aIsArray || bIsArray) { + if (!aIsArray || !bIsArray) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!Object.is(a[i], b[i])) return false; + } + return true; + } + + if (typeof a === "object") { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false; + if (!Object.is(aObj[key], bObj[key])) return false; + } + return true; + } + + return Object.is(a, b); }; /** @@ -13,7 +41,35 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { * 객체와 배열의 모든 중첩된 속성을 재귀적으로 비교합니다. */ export const deepEquals = (a: unknown, b: unknown): boolean => { - // 여기를 구현하세요. - // 재귀적으로 deepEquals를 호출하여 중첩된 구조를 비교해야 합니다. - return a === b; + if (Object.is(a, b)) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return false; + + const aIsArray = Array.isArray(a); + const bIsArray = Array.isArray(b); + if (aIsArray || bIsArray) { + if (!aIsArray || !bIsArray) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (!deepEquals(a[i], b[i])) return false; + } + return true; + } + + if (typeof a === "object") { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(bObj, key)) return false; + if (!deepEquals(aObj[key], bObj[key])) return false; + } + return true; + } + + return Object.is(a, b); }; diff --git a/packages/react/src/utils/validators.ts b/packages/react/src/utils/validators.ts index da81b3dd..1bb1b750 100644 --- a/packages/react/src/utils/validators.ts +++ b/packages/react/src/utils/validators.ts @@ -6,6 +6,6 @@ * @returns 렌더링되지 않아야 하면 true, 그렇지 않으면 false */ export const isEmptyValue = (value: unknown): boolean => { - // 여기를 구현하세요. - return false; + // null, undefined, boolean 값(true/false)은 렌더링 대상이 아님 + return value == null || typeof value === "boolean"; };