diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ce399ab0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - 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: "./packages/app/dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/package.json b/package.json index ad975ab4..3d33576b 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "pnpm run -r build", "lint:fix": "eslint --fix", "prettier:write": "prettier --write ./packages/*/src", - "preview": "vite preview", + "preview": "pnpm -F @hanghae-plus/shopping preview", "test": "pnpm -F @hanghae-plus/react test", "test:basic": "pnpm -F @hanghae-plus/react test:basic", "test:advanced": "pnpm -F @hanghae-plus/react test:advanced", diff --git a/packages/react/src/__tests__/basic.mini-react.test.tsx b/packages/react/src/__tests__/basic.mini-react.test.tsx index d6fe39ed..cff981b7 100644 --- a/packages/react/src/__tests__/basic.mini-react.test.tsx +++ b/packages/react/src/__tests__/basic.mini-react.test.tsx @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; // eslint-disable-next-line @typescript-eslint/no-unused-vars import { createElement, Fragment, setup, useEffect, useState } from "../core"; import { TEXT_ELEMENT } from "../core/constants"; +import { context } from "../core/context"; const flushMicrotasks = async () => await Promise.resolve(); @@ -692,6 +693,7 @@ describe("Chapter 2-2 기본과제: MiniReact", () => { initializerCalls += 1; return 1; }); + console.log({ value }); setValue = update; return
{value}
; } @@ -705,6 +707,8 @@ describe("Chapter 2-2 기본과제: MiniReact", () => { setValue!(5); await flushMicrotasks(); + console.log(context.hooks.state); + div = container.firstElementChild as HTMLElement; expect(initializerCalls).toBe(1); expect(div?.textContent).toBe("5"); diff --git a/packages/react/src/core/context.ts b/packages/react/src/core/context.ts index bf41d61d..17a60845 100644 --- a/packages/react/src/core/context.ts +++ b/packages/react/src/core/context.ts @@ -13,8 +13,10 @@ 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 +34,39 @@ export const context: Context = { * 모든 훅 관련 상태를 초기화합니다. */ clear() { - // 여기를 구현하세요. // state, cursor, visited, componentStack을 모두 비웁니다. + this.state.clear(); + this.cursor.clear(); + this.visited.clear(); + this.componentStack = []; }, /** * 현재 실행 중인 컴포넌트의 고유 경로를 반환합니다. */ get currentPath() { - // 여기를 구현하세요. - // componentStack의 마지막 요소를 반환해야 합니다. // 스택이 비어있으면 '훅은 컴포넌트 내부에서만 호출되어야 한다'는 에러를 발생시켜야 합니다. - return ""; + if (this.componentStack.length === 0) { + throw new Error("훅은 컴포넌트 내부에서만 호출되어야 한다"); + } + // componentStack의 마지막 요소를 반환해야 합니다. + return this.componentStack[this.componentStack.length - 1]; }, /** * 현재 컴포넌트에서 다음에 실행될 훅의 인덱스(커서)를 반환합니다. */ get currentCursor() { - // 여기를 구현하세요. // cursor Map에서 현재 경로의 커서를 가져옵니다. 없으면 0을 반환합니다. - return 0; + return this.cursor.get(this.currentPath) ?? 0; }, /** * 현재 컴포넌트의 훅 상태 배열을 반환합니다. */ get currentHooks() { - // 여기를 구현하세요. // state Map에서 현재 경로의 훅 배열을 가져옵니다. 없으면 빈 배열을 반환합니다. - return []; + return this.state.get(this.currentPath) ?? []; }, }, @@ -71,4 +76,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..467cfa22 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,46 @@ import { Instance } from "./types"; * 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다. */ export const setDomProps = (dom: HTMLElement, props: Record): void => { - // 여기를 구현하세요. + if (!props || props === null) return; + Object.keys(props).forEach((key) => { + if (key === "children") return; + if (dom.nodeType === Node.TEXT_NODE) return; + if (!dom) return; + + const value = props[key]; + + // 이벤트 핸들러 처리 + if (key.startsWith("on") && typeof value === "function") { + const eventName = key.slice(2).toLowerCase(); + (dom as HTMLElement).addEventListener(eventName, value as EventListener); + return; + } + + // 스타일 객체 처리 + if (key === "style" && typeof value === "object") { + Object.assign((dom as HTMLElement).style, value); + return; + } + + // className 속성 처리 + if (key === "className") { + (dom as HTMLElement).setAttribute("class", value as string); + return; + } + + // 일반 HTML 속성은 setAttribute로 설정 + // 단, boolean 값은 속성의 존재 여부로 처리 + if (typeof value === "boolean") { + if (value) { + (dom as HTMLElement).setAttribute(key, ""); + } else { + (dom as HTMLElement).removeAttribute(key); + } + return; + } + + (dom as HTMLElement)?.setAttribute(key, String(value)); + }); }; /** @@ -19,7 +58,46 @@ export const updateDomProps = ( prevProps: Record = {}, nextProps: Record = {}, ): void => { - // 여기를 구현하세요. + // 이벤트 핸들러 처리: 변경되거나 제거된 이벤트 핸들러 제거 + Object.keys(prevProps).forEach((key) => { + if (key === "children") return; + + const prevValue = prevProps[key]; + const nextValue = nextProps[key]; + + // 이벤트 핸들러인 경우 + if (key.startsWith("on") && typeof prevValue === "function") { + const eventName = key.slice(2).toLowerCase(); + + // 이벤트 핸들러가 제거되었거나 변경된 경우 + if (nextValue === undefined || nextValue !== prevValue) { + dom.removeEventListener(eventName, prevValue as EventListener); + } + } + }); + + // nextProps에 없는 일반 속성 제거 + Object.keys(prevProps).forEach((key) => { + if (key === "children") return; + if (key.startsWith("on")) return; // 이벤트 핸들러는 위에서 처리됨 + + if (nextProps[key] === undefined) { + // className은 "class" 속성으로 제거 + if (key === "className") { + dom.removeAttribute("class"); + } else if (key === "style" && typeof prevProps[key] === "object") { + // 스타일 객체의 경우 모든 속성 제거 + Object.keys(prevProps[key] || {}).forEach((styleKey) => { + (dom as HTMLElement).style.removeProperty(styleKey); + }); + } else { + dom.removeAttribute(key); + } + } + }); + + // 새 속성 설정 (변경된 속성 포함) + setDomProps(dom, nextProps); }; /** @@ -27,24 +105,21 @@ export const updateDomProps = ( * Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다. */ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { - // 여기를 구현하세요. - return []; + return instance?.children.map((child) => child?.dom as HTMLElement | Text) ?? []; }; /** * 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => { - // 여기를 구현하세요. - return null; + return instance?.dom as HTMLElement | Text | null; }; /** * 자식 인스턴스들로부터 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => { - // 여기를 구현하세요. - return null; + return children.find((child) => child?.dom) as HTMLElement | Text | null; }; /** @@ -56,12 +131,31 @@ export const insertInstance = ( instance: Instance | null, anchor: HTMLElement | Text | null = null, ): void => { - // 여기를 구현하세요. + parentDom.insertBefore(instance?.dom as Node, anchor); }; /** * 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다. */ export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => { - // 여기를 구현하세요. + if (!instance) return; + + // Fragment나 Component의 경우 여러 DOM 노드를 가질 수 있음 + if (instance.kind === NodeTypes.FRAGMENT || instance.kind === NodeTypes.COMPONENT) { + // 자식 인스턴스들을 재귀적으로 제거 + if (instance.children) { + instance.children.forEach((child) => { + if (child && child.dom && parentDom.contains(child.dom as Node)) { + // 자식의 DOM이 부모에 포함되어 있으면 제거 + parentDom.removeChild(child.dom as Node); + } + }); + } + return; + } + + // HOST나 TEXT의 경우 instance.dom을 제거하면 자식들도 함께 제거됨 + if (instance.dom && parentDom.contains(instance.dom as Node)) { + parentDom.removeChild(instance.dom as Node); + } }; diff --git a/packages/react/src/core/elements.ts b/packages/react/src/core/elements.ts index d04bce98..82f95b77 100644 --- a/packages/react/src/core/elements.ts +++ b/packages/react/src/core/elements.ts @@ -8,16 +8,16 @@ import { Fragment, TEXT_ELEMENT } from "./constants"; * null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다. */ export const normalizeNode = (node: VNode): VNode | null => { - // 여기를 구현하세요. + if (typeof node === "string" || typeof node === "number") return createTextElement(node); + if (typeof node === "object" && node !== null) return node; return null; }; /** * 텍스트 노드를 위한 VNode를 생성합니다. */ -const createTextElement = (node: VNode): VNode => { - // 여기를 구현하세요. - return {} as VNode; +const createTextElement = (node: string | number): VNode => { + return { type: TEXT_ELEMENT, key: null, props: { children: [], nodeValue: node.toString() } } as VNode; }; /** @@ -29,12 +29,37 @@ export const createElement = ( originProps?: Record | null, ...rawChildren: any[] ) => { - // 여기를 구현하세요. + const { key, ...resetProps } = originProps || {}; + const children = rawChildren.flat(Infinity).map(normalizeNode).filter(isEmptyValue); + const isChildren = children.length > 0; + + return { + type, + key: key || null, + props: { + ...resetProps, + ...(isChildren ? { children } : {}), + }, + }; }; /** - * 부모 경로와 자식의 key/index를 기반으로 고유한 경로를 생성합니다. + * 타입을 문자열로 변환합니다. + */ +const getTypeString = (type: string | symbol | React.ComponentType | undefined): string => { + if (!type) return "unknown"; + if (typeof type === "string") return type; + if (typeof type === "symbol") return type.toString(); + if (typeof type === "function") { + return (type as any).name || (type as any).displayName || "Anonymous"; + } + return String(type); +}; + +/** + * 부모 경로와 자식의 key/index/type을 기반으로 고유한 경로를 생성합니다. * 이는 훅의 상태를 유지하고 Reconciliation에서 컴포넌트를 식별하는 데 사용됩니다. + * key가 없을 때는 타입 정보를 포함하여 같은 타입의 컴포넌트가 항상 같은 경로를 유지하도록 합니다. */ export const createChildPath = ( parentPath: string, @@ -43,6 +68,16 @@ export const createChildPath = ( nodeType?: string | symbol | React.ComponentType, siblings?: VNode[], ): string => { - // 여기를 구현하세요. - return ""; + // key가 있으면 key 기반 경로 + if (key !== null) { + return `${parentPath}.k${key}`; + } + + // key가 없으면 타입 정보를 포함한 경로 생성 + // 같은 타입의 컴포넌트는 같은 타입 식별자를 가지므로 경로가 유지됨 + const typeStr = getTypeString(nodeType); + // 타입 이름에서 특수문자 제거 (경로에 사용 가능한 문자만 사용) + const sanitizedType = typeStr.replace(/[^a-zA-Z0-9_$]/g, "_"); + + return `${parentPath}.c${index}.t${sanitizedType}`; }; diff --git a/packages/react/src/core/hooks.ts b/packages/react/src/core/hooks.ts index ef35d0f6..0198eec4 100644 --- a/packages/react/src/core/hooks.ts +++ b/packages/react/src/core/hooks.ts @@ -1,4 +1,4 @@ -import { shallowEquals, withEnqueue } from "../utils"; +import { shallowEquals } from "../utils"; import { context } from "./context"; import { EffectHook } from "./types"; import { enqueueRender } from "./render"; @@ -8,7 +8,32 @@ import { HookTypes } from "./constants"; * 사용되지 않는 컴포넌트의 훅 상태와 이펙트 클린업 함수를 정리합니다. */ export const cleanupUnusedHooks = () => { - // 여기를 구현하세요. + // state에 저장된 모든 경로 가져오기 + const allPaths = Array.from(context.hooks.state.keys()); + const visited = context.hooks.visited; + + // 이번 렌더링에서 방문하지 않은 경로는 언마운트된 컴포넌트 + for (const path of allPaths) { + if (!visited.has(path)) { + // 언마운트된 컴포넌트의 모든 이펙트 클린업 함수 실행 + const hooks = context.hooks.state.get(path); + if (hooks) { + hooks.forEach((hook) => { + if (hook && typeof hook === "object" && "kind" in hook && hook.kind === HookTypes.EFFECT) { + const effectHook = hook as EffectHook; + if (effectHook.cleanup) { + effectHook.cleanup(); + } + } + }); + } + + // 해당 경로의 state 삭제 + context.hooks.state.delete(path); + // cursor도 삭제 + context.hooks.cursor.delete(path); + } + } }; /** @@ -18,14 +43,51 @@ export const cleanupUnusedHooks = () => { */ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | ((prev: T) => T)) => void] => { // 여기를 구현하세요. + + // 컴포넌트 외부에서 호출시 // 1. 현재 컴포넌트의 훅 커서와 상태 배열을 가져옵니다. + // 실행중인 hook의 index + // hook 상태 배열 + const currentCursor = context.hooks.currentCursor; + const currentHooks = context.hooks.currentHooks; + const currentPath = context.hooks.currentPath; + // 2. 첫 렌더링이라면 초기값으로 상태를 설정합니다. + const isFirstRender = currentCursor >= currentHooks.length; + + if (isFirstRender) { + // initialValue가 함수면 실행, 아니면 그대로 사용 + const value = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + context.hooks.state.set(currentPath, [...currentHooks, value]); + } + // 3. 상태 변경 함수(setter)를 생성합니다. // - 새 값이 이전 값과 같으면(Object.is) 재렌더링을 건너뜁니다. // - 값이 다르면 상태를 업데이트하고 재렌더링을 예약(enqueueRender)합니다. // 4. 훅 커서를 증가시키고 [상태, setter]를 반환합니다. - const setState = (nextValue: T | ((prev: T) => T)) => {}; - return [initialValue as T, setState]; + const setState = (nextValue: T | ((prev: T) => T)) => { + const hooks = context.hooks.state.get(currentPath) || []; + const prevValue = hooks[currentCursor]; + + // 함수형 업데이트 처리 + const newValue = typeof nextValue === "function" ? (nextValue as (prev: T) => T)(prevValue) : nextValue; + + // Object.is로 값 비교 (React와 동일) + if (Object.is(prevValue, newValue)) { + return; + } + + const newState = [...hooks]; + newState[currentCursor] = newValue; + + context.hooks.state.set(currentPath, newState); + enqueueRender(); + }; + + const state = context.hooks.state.get(currentPath)?.[currentCursor]; + context.hooks.cursor.set(currentPath, currentCursor + 1); + + return [state as T, setState]; }; /** @@ -34,9 +96,50 @@ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | (( * @param deps - 의존성 배열. 이 값들이 변경될 때만 이펙트가 다시 실행됩니다. */ export const useEffect = (effect: () => (() => void) | void, deps?: unknown[]): void => { - // 여기를 구현하세요. + const currentCursor = context.hooks.currentCursor; + const currentHooks = context.hooks.currentHooks; + const currentPath = context.hooks.currentPath; + // 1. 이전 훅의 의존성 배열과 현재 의존성 배열을 비교(shallowEquals)합니다. + const isFirstRender = currentCursor >= currentHooks.length; + const prevHook = currentHooks[currentCursor] as EffectHook | undefined; + const prevDeps = prevHook?.deps; + + const depsChanged = !shallowEquals(prevDeps, deps); + // 2. 의존성이 변경되었거나 첫 렌더링일 경우, 이펙트 실행을 예약합니다. + if (isFirstRender || depsChanged) { + context.effects.queue.push({ + path: currentPath, + cursor: currentCursor, + effect, + }); + } + + const effectHook: EffectHook = { + kind: HookTypes.EFFECT, + deps: deps ?? null, + cleanup: null, + effect, + }; + + if (!isFirstRender) { + if (prevHook && prevHook.kind === HookTypes.EFFECT && prevHook.cleanup) { + effectHook.cleanup = prevHook.cleanup; + } + } + // 3. 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행합니다. // 4. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + // 상태 업데이트 + if (isFirstRender) { + context.hooks.state.set(currentPath, [...currentHooks, effectHook]); + } else { + const newHooks = [...currentHooks]; + newHooks[currentCursor] = effectHook; + context.hooks.state.set(currentPath, newHooks); + } + + // 4. 훅 커서 증가 + context.hooks.cursor.set(currentPath, currentCursor + 1); }; diff --git a/packages/react/src/core/reconciler.ts b/packages/react/src/core/reconciler.ts index 12cbdd39..88ec652f 100644 --- a/packages/react/src/core/reconciler.ts +++ b/packages/react/src/core/reconciler.ts @@ -1,16 +1,391 @@ import { context } from "./context"; -import { Fragment, NodeTypes, TEXT_ELEMENT } from "./constants"; -import { Instance, VNode } from "./types"; -import { - getFirstDom, - getFirstDomFromChildren, - insertInstance, - removeInstance, - setDomProps, - updateDomProps, -} from "./dom"; +import { Fragment, NodeType, NodeTypes, TEXT_ELEMENT, HookTypes } from "./constants"; +import { Instance, VNode, EffectHook } from "./types"; +import { getFirstDom, getFirstDomFromChildren, removeInstance, setDomProps, updateDomProps } from "./dom"; import { createChildPath } from "./elements"; -import { isEmptyValue } from "../utils"; + +/** + * 인스턴스의 모든 DOM 노드를 재귀적으로 수집합니다. + * Fragment나 컴포넌트의 경우 여러 DOM 노드를 가질 수 있습니다. + */ +const collectAllDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { + if (!instance) return []; + + const nodes: (HTMLElement | Text)[] = []; + + // Fragment나 컴포넌트의 경우 자식들의 DOM 노드를 수집 + if (instance.kind === NodeTypes.FRAGMENT || instance.kind === NodeTypes.COMPONENT) { + if (instance.children) { + instance.children.forEach((child) => { + nodes.push(...collectAllDomNodes(child)); + }); + } + } else if (instance.dom) { + // HOST나 TEXT의 경우 dom 속성 사용 + nodes.push(instance.dom as HTMLElement | Text); + } + + return nodes; +}; + +/** + * 인스턴스와 그 자식들의 cleanup 함수를 실행하고 hooks 상태를 정리합니다. + * key가 변경되어 언마운트될 때 호출됩니다. + */ +const cleanupInstance = (instance: Instance): void => { + // 인스턴스의 경로에 해당하는 hooks cleanup 실행 + const hooks = context.hooks.state.get(instance.path); + if (hooks) { + hooks.forEach((hook) => { + if (hook && typeof hook === "object" && "kind" in hook && hook.kind === HookTypes.EFFECT) { + const effectHook = hook as EffectHook; + if (effectHook.cleanup) { + effectHook.cleanup(); + } + } + }); + // hooks 상태 정리 + context.hooks.state.delete(instance.path); + context.hooks.cursor.delete(instance.path); + } + + // 자식 인스턴스들도 재귀적으로 cleanup + if (instance.children) { + instance.children.forEach((child) => { + if (child) { + cleanupInstance(child); + } + }); + } +}; + +/** + * 자식 인스턴스들의 DOM 노드를 올바른 순서로 재배치합니다. + * key 기반 재조정 후 DOM 노드의 순서를 업데이트합니다. + */ +const reorderDomNodes = (parentDom: HTMLElement, children: (Instance | null)[]): void => { + // 모든 DOM 노드를 순서대로 수집 (재귀적으로) + const domNodes: (HTMLElement | Text)[] = []; + for (const child of children) { + const childNodes = collectAllDomNodes(child); + domNodes.push(...childNodes); + } + + // 각 DOM 노드를 올바른 위치로 이동 + // 역순으로 처리하여 앞쪽 노드 이동이 뒤쪽 노드에 영향을 주지 않도록 함 + for (let i = domNodes.length - 1; i >= 0; i--) { + const currentDom = domNodes[i]; + if (!currentDom || !currentDom.parentNode) continue; + + const expectedNextSibling = i < domNodes.length - 1 ? domNodes[i + 1] : null; + const actualNextSibling = currentDom.nextSibling; + + // 다음 형제가 올바르지 않으면 재배치 + if (actualNextSibling !== expectedNextSibling) { + parentDom.insertBefore(currentDom, expectedNextSibling as Node | null); + } + } +}; + +/** + * 새로운 자식 VNode에 매칭되는 기존 자식 인스턴스를 찾습니다. + * key 기반 매칭을 우선하고, key가 없으면 타입 기반 매칭을 수행합니다. + */ +const findMatchingChild = ( + newChild: VNode, + newIndex: number, + oldChildren: (Instance | null)[], + usedOldIndices: Set, +): Instance | null => { + let matchedOldChild: Instance | null = null; + + // key가 있으면 key로 매칭 + if (newChild.key !== null) { + for (let i = 0; i < oldChildren.length; i++) { + if (usedOldIndices.has(i)) continue; + const oldChild = oldChildren[i]; + if (oldChild && oldChild.node.key === newChild.key) { + matchedOldChild = oldChild; + usedOldIndices.add(i); + break; + } + } + } else { + // key가 없으면 타입 기반 매칭 + // 먼저 같은 인덱스에서 타입이 같은지 확인 + if (newIndex < oldChildren.length) { + const oldChild = oldChildren[newIndex]; + if (oldChild && oldChild.node.type === newChild.type) { + matchedOldChild = oldChild; + usedOldIndices.add(newIndex); + } + } + + // 같은 인덱스에서 매칭되지 않으면 다른 위치에서 같은 타입 찾기 + if (!matchedOldChild) { + for (let i = 0; i < oldChildren.length; i++) { + if (usedOldIndices.has(i)) continue; + const oldChild = oldChildren[i]; + if (oldChild && oldChild.node.type === newChild.type && oldChild.node.key === null) { + matchedOldChild = oldChild; + usedOldIndices.add(i); + break; + } + } + } + } + + return matchedOldChild; +}; + +/** + * 자식 노드들을 재조정합니다. + * key 기반 매칭을 통해 최적화된 업데이트를 수행합니다. + */ +const reconcileChildren = ( + parentDom: HTMLElement, + parentPath: string, + oldChildren: (Instance | null)[], + newChildren: VNode[], + handleKeyMismatch?: (newChild: VNode, matchedOldChild: Instance | null) => void, +): (Instance | null)[] => { + const updatedChildren: (Instance | null)[] = []; + const usedOldIndices = new Set(); + + // 새로운 자식들을 순회하며 매칭 + newChildren.forEach((newChild, newIndex) => { + if (!newChild) { + updatedChildren.push(null); + return; + } + + const matchedOldChild = findMatchingChild(newChild, newIndex, oldChildren, usedOldIndices); + + // 경로 생성: 매칭된 인스턴스가 있고 타입이 같으면 기존 경로 유지, 없으면 새 경로 생성 + const childPath = + matchedOldChild && matchedOldChild.node.type === newChild.type + ? matchedOldChild.path + : createChildPath(parentPath, newChild.key, newIndex, newChild.type, newChildren); + + // 타입이 다르거나 key가 다르면 기존 인스턴스를 null로 처리하여 새로 마운트 + const oldChildForReconcile = + matchedOldChild && matchedOldChild.node.type === newChild.type ? matchedOldChild : null; + + // key 불일치 처리 (HOST/TEXT의 경우에만 필요) + if (handleKeyMismatch && newChild.key !== null && (!matchedOldChild || matchedOldChild.node.key !== newChild.key)) { + handleKeyMismatch(newChild, matchedOldChild); + } + + const childInstance = reconcile(parentDom, oldChildForReconcile, newChild, childPath); + updatedChildren.push(childInstance); + }); + + // 사용되지 않은 기존 자식들 언마운트 + for (let i = 0; i < oldChildren.length; i++) { + if (!usedOldIndices.has(i) && oldChildren[i]) { + reconcile(parentDom, oldChildren[i], null, oldChildren[i]!.path); + } + } + + return updatedChildren; +}; + +/** + * 노드 타입에 따라 kind를 결정합니다. + */ +const getNodeKind = (vNode: VNode): NodeType => { + if (vNode.type === TEXT_ELEMENT) return NodeTypes.TEXT; + if (vNode.type === Fragment) return NodeTypes.FRAGMENT; + if (typeof vNode.type === "function") return NodeTypes.COMPONENT; + return NodeTypes.HOST; +}; + +/** + * 컴포넌트를 마운트합니다. + */ +const mountComponent = (parentDom: HTMLElement, node: VNode, path: string): Instance | null => { + if (typeof node.type !== "function") return null; + + const renderedVNode = node.type(node.props); + if (renderedVNode === null) return null; + + const childPath = createChildPath(path, null, 0, renderedVNode.type); + const childInstance = reconcile(parentDom, null, renderedVNode, childPath); + + return { + kind: NodeTypes.COMPONENT, + dom: getFirstDom(childInstance), + node, + children: childInstance ? [childInstance] : [], + key: node.key, + path, + }; +}; + +/** + * 텍스트 노드를 마운트합니다. + */ +const mountText = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const dom = document.createTextNode(node.props.nodeValue as string); + parentDom.appendChild(dom); + + return { + kind: NodeTypes.TEXT, + dom, + node, + children: [], + key: node.key, + path, + }; +}; + +/** + * Fragment를 마운트합니다. + */ +const mountFragment = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const childInstances: (Instance | null)[] = []; + const children = node.props?.children || []; + + children.forEach((child, index) => { + const childPath = createChildPath(path, child.key, index, child.type, children); + const childInstance = reconcile(parentDom, null, child, childPath); + childInstances.push(childInstance); + }); + + return { + kind: NodeTypes.FRAGMENT, + dom: getFirstDomFromChildren(childInstances), + node, + children: childInstances, + key: node.key, + path, + }; +}; + +/** + * HOST 요소를 마운트합니다. + */ +const mountHost = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const dom = document.createElement(node.type as string); + setDomProps(dom, node.props); + parentDom.appendChild(dom); + + const newInstance: Instance = { + kind: NodeTypes.HOST, + dom, + node, + children: [], + key: node.key, + path, + }; + + // 자식들을 재귀적으로 마운트 + const children = node.props?.children || []; + const childInstances: (Instance | null)[] = []; + + children.forEach((child, index) => { + const childPath = createChildPath(path, child.key, index, child.type, children); + const childInstance = reconcile(dom, null, child, childPath); + childInstances.push(childInstance); + }); + + newInstance.children = childInstances; + return newInstance; +}; + +/** + * HOST 또는 TEXT 인스턴스를 업데이트합니다. + */ +const updateHost = (instance: Instance, node: VNode, path: string): Instance => { + if (instance.dom) { + updateDomProps(instance.dom as HTMLElement, instance.node.props, node.props); + + // 텍스트 노드 업데이트 + if (instance.kind === NodeTypes.TEXT && node.props.nodeValue !== undefined) { + (instance.dom as Text).nodeValue = String(node.props.nodeValue); + } + } + + // key 불일치 처리 함수 + const handleKeyMismatch = (newChild: VNode, matchedOldChild: Instance | null) => { + if (!matchedOldChild || matchedOldChild.node.key === newChild.key) return; + + // 이전 자식 중 같은 타입이지만 key가 다른 인스턴스 찾기 + const oldChildren = instance.children || []; + for (let i = 0; i < oldChildren.length; i++) { + const oldChild = oldChildren[i]; + if ( + oldChild && + oldChild.node.type === newChild.type && + oldChild.node.key !== newChild.key && + oldChild.node.key !== null + ) { + cleanupInstance(oldChild); + removeInstance(instance.dom as HTMLElement, oldChild); + break; + } + } + }; + + // 자식 재조정 + const oldChildren = instance.children || []; + const newChildren = node.props?.children || []; + const updatedChildren = reconcileChildren( + instance.dom as HTMLElement, + path, + oldChildren, + newChildren, + handleKeyMismatch, + ); + + // DOM 노드를 올바른 순서로 재배치 + reorderDomNodes(instance.dom as HTMLElement, updatedChildren); + + instance.children = updatedChildren; + instance.node = node; + return instance; +}; + +/** + * 컴포넌트 인스턴스를 업데이트합니다. + */ +const updateComponent = (parentDom: HTMLElement, instance: Instance, node: VNode, path: string): Instance | null => { + if (typeof node.type !== "function") return instance; + + // 컴포넌트 함수 재실행 + const renderedVNode = node.type(node.props); + + if (renderedVNode === null) { + removeInstance(parentDom, instance); + return null; + } + + // 렌더된 VNode를 재조정 + const childPath = createChildPath(path, null, 0, renderedVNode.type); + const childInstance = reconcile(parentDom, instance.children[0] || null, renderedVNode, childPath); + + instance.children = [childInstance]; + instance.node = node; + instance.dom = getFirstDom(childInstance); + return instance; +}; + +/** + * Fragment 인스턴스를 업데이트합니다. + */ +const updateFragment = (parentDom: HTMLElement, instance: Instance, node: VNode, path: string): Instance => { + const oldChildren = instance.children || []; + const newChildren = node.props?.children || []; + const updatedChildren = reconcileChildren(parentDom, path, oldChildren, newChildren); + + // DOM 노드를 올바른 순서로 재배치 + reorderDomNodes(parentDom, updatedChildren); + + instance.children = updatedChildren; + instance.node = node; + instance.dom = getFirstDomFromChildren(updatedChildren); + return instance; +}; /** * 이전 인스턴스와 새로운 VNode를 비교하여 DOM을 업데이트하는 재조정 과정을 수행합니다. @@ -27,12 +402,62 @@ export const reconcile = ( node: VNode | null, path: string, ): Instance | null => { - // 여기를 구현하세요. // 1. 새 노드가 null이면 기존 인스턴스를 제거합니다. (unmount) - // 2. 기존 인스턴스가 없으면 새 노드를 마운트합니다. (mount) - // 3. 타입이나 키가 다르면 기존 인스턴스를 제거하고 새로 마운트합니다. - // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) - // - DOM 요소: updateDomProps로 속성 업데이트 후 자식 재조정 - // - 컴포넌트: 컴포넌트 함수 재실행 후 자식 재조정 - return null; + if (node === null) { + if (instance) { + // 언마운트되는 컴포넌트는 visited에 포함하지 않아야 cleanupUnusedHooks가 정리할 수 있음 + // cleanup 실행 및 DOM 제거 + cleanupInstance(instance); + removeInstance(parentDom, instance); + } + + return null; + } + + context.hooks.componentStack.push(path); + context.hooks.visited.add(path); + + try { + // 2. 기존 인스턴스가 없으면 새 노드를 마운트합니다. (mount) + if (instance === null) { + const kind = getNodeKind(node); + + switch (kind) { + case NodeTypes.COMPONENT: + return mountComponent(parentDom, node, path); + case NodeTypes.TEXT: + return mountText(parentDom, node, path); + case NodeTypes.FRAGMENT: + return mountFragment(parentDom, node, path); + case NodeTypes.HOST: + return mountHost(parentDom, node, path); + default: + return instance; + } + } + + // 3. 타입이나 키가 다르면 기존 인스턴스를 제거하고 새로 마운트합니다. + if (instance.node.type !== node.type || instance.key !== node.key) { + // cleanup 실행 (key 변경 시 이전 인스턴스의 cleanup 필요) + cleanupInstance(instance); + removeInstance(parentDom, instance); + // 새로 마운트 + return reconcile(parentDom, null, node, path); + } + + // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) + switch (instance.kind) { + case NodeTypes.HOST: + case NodeTypes.TEXT: + return updateHost(instance, node, path); + case NodeTypes.COMPONENT: + return updateComponent(parentDom, instance, node, path); + case NodeTypes.FRAGMENT: + return updateFragment(parentDom, instance, node, path); + default: + return instance; + } + } finally { + context.hooks.componentStack.pop(); + } }; diff --git a/packages/react/src/core/render.ts b/packages/react/src/core/render.ts index 79c4bbb8..381cd5f9 100644 --- a/packages/react/src/core/render.ts +++ b/packages/react/src/core/render.ts @@ -1,18 +1,66 @@ import { context } from "./context"; -import { getDomNodes, insertInstance } from "./dom"; import { reconcile } from "./reconciler"; import { cleanupUnusedHooks } from "./hooks"; import { withEnqueue } from "../utils"; +import { HookTypes } from "./constants"; +import type { EffectHook } from "./types"; /** * 루트 컴포넌트의 렌더링을 수행하는 함수입니다. * `enqueueRender`에 의해 스케줄링되어 호출됩니다. */ +/** + * 예약된 이펙트들을 실행합니다. + * 렌더링이 끝난 후 비동기로 실행됩니다. + */ +const executeEffects = (): void => { + const effectsToRun = [...context.effects.queue]; + context.effects.queue = []; + + effectsToRun.forEach(({ path, cursor, effect }) => { + // path에 대한 훅 배열 가져오기 + const hooks = context.hooks.state.get(path); + if (!hooks) return; + + // 현재 커서에 해당하는 effect hook 가져오기 + const hook = hooks[cursor] as EffectHook | undefined; + if (!hook || hook.kind !== HookTypes.EFFECT) return; + + // 이전 클린업 함수 실행 + if (hook.cleanup) hook.cleanup(); + + // effect함수 실행후 return 값으로 new cleanup 함수 생성 + const cleanup = effect(); + + // 클린업 함수 저장 + const newHooks = [...hooks]; + const updatedHook: EffectHook = { + ...hook, + cleanup: cleanup || null, + }; + newHooks[cursor] = updatedHook; + context.hooks.state.set(path, newHooks); + }); +}; + export const render = (): void => { - // 여기를 구현하세요. // 1. 훅 컨텍스트를 초기화합니다. + context.hooks.cursor.clear(); + context.hooks.visited.clear(); + context.hooks.componentStack = []; + // 2. reconcile 함수를 호출하여 루트 노드를 재조정합니다. - // 3. 사용되지 않은 훅들을 정리(cleanupUnusedHooks)합니다. + context.root.instance = reconcile( + context.root.container as HTMLElement, + context.root.instance, + context.root.node, + "0", // 루트 경로 + ); + + // 3. 예약된 이펙트들을 실행합니다 (렌더링 후 비동기) + if (context.effects.queue.length > 0) queueMicrotask(executeEffects); + + cleanupUnusedHooks(); }; /** diff --git a/packages/react/src/core/setup.ts b/packages/react/src/core/setup.ts index 03813995..0120abe9 100644 --- a/packages/react/src/core/setup.ts +++ b/packages/react/src/core/setup.ts @@ -1,7 +1,6 @@ import { context } from "./context"; import { VNode } from "./types"; import { removeInstance } from "./dom"; -import { cleanupUnusedHooks } from "./hooks"; import { render } from "./render"; /** @@ -11,9 +10,23 @@ import { render } from "./render"; * @param container - VNode가 렌더링될 DOM 컨테이너 */ export const setup = (rootNode: VNode | null, container: HTMLElement): void => { - // 여기를 구현하세요. // 1. 컨테이너 유효성을 검사합니다. + if (!container) throw new Error("Container is required"); + if (rootNode === null) throw new Error("Root node is required"); + // 2. 이전 렌더링 내용을 정리하고 컨테이너를 비웁니다. + if (context.root.instance) { + removeInstance(container, context.root.instance); + } + // 컨테이너를 완전히 비웁니다 (removeInstance로 제거되지 않은 노드들) + while (container.firstChild) { + container.removeChild(container.firstChild); + } + // 3. 루트 컨텍스트와 훅 컨텍스트를 리셋합니다. + context.root.reset({ container, node: rootNode }); + context.hooks.clear(); + // 4. 첫 렌더링을 실행합니다. + render(); }; diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index d88c5714..26e6a729 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -55,7 +55,7 @@ export interface HooksContext { } export interface EffectsContext { - queue: Array<{ path: string; cursor: number }>; + queue: Array<{ path: string; cursor: number; effect: () => (() => void) | void }>; } export interface Context { diff --git a/packages/react/src/hocs/deepMemo.ts b/packages/react/src/hocs/deepMemo.ts index 9f0177d6..649be1d0 100644 --- a/packages/react/src/hocs/deepMemo.ts +++ b/packages/react/src/hocs/deepMemo.ts @@ -8,5 +8,8 @@ import type { FunctionComponent } from "../core"; export function deepMemo

(Component: FunctionComponent

) { // 여기를 구현하세요. // memo HOC와 deepEquals 함수를 사용해야 합니다. - return memo(Component, deepEquals); + + const MemoizedComponent = memo(Component, deepEquals); + + return MemoizedComponent; } diff --git a/packages/react/src/hocs/memo.ts b/packages/react/src/hocs/memo.ts index 24569ce4..6818a84f 100644 --- a/packages/react/src/hocs/memo.ts +++ b/packages/react/src/hocs/memo.ts @@ -12,10 +12,17 @@ import { shallowEquals } from "../utils"; */ export function memo

(Component: FunctionComponent

, equals = shallowEquals) { const MemoizedComponent: FunctionComponent

= (props) => { + const prevPropsRef = useRef

(null); + const component = useRef(null); + + if (prevPropsRef.current === null || !equals(prevPropsRef.current, props)) { + prevPropsRef.current = props; + component.current = Component(props); + } // 여기를 구현하세요. // useRef를 사용하여 이전 props와 렌더링 결과를 저장해야 합니다. // equals 함수로 이전 props와 현재 props를 비교하여 렌더링 여부를 결정합니다. - return Component(props); + return component.current; }; 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..a4733ea6 100644 --- a/packages/react/src/hooks/useAutoCallback.ts +++ b/packages/react/src/hooks/useAutoCallback.ts @@ -9,7 +9,20 @@ import { useRef } from "./useRef"; * @returns 참조가 안정적인 콜백 함수 */ export const useAutoCallback = (fn: T): T => { - // 여기를 구현하세요. - // useRef와 useCallback을 조합하여 구현해야 합니다. - return fn; + // useRef로 최신 함수를 저장 + const fnRef = useRef(fn); + + // 매 렌더링마다 최신 함수로 업데이트 + fnRef.current = fn; + + // useCallback으로 안정적인 참조를 가진 래퍼 함수 생성 + // 이 함수는 항상 ref에 저장된 최신 함수를 호출 + const stableCallback = useCallback( + ((...args: Parameters) => { + return fnRef.current(...args); + }) as T, + [], + ); + + return stableCallback; }; diff --git a/packages/react/src/hooks/useCallback.ts b/packages/react/src/hooks/useCallback.ts index c0043993..a36bdaa4 100644 --- a/packages/react/src/hooks/useCallback.ts +++ b/packages/react/src/hooks/useCallback.ts @@ -1,3 +1,4 @@ +import type { AnyFunction } from "../types"; import { DependencyList } from "./types"; import { useMemo } from "./useMemo"; @@ -9,8 +10,9 @@ import { useMemo } from "./useMemo"; * @param deps - 의존성 배열 * @returns 메모이제이션된 콜백 함수 */ -export const useCallback = any>(callback: T, deps: DependencyList): T => { - // 여기를 구현하세요. +export const useCallback = (callback: T, deps: DependencyList): T => { // useMemo를 사용하여 구현할 수 있습니다. - return callback; + const callbackRef = useMemo(() => callback, deps); + + return callbackRef; }; diff --git a/packages/react/src/hooks/useDeepMemo.ts b/packages/react/src/hooks/useDeepMemo.ts index f968d05a..54eefc32 100644 --- a/packages/react/src/hooks/useDeepMemo.ts +++ b/packages/react/src/hooks/useDeepMemo.ts @@ -8,5 +8,6 @@ import { useMemo } from "./useMemo"; export const useDeepMemo = (factory: () => T, deps: DependencyList): T => { // 여기를 구현하세요. // useMemo와 deepEquals 함수를 사용해야 합니다. - return factory(); + const memo = useMemo(() => factory(), deps, deepEquals); + return memo; }; diff --git a/packages/react/src/hooks/useMemo.ts b/packages/react/src/hooks/useMemo.ts index c275d0e1..f21760ff 100644 --- a/packages/react/src/hooks/useMemo.ts +++ b/packages/react/src/hooks/useMemo.ts @@ -12,8 +12,12 @@ import { shallowEquals } from "../utils"; * @returns 메모이제이션된 값 */ export const useMemo = (factory: () => T, deps: DependencyList, equals = shallowEquals): T => { - // 여기를 구현하세요. // useRef를 사용하여 이전 의존성 배열과 계산된 값을 저장해야 합니다. // equals 함수로 의존성을 비교하여 factory 함수를 재실행할지 결정합니다. - return factory(); + const ref = useRef<{ value: T; deps: DependencyList } | null>(null); + if (ref.current === null || !equals(ref.current.deps, deps)) { + ref.current = { value: factory(), deps }; + } + + return ref.current.value; }; diff --git a/packages/react/src/hooks/useRef.ts b/packages/react/src/hooks/useRef.ts index d5521ca1..9efac9d2 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 } => { + const [ref] = useState(() => ({ current: initialValue })); // 여기를 구현하세요. // useState를 사용하여 ref 객체를 한 번만 생성하도록 해야 합니다. - return { current: initialValue }; + return ref as { current: T }; }; diff --git a/packages/react/src/utils/enqueue.ts b/packages/react/src/utils/enqueue.ts index a4957d53..c08bb3b0 100644 --- a/packages/react/src/utils/enqueue.ts +++ b/packages/react/src/utils/enqueue.ts @@ -5,7 +5,7 @@ import type { AnyFunction } from "../types"; * 브라우저의 `queueMicrotask` 또는 `Promise.resolve().then()`을 사용합니다. */ export const enqueue = (callback: () => void) => { - // 여기를 구현하세요. + queueMicrotask(callback); }; /** @@ -13,7 +13,16 @@ export const enqueue = (callback: () => void) => { * 렌더링이나 이펙트 실행과 같은 작업의 중복을 방지하는 데 사용됩니다. */ export const withEnqueue = (fn: AnyFunction) => { - // 여기를 구현하세요. // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현합니다. - return () => {}; + let scheduled = false; + + return () => { + if (scheduled) return; + scheduled = true; + + enqueue(() => { + fn(); + scheduled = false; + }); + }; }; diff --git a/packages/react/src/utils/equals.ts b/packages/react/src/utils/equals.ts index 31ec4ba5..7ed63090 100644 --- a/packages/react/src/utils/equals.ts +++ b/packages/react/src/utils/equals.ts @@ -5,7 +5,26 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { // 여기를 구현하세요. // Object.is(), Array.isArray(), Object.keys() 등을 활용하여 1단계 깊이의 비교를 구현합니다. - return a === b; + if (a === b) return true; + if (typeof a !== typeof b) return false; + + // 배열 비교 + if (typeof a === "object" && Array.isArray(a) && typeof b === "object" && Array.isArray(b)) { + if (a.length !== b.length) return false; + if (a.some((value, index) => value !== b[index])) return false; + return true; + } + + //객체 비교 + if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { + if (Object.keys(a).length !== Object.keys(b).length) return false; + if (Object.keys(a).some((key) => a[key as keyof typeof a] !== b[key as keyof typeof b])) return false; + return true; + } + + // 함수 비교 + if (typeof a === "function" && typeof b === "function") return a.toString() === b.toString(); + return Object.is(a, b); }; /** @@ -15,5 +34,18 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { export const deepEquals = (a: unknown, b: unknown): boolean => { // 여기를 구현하세요. // 재귀적으로 deepEquals를 호출하여 중첩된 구조를 비교해야 합니다. - return a === b; + if (typeof a !== typeof b) return false; + if (typeof a === "object" && Array.isArray(a) && typeof b === "object" && Array.isArray(b)) { + if (a.length !== b.length) return false; + if (a.some((value, index) => !deepEquals(value, b[index]))) return false; + return true; + } + + if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { + if (Object.keys(a).length !== Object.keys(b).length) return false; + if (Object.keys(a).some((key) => !deepEquals(a[key as keyof typeof a], b[key as keyof typeof b]))) return false; + return true; + } + if (typeof a === "function" && typeof b === "function") return a.toString() === b.toString(); + return Object.is(a, b); }; diff --git a/packages/react/src/utils/validators.ts b/packages/react/src/utils/validators.ts index da81b3dd..386bbfbb 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; + if (value === null || value === undefined || value === false) return false; + return true; };