diff --git a/packages/react/src/core/context.ts b/packages/react/src/core/context.ts index bf41d61d..4818d3cc 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,34 @@ 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("훅은 컴포넌트 내부에서만 호출되어야 합니다"); + } + 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 +70,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..f7f42782 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,60 @@ import { Instance } from "./types"; * 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다. */ export const setDomProps = (dom: HTMLElement, props: Record): void => { - // 여기를 구현하세요. + Object.keys(props).forEach((key) => { + // children은 DOM 속성이 아니므로 제외 + if (key === "children") { + return; + } + + const value = props[key]; + + // 이벤트 핸들러 처리 (on으로 시작하는 속성) + if (key.startsWith("on") && typeof value === "function") { + const eventType = key.slice(2).toLowerCase(); // onClick -> click + dom.addEventListener(eventType, value); + return; + } + + // style 객체 처리 + if (key === "style" && typeof value === "object" && value !== null && !Array.isArray(value)) { + Object.keys(value).forEach((styleKey) => { + (dom.style as any)[styleKey] = value[styleKey]; + }); + return; + } + + // className 처리 + if (key === "className") { + dom.className = value || ""; + return; + } + + // boolean 속성 처리 (disabled, checked, readOnly 등) + if (typeof value === "boolean") { + if (value) { + dom.setAttribute(key, ""); + } else { + dom.removeAttribute(key); + } + // boolean 속성은 DOM 프로퍼티로도 설정 + (dom as any)[key] = value; + return; + } + + // 일반 속성 처리 + if (value == null || value === false) { + dom.removeAttribute(key); + } else { + dom.setAttribute(key, value); + // DOM 프로퍼티로도 설정 (예: id, name 등) + try { + (dom as any)[key] = value; + } catch { + // 읽기 전용 속성일 수 있으므로 무시 + } + } + }); }; /** @@ -19,7 +72,95 @@ export const updateDomProps = ( prevProps: Record = {}, nextProps: Record = {}, ): void => { - // 여기를 구현하세요. + // 이전 props에 있던 키들 확인 + const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(nextProps)]); + + allKeys.forEach((key) => { + // children은 DOM 속성이 아니므로 제외 + if (key === "children") { + return; + } + + const prevValue = prevProps[key]; + const nextValue = nextProps[key]; + + // 값이 변경되지 않았으면 스킵 + if (prevValue === nextValue) { + return; + } + + // 이벤트 핸들러 처리 + if (key.startsWith("on") && typeof (prevValue || nextValue) === "function") { + const eventType = key.slice(2).toLowerCase(); + // 이전 핸들러 제거 + if (prevValue) { + dom.removeEventListener(eventType, prevValue); + } + // 새 핸들러 추가 + if (nextValue) { + dom.addEventListener(eventType, nextValue); + } + return; + } + + // style 객체 처리 + if (key === "style") { + // 이전 스타일 제거 + if (prevValue && typeof prevValue === "object") { + Object.keys(prevValue).forEach((styleKey) => { + (dom.style as any)[styleKey] = ""; + }); + } + // 새 스타일 적용 + if (nextValue && typeof nextValue === "object" && nextValue !== null && !Array.isArray(nextValue)) { + Object.keys(nextValue).forEach((styleKey) => { + (dom.style as any)[styleKey] = nextValue[styleKey]; + }); + } else if (!nextValue) { + dom.removeAttribute("style"); + } + return; + } + + // className 처리 + if (key === "className") { + if (nextValue) { + dom.className = nextValue; + } else { + dom.className = ""; + } + return; + } + + // boolean 속성 처리 + if (typeof prevValue === "boolean" || typeof nextValue === "boolean") { + const boolValue = Boolean(nextValue); + if (boolValue) { + dom.setAttribute(key, ""); + } else { + dom.removeAttribute(key); + } + (dom as any)[key] = boolValue; + return; + } + + // 일반 속성 처리 + if (nextValue == null || nextValue === false) { + dom.removeAttribute(key); + try { + (dom as any)[key] = nextValue; + } catch { + // 무시 + } + } else { + dom.setAttribute(key, nextValue); + try { + (dom as any)[key] = nextValue; + } catch { + // 무시 + } + } + }); }; /** @@ -27,7 +168,33 @@ export const updateDomProps = ( * Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다. */ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { - // 여기를 구현하세요. + if (!instance) { + return []; + } + + const { kind, dom, children } = instance; + + // HOST 노드: 실제 DOM 요소 + if (kind === NodeTypes.HOST) { + return dom ? [dom] : []; + } + + // TEXT 노드: 텍스트 노드 + if (kind === NodeTypes.TEXT) { + return dom ? [dom] : []; + } + + // COMPONENT나 FRAGMENT: 자식들을 재귀적으로 탐색 + if (kind === NodeTypes.COMPONENT || kind === NodeTypes.FRAGMENT) { + const nodes: (HTMLElement | Text)[] = []; + children.forEach((child) => { + if (child) { + nodes.push(...getDomNodes(child)); + } + }); + return nodes; + } + return []; }; @@ -35,7 +202,27 @@ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] = * 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => { - // 여기를 구현하세요. + if (!instance) { + return null; + } + + const { kind, dom, children } = instance; + + // HOST 노드: 실제 DOM 요소 + if (kind === NodeTypes.HOST) { + return dom; + } + + // TEXT 노드: 텍스트 노드 + if (kind === NodeTypes.TEXT) { + return dom; + } + + // COMPONENT나 FRAGMENT: 자식들에서 첫 번째 DOM 노드 찾기 + if (kind === NodeTypes.COMPONENT || kind === NodeTypes.FRAGMENT) { + return getFirstDomFromChildren(children); + } + return null; }; @@ -43,7 +230,14 @@ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | nul * 자식 인스턴스들로부터 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => { - // 여기를 구현하세요. + for (const child of children) { + if (child) { + const firstDom = getFirstDom(child); + if (firstDom) { + return firstDom; + } + } + } return null; }; @@ -56,12 +250,44 @@ export const insertInstance = ( instance: Instance | null, anchor: HTMLElement | Text | null = null, ): void => { - // 여기를 구현하세요. + if (!instance) { + return; + } + + const nodes = getDomNodes(instance); + + if (nodes.length === 0) { + return; + } + + // anchor가 있으면 insertBefore, 없으면 appendChild + if (anchor) { + // anchor 앞에 모든 노드를 순서대로 삽입 + nodes.forEach((node) => { + parentDom.insertBefore(node, anchor); + }); + } else { + // 마지막에 모든 노드를 순서대로 추가 + nodes.forEach((node) => { + parentDom.appendChild(node); + }); + } }; /** * 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다. */ export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => { - // 여기를 구현하세요. + if (!instance) { + return; + } + + const nodes = getDomNodes(instance); + + // 각 노드를 부모에서 제거 + nodes.forEach((node) => { + 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..5729e76c 100644 --- a/packages/react/src/core/elements.ts +++ b/packages/react/src/core/elements.ts @@ -1,23 +1,47 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { isEmptyValue } from "../utils"; import { VNode } from "./types"; -import { Fragment, TEXT_ELEMENT } from "./constants"; +import { TEXT_ELEMENT } from "./constants"; /** * 주어진 노드를 VNode 형식으로 정규화합니다. * null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다. */ -export const normalizeNode = (node: VNode): VNode | null => { - // 여기를 구현하세요. +export const normalizeNode = (node: any): VNode | null => { + // null, undefined, boolean은 렌더링되지 않음 + if (isEmptyValue(node)) { + return null; + } + + // 이미 VNode인 경우 그대로 반환 + if (node && typeof node === "object" && "type" in node && "props" in node) { + return node as VNode; + } + + // 문자열이나 숫자는 TEXT_ELEMENT로 변환 + if (typeof node === "string" || typeof node === "number") { + return createTextElement(node); + } + + // 배열은 Fragment로 감싸서 처리 (하지만 실제로는 children으로 처리되어야 함) + // normalizeNode는 단일 노드를 정규화하는 함수이므로 배열은 처리하지 않음 + // 배열은 createElement에서 children으로 처리됨 + return null; }; /** * 텍스트 노드를 위한 VNode를 생성합니다. */ -const createTextElement = (node: VNode): VNode => { - // 여기를 구현하세요. - return {} as VNode; +const createTextElement = (text: string | number): VNode => { + return { + type: TEXT_ELEMENT, + key: null, + props: { + children: [], + nodeValue: String(text), + }, + }; }; /** @@ -29,7 +53,55 @@ export const createElement = ( originProps?: Record | null, ...rawChildren: any[] ) => { - // 여기를 구현하세요. + const props = originProps || {}; + + // key를 props에서 추출하고 props에서 제거 + // 테스트에서 숫자 key를 기대하므로, 숫자는 그대로 유지하고 문자열로 변환 + let key: string | number | null = null; + if (props.key != null) { + key = typeof props.key === "number" ? props.key : String(props.key); + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key: _, ...restProps } = props; + + // children 평탄화 및 정규화 + const flattenChildren = (children: any[]): VNode[] => { + const result: VNode[] = []; + + for (const child of children) { + if (isEmptyValue(child)) { + // null, undefined, boolean은 무시 + continue; + } + + if (Array.isArray(child)) { + // 배열은 재귀적으로 평탄화 + result.push(...flattenChildren(child)); + } else if (typeof child === "string" || typeof child === "number") { + // 문자열이나 숫자는 TEXT_ELEMENT로 변환 + result.push(createTextElement(child)); + } else if (child && typeof child === "object" && "type" in child) { + // 이미 VNode인 경우 + result.push(child as VNode); + } + } + + return result; + }; + + const children = flattenChildren(rawChildren); + + // children이 없으면 children 속성을 추가하지 않음 (함수형 컴포넌트의 경우) + const finalProps: Record = { ...restProps }; + if (children.length > 0) { + finalProps.children = children; + } + + return { + type, + key: key as string | null, // 타입 캐스팅 (테스트에서 숫자 key를 기대하지만 타입은 string | null) + props: finalProps, + }; }; /** @@ -40,9 +112,13 @@ export const createChildPath = ( parentPath: string, key: string | null, index: number, - nodeType?: string | symbol | React.ComponentType, - siblings?: VNode[], + // nodeType?: string | symbol | React.ComponentType, + // siblings?: VNode[], ): string => { - // 여기를 구현하세요. - return ""; + // key가 있으면 key를 사용, 없으면 index를 사용 + if (key != null) { + return `${parentPath}.${key}`; + } + + return `${parentPath}.${index}`; }; diff --git a/packages/react/src/core/hooks.ts b/packages/react/src/core/hooks.ts index ef35d0f6..8408e8d3 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, enqueue, withEnqueue } from "../utils"; import { context } from "./context"; import { EffectHook } from "./types"; import { enqueueRender } from "./render"; @@ -7,8 +7,67 @@ import { HookTypes } from "./constants"; /** * 사용되지 않는 컴포넌트의 훅 상태와 이펙트 클린업 함수를 정리합니다. */ -export const cleanupUnusedHooks = () => { - // 여기를 구현하세요. +export const cleanupUnusedHooks = (): void => { + const { state, cursor, visited } = context.hooks; + + // visited에 없는 path의 훅 상태 정리 + // path가 visited에 있거나, visited의 어떤 path의 하위 path인지 확인 + const pathsToCleanup: string[] = []; + + state.forEach((hooks, path) => { + // path 자체가 visited에 있으면 스킵 + if (visited.has(path)) { + return; + } + + // visited의 어떤 path의 하위 path인지 확인 + // 예: visited에 "0.i0"이 있고 path가 "0.i0.cChild_0"이면 스킵 + let isSubPath = false; + for (const visitedPath of visited) { + if (path.startsWith(visitedPath + ".")) { + isSubPath = true; + break; + } + } + + if (!isSubPath) { + pathsToCleanup.push(path); + // cleanup 함수 실행 + hooks.forEach((hook) => { + if (hook && typeof hook === "object" && "kind" in hook) { + const effectHook = hook as EffectHook; + if (effectHook.kind === HookTypes.EFFECT && effectHook.cleanup) { + try { + effectHook.cleanup(); + } catch (error) { + // cleanup 오류는 무시 + console.error("Cleanup 함수 실행 중 오류:", error); + } + } + } + }); + } + }); + + // 정리된 path의 상태 제거 + pathsToCleanup.forEach((path) => { + state.delete(path); + cursor.delete(path); + }); + + // 이펙트 큐에서도 사용되지 않는 path의 이펙트 제거 + context.effects.queue = context.effects.queue.filter(({ path }) => { + // visited에 있거나 visited의 하위 path인 경우만 유지 + if (visited.has(path)) { + return true; + } + for (const visitedPath of visited) { + if (path.startsWith(visitedPath + ".")) { + return true; + } + } + return false; + }); }; /** @@ -17,15 +76,44 @@ export const cleanupUnusedHooks = () => { * @returns [현재 상태, 상태를 업데이트하는 함수] */ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | ((prev: T) => T)) => void] => { - // 여기를 구현하세요. // 1. 현재 컴포넌트의 훅 커서와 상태 배열을 가져옵니다. + const path = context.hooks.currentPath; + const cursor = context.hooks.currentCursor; + const hooks = context.hooks.currentHooks; + // 2. 첫 렌더링이라면 초기값으로 상태를 설정합니다. + if (cursor >= hooks.length || hooks[cursor] === undefined) { + // 초기값 평가 (함수면 lazy initialization) + const value = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + hooks[cursor] = value; + } + + // 현재 상태 가져오기 + const state = hooks[cursor] as T; + // 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 newValue = typeof nextValue === "function" ? (nextValue as (prev: T) => T)(state) : nextValue; + + // Object.is로 값 비교하여 변경 감지 + if (Object.is(state, newValue)) { + // 값이 같으면 재렌더링 건너뛰기 + return; + } + + // 상태 업데이트 + hooks[cursor] = newValue; + + // 재렌더링 예약 + enqueueRender(); + }; + + // 4. 훅 커서를 증가시킵니다. + const currentCursor = context.hooks.cursor.get(path) ?? 0; + context.hooks.cursor.set(path, currentCursor + 1); + + return [state, setState]; }; /** @@ -34,9 +122,145 @@ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | (( * @param deps - 의존성 배열. 이 값들이 변경될 때만 이펙트가 다시 실행됩니다. */ export const useEffect = (effect: () => (() => void) | void, deps?: unknown[]): void => { - // 여기를 구현하세요. - // 1. 이전 훅의 의존성 배열과 현재 의존성 배열을 비교(shallowEquals)합니다. - // 2. 의존성이 변경되었거나 첫 렌더링일 경우, 이펙트 실행을 예약합니다. - // 3. 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행합니다. - // 4. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + // 1. 현재 컴포넌트 정보 가져오기 + const path = context.hooks.currentPath; + const cursor = context.hooks.currentCursor; + + // hooks 배열 가져오기 (없으면 생성) + let hooks = context.hooks.state.get(path); + if (!hooks) { + hooks = []; + context.hooks.state.set(path, hooks); + } + + // 이전 EffectHook 가져오기 + let prevHook: EffectHook | undefined; + if (cursor < hooks.length && hooks[cursor] && typeof hooks[cursor] === "object" && "kind" in hooks[cursor]) { + prevHook = hooks[cursor] as EffectHook; + } + + // 2. 의존성 배열 비교 (shallowEquals) + const prevDeps = prevHook?.deps ?? null; + // deps가 undefined이면 매 렌더링마다 실행 (의존성 배열이 없음) + // prevHook이 없으면 첫 렌더링 + // 의존성이 변경되었으면 실행 + let shouldRun = false; + if (!prevHook) { + // 첫 렌더링 - 항상 실행 + shouldRun = true; + } else if (deps === undefined || prevDeps === undefined || prevDeps === null) { + // deps가 없으면 매 렌더링마다 실행 + // prevDeps가 undefined이거나 null이면 이전에도 deps가 없었던 것이므로 실행 + shouldRun = true; + } else { + // 의존성이 변경되었으면 실행 + shouldRun = !shallowEquals(prevDeps, deps); + } + + // 3. 이펙트 실행 결정 + if (shouldRun) { + // 이펙트 큐에 추가 (path, cursor) + context.effects.queue.push({ path, cursor }); + } + + // 4. EffectHook 저장 (항상 최신 effect 함수로 업데이트) + const effectHook: EffectHook = { + kind: HookTypes.EFFECT, + deps: deps ?? null, + cleanup: prevHook?.cleanup ?? null, + effect, // 항상 최신 effect 함수로 저장 + }; + + // cursor 위치까지 배열 확장 + while (hooks.length <= cursor) { + hooks.push(undefined); + } + hooks[cursor] = effectHook; + + // 5. 훅 커서 증가 + const currentCursor = context.hooks.cursor.get(path) ?? 0; + context.hooks.cursor.set(path, currentCursor + 1); +}; + +/** + * 큐에 등록된 이펙트들을 실행합니다. + * 렌더링 완료 후 호출되어야 합니다. + */ +const executeEffects = (): void => { + const queue = context.effects.queue; + if (queue.length === 0) { + return; + } + + // 큐 복사 후 비우기 + const effectsToRun = [...queue]; + queue.length = 0; + + // 각 이펙트 실행 + effectsToRun.forEach(({ path, cursor }) => { + // state에 path가 없으면 cleanup된 컴포넌트이므로 스킵 + const hooks = context.hooks.state.get(path); + if (!hooks) { + return; + } + if (cursor >= hooks.length) { + return; + } + + const hook = hooks[cursor]; + if (!hook || typeof hook !== "object" || !("kind" in hook)) { + return; + } + + const effectHook = hook as EffectHook; + if (effectHook.kind !== HookTypes.EFFECT) { + return; + } + + // 이전 cleanup 함수 실행 + if (effectHook.cleanup) { + try { + effectHook.cleanup(); + } catch (error) { + console.error("Cleanup 함수 실행 중 오류:", error); + } + } + + // 이펙트 실행 + try { + const cleanup = effectHook.effect(); + // cleanup 함수 저장 + if (typeof cleanup === "function") { + effectHook.cleanup = cleanup; + } else { + effectHook.cleanup = null; + } + } catch (error) { + console.error("Effect 함수 실행 중 오류:", error); + effectHook.cleanup = null; + } + }); +}; + +/** + * 렌더링 후 이펙트를 스케줄링합니다. + * render 함수에서 호출해야 합니다. + * setup에서 render()가 동기적으로 호출되거나, enqueueRender()가 마이크로태스크에서 실행될 수 있으므로, + * 이펙트는 항상 Promise.resolve().then()을 사용하여 다음 마이크로태스크 사이클에서 실행되도록 합니다. + * flushMicrotasks()에서 await Promise.resolve()를 호출하면 실행됩니다. + */ +const scheduleEffects = withEnqueue(executeEffects); + +export const flushEffects = (): void => { + if (context.effects.queue.length === 0) { + return; + } + + // Promise.resolve().then()을 사용하여 다음 마이크로태스크 사이클에서 실행 + // 이렇게 하면 render가 동기적으로 호출되든 마이크로태스크에서 호출되든 상관없이 + // 항상 render 완료 후에 이펙트가 실행됩니다 + // scheduleEffects를 사용하여 중복 실행 방지 + enqueue(() => { + scheduleEffects(); + }); }; diff --git a/packages/react/src/core/reconciler.ts b/packages/react/src/core/reconciler.ts index 12cbdd39..db4864f1 100644 --- a/packages/react/src/core/reconciler.ts +++ b/packages/react/src/core/reconciler.ts @@ -10,7 +10,370 @@ import { updateDomProps, } from "./dom"; import { createChildPath } from "./elements"; -import { isEmptyValue } from "../utils"; + +/** + * HOST 노드(일반 DOM 요소)를 마운트합니다. + */ +const mountHost = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const { type, props } = node; + const dom = document.createElement(type as string); + + // 속성 설정 + setDomProps(dom, props); + + // 인스턴스 생성 + const instance: Instance = { + kind: NodeTypes.HOST, + dom, + node, + children: [], + key: node.key, + path, + }; + + // 자식 재조정 + const childNodes = props.children || []; + instance.children = childNodes.map((child: VNode, index: number) => { + if (!child) return null; + const childPath = createChildPath(path, child.key, index); + return reconcile(dom, null, child, childPath); + }); + + // DOM에 삽입 + insertInstance(parentDom, instance); + + return instance; +}; + +/** + * TEXT 노드를 마운트합니다. + */ +const mountText = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const textNode = document.createTextNode(node.props.nodeValue || ""); + + const instance: Instance = { + kind: NodeTypes.TEXT, + dom: textNode, + node, + children: [], + key: node.key, + path, + }; + + // DOM에 삽입 + insertInstance(parentDom, instance); + + return instance; +}; + +/** + * 컴포넌트를 마운트합니다. + */ +const mountComponent = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const component = node.type as React.ComponentType; + const { props } = node; + + // 컴포넌트 스택에 추가 + context.hooks.componentStack.push(path); + context.hooks.cursor.set(path, 0); + context.hooks.visited.add(path); + + let childVNode: VNode | null = null; + try { + // 컴포넌트 함수 실행 + childVNode = component(props); + } finally { + // 스택에서 제거 (하지만 path는 visited에 남아있음) + context.hooks.componentStack.pop(); + } + + // 자식 재조정 + const instance: Instance = { + kind: NodeTypes.COMPONENT, + dom: null, + node, + children: [], + key: node.key, + path, + }; + + if (childVNode) { + const childPath = createChildPath(path, childVNode.key, 0); + const childInstance = reconcile(parentDom, null, childVNode, childPath); + instance.children = [childInstance]; + // 컴포넌트의 dom은 자식의 첫 번째 dom + instance.dom = getFirstDom(childInstance); + } + + return instance; +}; + +/** + * Fragment를 마운트합니다. + */ +const mountFragment = (parentDom: HTMLElement, node: VNode, path: string): Instance => { + const { props } = node; + const childNodes = props.children || []; + + const instance: Instance = { + kind: NodeTypes.FRAGMENT, + dom: null, + node, + children: [], + key: node.key, + path, + }; + + // 자식 재조정 + instance.children = childNodes.map((child: VNode, index: number) => { + if (!child) return null; + const childPath = createChildPath(path, child.key, index); + return reconcile(parentDom, null, child, childPath); + }); + + // Fragment의 dom은 자식의 첫 번째 dom + instance.dom = getFirstDomFromChildren(instance.children); + + return instance; +}; + +/** + * HOST 노드를 업데이트합니다. + */ +const updateHost = (parentDom: HTMLElement, instance: Instance, newNode: VNode): Instance => { + const { dom, node } = instance; + + if (!dom) { + throw new Error("HOST 인스턴스에 DOM이 없습니다"); + } + + // HOST 인스턴스의 dom은 항상 HTMLElement + if (!(dom instanceof HTMLElement)) { + throw new Error("HOST 인스턴스의 DOM이 HTMLElement가 아닙니다"); + } + + // 속성 업데이트 + updateDomProps(dom, node.props, newNode.props); + + // 자식 재조정 + const oldChildren = instance.children; + const newChildren = newNode.props.children || []; + + instance.children = reconcileChildren(parentDom, dom, oldChildren, newChildren, instance.path); + + // 노드 정보 업데이트 + instance.node = newNode; + + return instance; +}; + +/** + * TEXT 노드를 업데이트합니다. + */ +const updateText = (instance: Instance, newNode: VNode): Instance => { + const { dom } = instance; + + if (!dom || !(dom instanceof Text)) { + throw new Error("TEXT 인스턴스에 Text DOM이 없습니다"); + } + + // 텍스트 내용 업데이트 + const newValue = newNode.props.nodeValue || ""; + if (dom.textContent !== newValue) { + dom.textContent = newValue; + } + + // 노드 정보 업데이트 + instance.node = newNode; + + return instance; +}; + +/** + * 컴포넌트를 업데이트합니다. + */ +const updateComponent = (parentDom: HTMLElement, instance: Instance, newNode: VNode): Instance => { + const component = newNode.type as React.ComponentType; + const { path } = instance; + + // 컴포넌트 스택에 추가 + context.hooks.componentStack.push(path); + context.hooks.cursor.set(path, 0); + context.hooks.visited.add(path); + + let childVNode: VNode | null = null; + try { + // 컴포넌트 함수 재실행 + childVNode = component(newNode.props); + } finally { + // 스택에서 제거 + context.hooks.componentStack.pop(); + } + + // 자식 재조정 + const oldChildInstance = instance.children[0] || null; + // childVNode가 null이면 oldChildInstance의 path를 사용, 없으면 새 path 생성 + const childPath = childVNode + ? createChildPath(path, childVNode.key, 0) + : oldChildInstance + ? oldChildInstance.path + : createChildPath(path, null, 0); + const newChildInstance = reconcile(parentDom, oldChildInstance, childVNode, childPath); + + instance.children = [newChildInstance]; + instance.node = newNode; + instance.dom = getFirstDom(newChildInstance); + + return instance; +}; + +/** + * Fragment를 업데이트합니다. + */ +const updateFragment = (parentDom: HTMLElement, instance: Instance, newNode: VNode): Instance => { + const oldChildren = instance.children; + const newChildren = newNode.props.children || []; + + // 자식 재조정 + instance.children = reconcileChildren(parentDom, parentDom, oldChildren, newChildren, instance.path); + + // 노드 정보 업데이트 + instance.node = newNode; + instance.dom = getFirstDomFromChildren(instance.children); + + return instance; +}; + +/** + * 자식 노드들을 재조정합니다. + * key 기반 매칭과 anchor를 사용하여 효율적으로 업데이트합니다. + */ +const reconcileChildren = ( + parentDom: HTMLElement, + containerDom: HTMLElement, + oldChildren: (Instance | null)[], + newChildren: VNode[], + parentPath: string, +): (Instance | null)[] => { + // key가 있는 기존 자식들을 맵으로 저장 + const keyedOldChildren = new Map(); + const keyedOldChildrenIndices = new Map(); + + oldChildren.forEach((oldChild, index) => { + if (oldChild && oldChild.key != null) { + keyedOldChildren.set(oldChild.key, oldChild); + keyedOldChildrenIndices.set(oldChild.key, index); + } + }); + + // 사용된 기존 자식 추적 + const usedOldChildren = new Set(); + + // 새로운 자식들을 처리 (뒤에서 앞으로 순회하여 anchor 계산) + const result: (Instance | null)[] = []; + const newInstances: (Instance | null)[] = []; + + for (let i = newChildren.length - 1; i >= 0; i--) { + const newChild = newChildren[i]; + if (!newChild) { + newInstances[i] = null; + result[i] = null; + continue; + } + + let newInstance: Instance | null = null; + + // key가 있으면 keyed 맵에서 찾기 + if (newChild.key != null && keyedOldChildren.has(newChild.key)) { + const oldInstance = keyedOldChildren.get(newChild.key)!; + const oldIndex = keyedOldChildrenIndices.get(newChild.key)!; + usedOldChildren.add(oldIndex); + + // 타입 비교 + if (oldInstance.node.type === newChild.type) { + // 업데이트 + const childPath = createChildPath(parentPath, newChild.key, i); + newInstance = reconcile(containerDom, oldInstance, newChild, childPath); + } else { + // 타입이 다르면 언마운트 후 새로 마운트 + removeInstance(containerDom, oldInstance); + const childPath = createChildPath(parentPath, newChild.key, i); + newInstance = reconcile(containerDom, null, newChild, childPath); + } + } else { + // key가 없으면 타입과 위치로 찾기 + let matchedOldIndex = -1; + for (let j = 0; j < oldChildren.length; j++) { + if (!usedOldChildren.has(j) && oldChildren[j]) { + const oldInstance = oldChildren[j]!; + if (oldInstance.node.type === newChild.type && oldInstance.key === newChild.key) { + matchedOldIndex = j; + usedOldChildren.add(j); + break; + } + } + } + + if (matchedOldIndex >= 0) { + // 매칭되는 기존 인스턴스 발견 - 업데이트 + const oldInstance = oldChildren[matchedOldIndex]!; + const childPath = createChildPath(parentPath, newChild.key, i); + newInstance = reconcile(containerDom, oldInstance, newChild, childPath); + } else { + // 새로 마운트 + const childPath = createChildPath(parentPath, newChild.key, i); + newInstance = reconcile(containerDom, null, newChild, childPath); + } + } + + newInstances[i] = newInstance; + result[i] = newInstance; + } + + // 순서대로 DOM에 삽입/재배치 + let nextAnchor: HTMLElement | Text | null = null; + for (let i = newChildren.length - 1; i >= 0; i--) { + const newInstance = newInstances[i]; + if (!newInstance) continue; + + const firstDom = getFirstDom(newInstance); + if (!firstDom) continue; + + // 기존 DOM 위치 확인 + const currentParent = firstDom.parentNode; + const needsMove = currentParent !== containerDom || (nextAnchor && firstDom.nextSibling !== nextAnchor); + + if (needsMove) { + // 재배치 필요 + removeInstance(containerDom, newInstance); + insertInstance(containerDom, newInstance, nextAnchor); + } + + nextAnchor = firstDom; + } + + // 사용되지 않은 기존 자식들 언마운트 + // cleanup은 render() 함수에서 state 초기화 전에 실행되므로 여기서는 DOM 제거만 수행 + for (let i = 0; i < oldChildren.length; i++) { + if (!usedOldChildren.has(i) && oldChildren[i]) { + const oldInstance = oldChildren[i]!; + // DOM 제거 (cleanup은 render()에서 실행됨) + removeInstance(containerDom, oldInstance); + } + } + + return result; +}; + +/** + * 이전 인스턴스와 새로운 VNode를 비교하여 DOM을 업데이트하는 재조정 과정을 수행합니다. + * + * @param parentDom - 부모 DOM 요소 + * @param instance - 이전 렌더링의 인스턴스 + * @param node - 새로운 VNode + * @param path - 현재 노드의 고유 경로 + * @returns 업데이트되거나 새로 생성된 인스턴스 + */ /** * 이전 인스턴스와 새로운 VNode를 비교하여 DOM을 업데이트하는 재조정 과정을 수행합니다. @@ -27,12 +390,48 @@ export const reconcile = ( node: VNode | null, path: string, ): Instance | null => { - // 여기를 구현하세요. // 1. 새 노드가 null이면 기존 인스턴스를 제거합니다. (unmount) + // cleanup은 render() 함수에서 state 초기화 전에 실행되므로 여기서는 DOM 제거만 수행 + if (!node) { + if (instance) { + // DOM 제거 (cleanup은 render()에서 실행됨) + removeInstance(parentDom, instance); + } + return null; + } + // 2. 기존 인스턴스가 없으면 새 노드를 마운트합니다. (mount) + if (!instance) { + if (node.type === TEXT_ELEMENT) { + return mountText(parentDom, node, path); + } else if (node.type === Fragment) { + return mountFragment(parentDom, node, path); + } else if (typeof node.type === "function") { + return mountComponent(parentDom, node, path); + } else { + return mountHost(parentDom, node, path); + } + } + // 3. 타입이나 키가 다르면 기존 인스턴스를 제거하고 새로 마운트합니다. + // cleanup은 render() 함수에서 state 초기화 전에 실행되므로 여기서는 DOM 제거만 수행 + if (instance.node.type !== node.type || instance.key !== node.key) { + // DOM 제거 (cleanup은 render()에서 실행됨) + removeInstance(parentDom, instance); + // 재귀 호출로 새로 마운트 + return reconcile(parentDom, null, node, path); + } + // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) - // - DOM 요소: updateDomProps로 속성 업데이트 후 자식 재조정 - // - 컴포넌트: 컴포넌트 함수 재실행 후 자식 재조정 + if (instance.kind === NodeTypes.HOST) { + return updateHost(parentDom, instance, node); + } else if (instance.kind === NodeTypes.TEXT) { + return updateText(instance, node); + } else if (instance.kind === NodeTypes.COMPONENT) { + return updateComponent(parentDom, instance, node); + } else if (instance.kind === NodeTypes.FRAGMENT) { + return updateFragment(parentDom, instance, node); + } + return null; }; diff --git a/packages/react/src/core/render.ts b/packages/react/src/core/render.ts index 79c4bbb8..6f24c4f8 100644 --- a/packages/react/src/core/render.ts +++ b/packages/react/src/core/render.ts @@ -1,18 +1,168 @@ import { context } from "./context"; -import { getDomNodes, insertInstance } from "./dom"; import { reconcile } from "./reconciler"; -import { cleanupUnusedHooks } from "./hooks"; +import { cleanupUnusedHooks, flushEffects } from "./hooks"; import { withEnqueue } from "../utils"; +import { HookTypes, NodeTypes } from "./constants"; +import { EffectHook, Instance } from "./types"; + +/** + * 인스턴스와 그 하위 컴포넌트들의 cleanup 함수를 재귀적으로 실행하고, + * 이펙트 큐에서도 해당 path의 이펙트를 제거합니다. + * state가 초기화되기 전에 호출되어야 합니다. + */ +const cleanupInstanceBeforeClear = (instance: Instance | null, pathsToCleanup: Set): void => { + if (!instance) { + return; + } + + // 컴포넌트 타입인 경우 훅 cleanup 실행 + if (instance.kind === NodeTypes.COMPONENT) { + const path = instance.path; + pathsToCleanup.add(path); + + const hooks = context.hooks.state.get(path); + if (hooks) { + hooks.forEach((hook) => { + if (hook && typeof hook === "object" && "kind" in hook) { + const effectHook = hook as EffectHook; + if (effectHook.kind === HookTypes.EFFECT && effectHook.cleanup) { + try { + effectHook.cleanup(); + } catch (error) { + // cleanup 오류는 무시 + console.error("Cleanup 함수 실행 중 오류:", error); + } + } + } + }); + } + } + + // 자식 인스턴스들도 재귀적으로 cleanup + instance.children.forEach((child) => { + if (child) { + cleanupInstanceBeforeClear(child, pathsToCleanup); + } + }); +}; /** * 루트 컴포넌트의 렌더링을 수행하는 함수입니다. * `enqueueRender`에 의해 스케줄링되어 호출됩니다. */ export const render = (): void => { - // 여기를 구현하세요. + // 0. 이전 인스턴스의 cleanup 실행 (state 초기화 전에 실행) + const oldInstance = context.root.instance; + const pathsToCleanup = new Set(); + if (oldInstance) { + cleanupInstanceBeforeClear(oldInstance, pathsToCleanup); + // cleanup된 path와 그 하위 path는 visited에 추가하여 cleanupUnusedHooks에서 다시 cleanup하지 않도록 함 + pathsToCleanup.forEach((path) => { + context.hooks.visited.add(path); + // 하위 path도 모두 visited에 추가 + context.hooks.state.forEach((_, statePath) => { + if (statePath.startsWith(path + ".")) { + context.hooks.visited.add(statePath); + } + }); + }); + // 이펙트 큐에서 cleanup된 path와 그 하위 path의 이펙트 제거 + context.effects.queue = context.effects.queue.filter(({ path }) => { + // 정확히 일치하는 path + if (pathsToCleanup.has(path)) { + return false; + } + // 하위 path인지 확인 + for (const cleanupPath of pathsToCleanup) { + if (path.startsWith(cleanupPath + ".")) { + return false; + } + } + return true; + }); + } + // 1. 훅 컨텍스트를 초기화합니다. + // visited는 cleanupUnusedHooks에서 사용되므로 초기화하지 않습니다. + // cleanup된 path는 전역 Set으로 추적하여 reconcile 이후에도 effect 큐에서 제거할 수 있도록 함 + const cleanupedPaths = new Set(pathsToCleanup); + + // 이전 인스턴스에서 사용된 모든 path를 visited에 추가 + // 이렇게 하면 이전에 렌더링된 컴포넌트의 상태를 보존할 수 있습니다 + const collectPathsFromInstance = (instance: Instance | null): void => { + if (!instance) return; + if (instance.kind === NodeTypes.COMPONENT) { + context.hooks.visited.add(instance.path); + } + instance.children.forEach((child) => { + if (child) { + collectPathsFromInstance(child); + } + }); + }; + + if (oldInstance) { + collectPathsFromInstance(oldInstance); + } + + // visited된 path의 상태는 유지, 나머지는 제거 + const pathsToDelete: string[] = []; + context.hooks.state.forEach((_, path) => { + if (!context.hooks.visited.has(path)) { + pathsToDelete.push(path); + } + }); + pathsToDelete.forEach((path) => { + context.hooks.state.delete(path); + context.hooks.cursor.delete(path); + }); + + context.hooks.componentStack = []; + // visited는 cleanupUnusedHooks에서 사용되므로 reconcile 후에 정리 + // 2. reconcile 함수를 호출하여 루트 노드를 재조정합니다. + const { container, node } = context.root; + + // 컨테이너가 없으면 렌더링할 수 없음 + if (!container) { + return; + } + + // reconcile 실행 + const newInstance = reconcile(container, oldInstance, node, "0"); + + // 새 인스턴스를 루트에 저장 + context.root.instance = newInstance; + + // reconcile 과정에서 cleanup된 path의 effect가 다시 큐에 추가될 수 있으므로 다시 제거 + if (cleanupedPaths.size > 0) { + context.effects.queue = context.effects.queue.filter(({ path }) => { + // 정확히 일치하는 path + if (cleanupedPaths.has(path)) { + return false; + } + // 하위 path인지 확인 + for (const cleanupPath of cleanupedPaths) { + if (path.startsWith(cleanupPath + ".")) { + return false; + } + } + return true; + }); + } + // 3. 사용되지 않은 훅들을 정리(cleanupUnusedHooks)합니다. + // cleanupUnusedHooks는 이펙트 실행 전에 호출하여 사용되지 않는 컴포넌트의 cleanup을 실행합니다. + // 이미 cleanupInstanceBeforeClear에서 cleanup된 path는 visited에 추가되어 다시 cleanup되지 않습니다. + cleanupUnusedHooks(); + + // visited 정리 + context.hooks.visited.clear(); + + // 4. 이펙트 실행 스케줄링 + // Promise.resolve().then()을 사용하여 다음 마이크로태스크 사이클에서 실행 + // flushMicrotasks()에서 await Promise.resolve()를 호출하면 실행됩니다 + flushEffects(); }; /** diff --git a/packages/react/src/core/setup.ts b/packages/react/src/core/setup.ts index 03813995..80ce3231 100644 --- a/packages/react/src/core/setup.ts +++ b/packages/react/src/core/setup.ts @@ -1,7 +1,7 @@ import { context } from "./context"; import { VNode } from "./types"; import { removeInstance } from "./dom"; -import { cleanupUnusedHooks } from "./hooks"; +// import { cleanupUnusedHooks } from "./hooks"; import { render } from "./render"; /** @@ -11,9 +11,30 @@ import { render } from "./render"; * @param container - VNode가 렌더링될 DOM 컨테이너 */ export const setup = (rootNode: VNode | null, container: HTMLElement): void => { - // 여기를 구현하세요. - // 1. 컨테이너 유효성을 검사합니다. - // 2. 이전 렌더링 내용을 정리하고 컨테이너를 비웁니다. - // 3. 루트 컨텍스트와 훅 컨텍스트를 리셋합니다. - // 4. 첫 렌더링을 실행합니다. + // 1. 컨테이너 유효성 검사 + if (!container) { + throw new Error("컨테이너가 제공되지 않았습니다"); + } + + // 2. null 루트 노드 체크 + if (rootNode === null) { + throw new Error("루트 노드가 null입니다"); + } + + // 3. 이전 렌더링 정리 + if (context.root.instance) { + removeInstance(container, context.root.instance); + } + container.innerHTML = ""; + + // 4. 컨텍스트 리셋 + context.hooks.clear(); + context.root.reset({ container, node: rootNode }); + context.effects.queue = []; + + // 5. 첫 렌더링 동기 실행 (테스트에서 setup 직후 렌더링이 완료되기를 기대) + render(); + + // 6. 이후 업데이트는 비동기로 스케줄링 + // (render 함수가 enqueueRender를 호출하지 않도록 주의) }; diff --git a/packages/react/src/utils/enqueue.ts b/packages/react/src/utils/enqueue.ts index a4957d53..fb585bf2 100644 --- a/packages/react/src/utils/enqueue.ts +++ b/packages/react/src/utils/enqueue.ts @@ -5,7 +5,12 @@ import type { AnyFunction } from "../types"; * 브라우저의 `queueMicrotask` 또는 `Promise.resolve().then()`을 사용합니다. */ export const enqueue = (callback: () => void) => { - // 여기를 구현하세요. + // queueMicrotask가 있으면 사용, 없으면 Promise.resolve().then() 사용 + if (typeof queueMicrotask !== "undefined") { + queueMicrotask(callback); + } else { + Promise.resolve().then(callback); + } }; /** @@ -13,7 +18,24 @@ export const enqueue = (callback: () => void) => { * 렌더링이나 이펙트 실행과 같은 작업의 중복을 방지하는 데 사용됩니다. */ export const withEnqueue = (fn: AnyFunction) => { - // 여기를 구현하세요. - // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현합니다. - return () => {}; + let scheduled = false; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (...args: any[]) => { + // 이미 스케줄링되어 있으면 스킵 + 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..ff5f8893 100644 --- a/packages/react/src/utils/equals.ts +++ b/packages/react/src/utils/equals.ts @@ -3,9 +3,50 @@ * 객체와 배열은 1단계 깊이까지만 비교합니다. */ export const shallowEquals = (a: unknown, b: unknown): boolean => { - // 여기를 구현하세요. - // Object.is(), Array.isArray(), Object.keys() 등을 활용하여 1단계 깊이의 비교를 구현합니다. - return a === b; + // Object.is()로 기본 타입 비교 + if (Object.is(a, b)) { + return true; + } + + // null 또는 undefined 체크 + if (a == null || b == null) { + return false; + } + + // 배열 비교 + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!Object.is(a[i], b[i])) { + return false; + } + } + return true; + } + + // 객체 비교 + if (typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if ( + !keysB.includes(key) || + !Object.is((a as Record)[key], (b as Record)[key]) + ) { + return false; + } + } + return true; + } + + return false; }; /** @@ -13,7 +54,48 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { * 객체와 배열의 모든 중첩된 속성을 재귀적으로 비교합니다. */ export const deepEquals = (a: unknown, b: unknown): boolean => { - // 여기를 구현하세요. - // 재귀적으로 deepEquals를 호출하여 중첩된 구조를 비교해야 합니다. - return a === b; + // Object.is()로 기본 타입 비교 + if (Object.is(a, b)) { + return true; + } + + // null 또는 undefined 체크 + if (a == null || b == null) { + return false; + } + + // 배열 비교 + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEquals(a[i], b[i])) { + return false; + } + } + return true; + } + + // 객체 비교 + if (typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b)) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) { + return false; + } + + for (const key of keysA) { + if ( + !keysB.includes(key) || + !deepEquals((a as Record)[key], (b as Record)[key]) + ) { + return false; + } + } + return true; + } + + return false; }; diff --git a/packages/react/src/utils/validators.ts b/packages/react/src/utils/validators.ts index da81b3dd..af09136d 100644 --- a/packages/react/src/utils/validators.ts +++ b/packages/react/src/utils/validators.ts @@ -6,6 +6,5 @@ * @returns 렌더링되지 않아야 하면 true, 그렇지 않으면 false */ export const isEmptyValue = (value: unknown): boolean => { - // 여기를 구현하세요. - return false; + return value === null || value === undefined || typeof value === "boolean"; };