diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/404.html b/404.html new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/createElement.js b/src/lib/createElement.js index 5d39ae7d..b999023a 100644 --- a/src/lib/createElement.js +++ b/src/lib/createElement.js @@ -1,5 +1,170 @@ import { addEvent } from "./eventManager"; -export function createElement(vNode) {} +// Boolean 속성 목록 +const BOOLEAN_PROPS = [ + "checked", + "selected", + "disabled", + "readonly", + "readOnly", + "multiple", + "autofocus", + "required", + "autoplay", + "controls", + "loop", + "muted", + "default", + "open", +]; -function updateAttributes($el, props) {} +/** + * VNode를 실제 DOM 요소로 변환 + * @param {VNode} vNode - 변환할 VNode + * @returns {Node} - 생성된 DOM 요소 또는 TextNode + */ +export function createElement(vNode) { + // Step 1: 원시 값(문자열, 숫자) 처리 + if (typeof vNode === "string" || typeof vNode === "number") { + return document.createTextNode(vNode); + } + + // Step 2: null, undefined 처리 + if (vNode == null) { + return document.createTextNode(""); + } + + // Step 3: 배열 처리 (map으로 각 항목을 createElement 호출) + if (Array.isArray(vNode)) { + //fragment 사용 - 노드들을 fragment안에 담아놓고 한번에 dom에 추가하기 위함 + const fragment = document.createDocumentFragment(); + vNode.forEach((node) => { + fragment.appendChild(createElement(node)); + }); + return fragment; + } + + // Step 4: 객체(VNode) 처리 + if (typeof vNode === "object") { + const { type, props, children } = vNode; + + // Step 4-1: 함수형 컴포넌트 처리 - 오류 발생 + // 함수형 컴포넌트는 normalizeVNode로 미리 정규화되어야 함 + if (typeof type === "function") { + throw new Error( + `컴포넌트는 반드시 normalizeVNode로 정규화된 후에 createElement를 호출해야 합니다. 받은 컴포넌트: ${type.name}`, + ); + } + + // Step 4-2: HTML 태그 요소 처리 + if (typeof type === "string") { + const $el = document.createElement(type); + + // Step 4-3: props 적용 (className, onClick, data-* 등) + if (props) { + updateAttributes($el, props); + } + + // Step 4-4: children 추가 + if (children && children.length > 0) { + children.forEach((child) => { + // undefined는 무시 + if (child != null) { + const childNode = createElement(child); + if (childNode) { + $el.appendChild(childNode); + } + } + }); + } + + return $el; + } + } + + // 예상 밖의 타입 + return document.createTextNode(""); +} + +/** + * DOM 요소에 props(속성)을 적용 + * @param {HTMLElement} $el - 대상 DOM 요소 + * @param {Object} props - 적용할 속성 객체 + */ +function updateAttributes($el, props) { + Object.entries(props).forEach(([key, value]) => { + // Step 1: 이벤트 핸들러 (onClick, onChange 등) + if (key.startsWith("on")) { + const eventType = key.slice(2).toLowerCase(); // onClick -> click + addEvent($el, eventType, value); + return; + } + + // Step 2: 특수 속성 무시 + if (key === "key" || key === "ref" || key === "children") { + return; + } + + // Step 3: className → class로 변환 + if (key === "className") { + $el.setAttribute("class", value); + return; + } + + // Step 4: style 처리 + if (key === "style") { + if (typeof value === "object") { + // 객체 형태: { color: 'red', fontSize: '16px' } + Object.entries(value).forEach(([cssKey, cssValue]) => { + $el.style[cssKey] = cssValue; + }); + } else if (typeof value === "string") { + // 문자열 형태: "color: red; font-size: 16px;" + $el.setAttribute("style", value); + } + return; + } + + // Step 5: data-* 속성 + if (key.startsWith("data-")) { + $el.setAttribute(key, value); + return; + } + + // Step 5.5: Boolean 속성 처리 + const lowerKey = key.toLowerCase(); + if (BOOLEAN_PROPS.includes(key) || BOOLEAN_PROPS.includes(lowerKey)) { + // property로 직접 설정 + $el[key] = Boolean(value); + + const attrName = key === "readOnly" ? "readonly" : lowerKey; + + // disabled, readonly는 attribute도 설정 (true일 때만) + if ( + value && + (key === "disabled" || key === "readonly" || key === "readOnly") + ) { + $el.setAttribute(attrName, ""); + } else { + // checked, selected는 attribute 설정하지 않음 + // false인 경우도 attribute 제거 + $el.removeAttribute(attrName); + } + return; + } + + // Step 6: 표준 HTML 속성 (id, type, placeholder, disabled 등) + if (value != null && value !== false) { + if (value === true) { + // boolean 속성 (disabled, checked 등) + $el.setAttribute(key, ""); + } else { + // 일반 속성 + $el.setAttribute(key, value); + } + } else if (value === false) { + // false인 경우 속성 제거 + $el.removeAttribute(key); + } + }); +} diff --git a/src/lib/createVNode.js b/src/lib/createVNode.js index 9991337f..0fcb07ba 100644 --- a/src/lib/createVNode.js +++ b/src/lib/createVNode.js @@ -1,3 +1,33 @@ +/** + * 배열을 재귀적으로 완전히 평탄화하고 falsy 값 필터링 + * @param {Array} arr - 평탄화할 배열 + * @returns {Array} - 완전히 평탄화되고 falsy 값이 제거된 배열 + */ +function flattenDeep(arr) { + return arr.reduce((flat, item) => { + // null, undefined, boolean(false)은 제외 + if (item == null || item === false) { + return flat; + } + + // 배열인 경우 재귀적으로 평탄화 + if (Array.isArray(item)) { + return flat.concat(flattenDeep(item)); + } + + // 배열이 아니면 그대로 추가 + return flat.concat(item); + }, []); +} + +//이 함수가 jsx를 VNode로 변환(jsx 주석을 통해 명시적 지정) export function createVNode(type, props, ...children) { - return {}; + if (type === "input") { + console.log("type", type, props); + } + return { + type, + props, + children: flattenDeep(children), + }; } diff --git a/src/lib/eventManager.js b/src/lib/eventManager.js index 24e4240f..aecf8fd5 100644 --- a/src/lib/eventManager.js +++ b/src/lib/eventManager.js @@ -1,5 +1,159 @@ -export function setupEventListeners(root) {} +// 요소별 이벤트 핸들러를 저장하는 WeakMap +const eventHandlers = new Map(); -export function addEvent(element, eventType, handler) {} +/** + * 요소에 이벤트 핸들러 등록 + * @param {HTMLElement} element - 대상 요소 + * @param {string} eventType - 이벤트 타입 (click, change, input 등) + * @param {Function} handler - 이벤트 핸들러 함수 + */ +export function addEvent(element, eventType, handler) { + const normalizedType = eventType.startsWith("on") + ? eventType.slice(2).toLowerCase() + : eventType.toLowerCase(); -export function removeEvent(element, eventType, handler) {} + if (normalizedType === "keydown") { + // ✅ keydown만 로깅 + console.log(`[addEvent] type: ${normalizedType}, element:`, element); + } + + if (!eventHandlers.has(element)) { + eventHandlers.set(element, {}); + } + + const handlers = eventHandlers.get(element); + + if (!handlers[normalizedType]) { + handlers[normalizedType] = []; + } + + if (!handlers[normalizedType].includes(handler)) { + handlers[normalizedType].push(handler); + if (normalizedType === "keydown") { + // ✅ keydown만 로깅 + console.log( + `[addEvent] keydown 핸들러 등록 완료:`, + handlers[normalizedType], + ); + } + } +} + +/** + * 요소에서 이벤트 핸들러 제거 + * @param {HTMLElement} element - 대상 요소 + * @param {string} eventType - 이벤트 타입 + * @param {Function} handler - 제거할 이벤트 핸들러 + */ + +export function removeEvent(element, eventType, handler) { + // Step 1: 이벤트 타입 정규화 + const normalizedType = eventType.startsWith("on") + ? eventType.slice(2).toLowerCase() + : eventType.toLowerCase(); + + // Step 2: 해당 요소의 핸들러 조회 + if (!eventHandlers.has(element)) { + return; + } + + const handlers = eventHandlers.get(element); + if (!handlers[normalizedType]) { + return; + } + + // ✅ Step 3: handler가 전달된 경우 특정 핸들러만 제거 + if (handler) { + handlers[normalizedType] = handlers[normalizedType].filter( + (h) => h !== handler, + ); + } else { + // handler가 없으면 모든 핸들러 제거 + handlers[normalizedType] = []; + } + + // Step 4: 핸들러 배열이 비어있으면 타입 자체를 삭제 + if (handlers[normalizedType].length === 0) { + delete handlers[normalizedType]; + } + + // Step 5: 모든 이벤트 타입이 비어있으면 요소 자체를 삭제 + if (Object.keys(handlers).length === 0) { + eventHandlers.delete(element); + } +} + +/** + * 루트 요소에 이벤트 위임(Event Delegation) 설정 + * container의 모든 자식 요소에서 발생하는 이벤트를 위임 방식으로 처리 + * @param {HTMLElement} root - 루트 요소 + */ +export function setupEventListeners(root) { + if (!root) { + return; + } + + console.log(`[setupEventListeners] 시작, root:`, root); + + const eventTypes = [ + "click", + "change", + "input", + "mouseover", + "focus", + "keydown", + ]; + + eventTypes.forEach((eventType) => { + if (root._delegatedListeners && root._delegatedListeners[eventType]) { + if (eventType === "keydown") { + // ✅ keydown만 로깅 + console.log(`[setupEventListeners] ${eventType} 이미 등록됨`); + } + return; + } + + const delegatedListener = (event) => { + if (eventType === "keydown") { + // ✅ keydown만 로깅 + console.log( + `[delegatedListener] ${eventType} 이벤트 발생:`, + event.target, + eventHandlers, + ); + } + + let target = event.target; + while (target && target !== root) { + if (eventHandlers.has(target)) { + const handlers = eventHandlers.get(target); + if (handlers[eventType]) { + if (eventType === "keydown") { + // ✅ keydown만 로깅 + console.log( + `[delegatedListener] keydown 핸들러 실행:`, + handlers[eventType].length, + ); + } + handlers[eventType].forEach((handler) => { + handler(event); + }); + } + } + target = target.parentNode; + } + }; + + root.addEventListener(eventType, delegatedListener, false); + + if (eventType === "keydown") { + // ✅ keydown만 로깅 + console.log(`[setupEventListeners] ${eventType} 위임 리스너 등록 완료`); + } + + if (!root._delegatedListeners) { + root._delegatedListeners = {}; + } + root._delegatedListeners[eventType] = delegatedListener; + }); +} diff --git a/src/lib/normalizeVNode.js b/src/lib/normalizeVNode.js index 7dc6f175..142d17a0 100644 --- a/src/lib/normalizeVNode.js +++ b/src/lib/normalizeVNode.js @@ -1,3 +1,65 @@ +/** + * VNode를 정규화 + * - null, undefined, boolean → 빈 문자열 + * - 숫자 → 문자열로 변환 + * - 함수형 컴포넌트 → 실행하여 반환된 VNode를 재귀 정규화 + * - 배열의 falsy 값 제거 + * @param {*} vNode - 정규화할 VNode + * @returns {*} - 정규화된 VNode + */ export function normalizeVNode(vNode) { - return vNode; + // Step 1: null, undefined, boolean은 빈 문자열로 변환 + if (vNode == null || typeof vNode === "boolean") { + return ""; + } + + // Step 2: 숫자는 문자열로 변환 + if (typeof vNode === "number") { + return String(vNode); + } + + // Step 3: 문자열은 그대로 반환 + if (typeof vNode === "string") { + return vNode; + } + + // Step 4: 배열 처리 - 각 항목을 정규화하고 falsy 값 제거 + if (Array.isArray(vNode)) { + return vNode + .map((item) => normalizeVNode(item)) + .filter((item) => item !== ""); // 빈 문자열(falsy) 제거 + } + + // Step 5: 객체(VNode) 처리 + if (typeof vNode === "object") { + const { type, props, children } = vNode; + + // Step 5-1: 함수형 컴포넌트 처리 + // 함수를 호출하여 반환된 VNode를 재귀적으로 정규화 + if (typeof type === "function") { + // children을 props에 포함시켜서 컴포넌트에 전달 + // props가 null이면 children만 포함한 객체 생성 + const componentProps = { + ...(props || {}), + children: children && children.length > 0 ? children : undefined, + }; + const componentVNode = type(componentProps); + return normalizeVNode(componentVNode); + } + + // Step 5-2: HTML 태그 요소 처리 + if (typeof type === "string") { + return { + type, + props, + // children 정규화: 각 자식을 정규화하고 빈 문자열 제거 + children: (children || []) + .map((child) => normalizeVNode(child)) + .filter((child) => child !== ""), + }; + } + } + + // 예상 밖의 타입 + return ""; } diff --git a/src/lib/renderElement.js b/src/lib/renderElement.js index 04295728..90971a90 100644 --- a/src/lib/renderElement.js +++ b/src/lib/renderElement.js @@ -3,8 +3,33 @@ import { createElement } from "./createElement"; import { normalizeVNode } from "./normalizeVNode"; import { updateElement } from "./updateElement"; +/** + * VNode를 container에 렌더링 + * 최초 렌더링시에는 createElement로 DOM을 생성하고 + * 이후에는 updateElement로 기존 DOM을 업데이트한다. + * 렌더링이 완료되면 container에 이벤트를 등록한다. + * @param {VNode} vNode - 렌더링할 VNode + * @param {HTMLElement} container - 렌더링할 컨테이너 + */ export function renderElement(vNode, container) { - // 최초 렌더링시에는 createElement로 DOM을 생성하고 - // 이후에는 updateElement로 기존 DOM을 업데이트한다. - // 렌더링이 완료되면 container에 이벤트를 등록한다. + // Step 1: VNode 정규화 (함수형 컴포넌트 실행, falsy 값 필터링) + const normalizedVNode = normalizeVNode(vNode); + + // Step 2: 현재 container의 첫 번째 자식 노드 (기존 DOM) + const existingChild = container.firstChild; + + if (!existingChild) { + // Step 3-1: 최초 렌더링 - 새로운 DOM 생성 + const newElement = createElement(normalizedVNode); + container.appendChild(newElement); + } else { + // Step 3-2: 업데이트 렌더링 - 기존 DOM 업데이트 + updateElement(container, normalizedVNode, container._vNode, 0); + } + + // Step 4: VNode를 container에 저장 (다음 업데이트시 비교용) + container._vNode = normalizedVNode; + + // Step 5: 이벤트 핸들러 설정 + setupEventListeners(container); } diff --git a/src/lib/updateElement.js b/src/lib/updateElement.js index ac321861..4d3806a4 100644 --- a/src/lib/updateElement.js +++ b/src/lib/updateElement.js @@ -1,6 +1,231 @@ import { addEvent, removeEvent } from "./eventManager"; import { createElement } from "./createElement.js"; -function updateAttributes(target, originNewProps, originOldProps) {} +// Boolean 속성 목록 +const BOOLEAN_PROPS = [ + "checked", + "selected", + "disabled", + "readonly", + "readOnly", + "multiple", + "autofocus", + "required", + "autoplay", + "controls", + "loop", + "muted", + "default", + "open", +]; -export function updateElement(parentElement, newNode, oldNode, index = 0) {} +/** + * DOM 요소의 속성을 업데이트 + * 새로운 props를 적용하고, 제거된 props는 삭제 + * @param {HTMLElement} target - 대상 DOM 요소 + * @param {Object} originNewProps - 새로운 props + * @param {Object} originOldProps - 이전 props + */ +function updateAttributes(target, originNewProps, originOldProps) { + const newProps = originNewProps || {}; + const oldProps = originOldProps || {}; + + // 이전 props에서 새로운 props에 없는 것들 제거 + Object.entries(oldProps).forEach(([key, oldValue]) => { + if (key === "key" || key === "ref" || key === "children") { + return; + } + + if (!(key in newProps)) { + // 이벤트 핸들러 제거 + if (key.startsWith("on")) { + const eventType = key.slice(2).toLowerCase(); + removeEvent(target, eventType, oldValue); + return; + } + + // Boolean 속성 처리 + const lowerKey = key.toLowerCase(); + if (BOOLEAN_PROPS.includes(key) || BOOLEAN_PROPS.includes(lowerKey)) { + target[key] = false; // property로 설정 + const attrName = key === "readOnly" ? "readonly" : lowerKey; + target.removeAttribute(attrName); // attribute 제거 + return; + } + + // 일반 속성 제거 + if (key === "className") { + target.removeAttribute("class"); + } else if (key === "style") { + target.removeAttribute("style"); + } else if (key.startsWith("data-")) { + target.removeAttribute(key); + } else { + target.removeAttribute(key); + } + } + }); + + // 새로운 props 적용 + Object.entries(newProps).forEach(([key, newValue]) => { + const oldValue = oldProps[key]; + + if (oldValue === newValue) { + return; + } + + if (key.startsWith("on")) { + const eventType = key.slice(2).toLowerCase(); + if (oldValue) { + removeEvent(target, eventType, oldValue); + } + if (newValue) { + addEvent(target, eventType, newValue); + } + return; + } + + if (key === "key" || key === "ref" || key === "children") { + return; + } + + if (key === "className") { + if (newValue) { + target.setAttribute("class", newValue); + } else { + target.removeAttribute("class"); + } + return; + } + + if (key === "style") { + if (typeof newValue === "object") { + Object.entries(newValue).forEach(([cssKey, cssValue]) => { + target.style[cssKey] = cssValue; + }); + } else if (typeof newValue === "string") { + target.setAttribute("style", newValue); + } else { + target.removeAttribute("style"); + } + return; + } + + // Boolean 속성 처리 (핵심!) + const lowerKey = key.toLowerCase(); + if (BOOLEAN_PROPS.includes(key) || BOOLEAN_PROPS.includes(lowerKey)) { + // property로 직접 설정 + target[key] = Boolean(newValue); + + const attrName = key === "readOnly" ? "readonly" : lowerKey; + + // disabled, readonly는 attribute도 설정 (true일 때만) + if ( + newValue && + (key === "disabled" || key === "readonly" || key === "readOnly") + ) { + target.setAttribute(attrName, ""); + } else { + // checked, selected는 attribute 항상 제거 + target.removeAttribute(attrName); + } + return; + } + + if (key.startsWith("data-")) { + if (newValue != null) { + target.setAttribute(key, newValue); + } else { + target.removeAttribute(key); + } + return; + } + + if (newValue != null && newValue !== false) { + if (newValue === true) { + target.setAttribute(key, ""); + } else { + target.setAttribute(key, newValue); + } + } else { + target.removeAttribute(key); + } + }); +} + +/** + * 기존 DOM 요소를 새로운 VNode로 업데이트 또는 완전히 교체 + * @param {HTMLElement} parentElement - 부모 요소 + * @param {VNode} newNode - 새로운 VNode + * @param {VNode} oldNode - 기존 VNode + * @param {number} index - 자식 요소의 인덱스 + */ +export function updateElement(parentElement, newNode, oldNode, index = 0) { + if (newNode?.type === "input") { + console.log("[updateElement] input - onKeyDown:", newNode.props?.onKeyDown); + } + + if (!oldNode) { + console.log("[updateElement] oldNode 없음 - 새로 생성"); // ✅ 로그 5 + parentElement.appendChild(createElement(newNode)); + return; + } + + if (!newNode) { + console.log("[updateElement] newNode 없음 - 제거"); // ✅ 로그 6 + const childNode = parentElement.childNodes[index]; + if (childNode) { + parentElement.removeChild(childNode); + } + return; + } + + if (typeof newNode === "string" && typeof oldNode === "string") { + console.log("[updateElement] 텍스트 노드 업데이트"); // ✅ 로그 7 + if (newNode !== oldNode) { + parentElement.childNodes[index].textContent = newNode; + } + return; + } + + if (newNode.type !== oldNode.type) { + console.log("[updateElement] type 변경 - 요소 교체"); // ✅ 로그 8 + const newElement = createElement(newNode); + const oldElement = parentElement.childNodes[index]; + if (oldElement) { + parentElement.replaceChild(newElement, oldElement); + } else { + parentElement.appendChild(newElement); + } + return; + } + + const $element = parentElement.childNodes[index]; + if (!$element) { + console.log("[updateElement] $element 없음"); // ✅ 로그 9 + parentElement.appendChild(createElement(newNode)); + return; + } + + console.log( + "[updateElement] updateAttributes 호출 전, newNode.props:", + newNode.props, + ); // ✅ 로그 10 + updateAttributes($element, newNode.props, oldNode.props); + + const newChildren = newNode.children || []; + const oldChildren = oldNode.children || []; + + // 새로운 자식들 업데이트 + for (let i = 0; i < newChildren.length; i++) { + updateElement($element, newChildren[i], oldChildren[i], i); + } + + // 초과된 기존 자식 요소 제거 + for (let i = newChildren.length; i < oldChildren.length; i++) { + const childNode = $element.childNodes[newChildren.length]; + if (childNode) { + $element.removeChild(childNode); + } + } +}