diff --git a/src/lib/createElement.js b/src/lib/createElement.js index 5d39ae7d..956c6ce0 100644 --- a/src/lib/createElement.js +++ b/src/lib/createElement.js @@ -1,5 +1,82 @@ import { addEvent } from "./eventManager"; -export function createElement(vNode) {} +export function createElement(vNode) { + // null, undefined, boolean 값 처리 + if (vNode === null || vNode === undefined || vNode === true || vNode === false) { + return document.createTextNode(""); + } -function updateAttributes($el, props) {} + // 문자열과 숫자 처리 + if (typeof vNode === "string" || typeof vNode === "number") { + return document.createTextNode(String(vNode)); + } + + // 배열 처리 - DocumentFragment 생성 + if (Array.isArray(vNode)) { + const fragment = document.createDocumentFragment(); + vNode.forEach((child) => { + const childElement = createElement(child); + if (childElement) { + fragment.appendChild(childElement); + } + }); + return fragment; + } + + // vNode 객체가 아닌 경우 + if (!vNode || typeof vNode !== "object" || !vNode.type) { + return document.createTextNode(""); + } + + // 함수형 컴포넌트 처리 - 오류 발생 + if (typeof vNode.type === "function") { + throw new Error("Cannot create element from component"); + } + + // 일반 엘리먼트 처리 + const element = document.createElement(vNode.type); + + // 속성 처리 + if (vNode.props) { + updateAttributes(element, vNode.props); + } + + // 자식 요소 처리 + if (vNode.children) { + vNode.children.forEach((child) => { + const childElement = createElement(child); + if (childElement) { + element.appendChild(childElement); + } + }); + } + + return element; +} + +function updateAttributes($el, props) { + Object.entries(props).forEach(([key, value]) => { + if (key === "children") return; // children은 별도 처리 + + if (key.startsWith("on") && typeof value === "function") { + // 이벤트 핸들러는 DOM 속성으로 추가하지 않고 별도로 관리 + const eventName = key.toLowerCase().substring(2); + addEvent($el, eventName, value); + } else if (key === "className") { + // className 처리 + $el.className = value; + } else if (key.startsWith("data-")) { + // data 속성 처리 + const dataKey = key.substring(5); + $el.dataset[dataKey] = value; + } else if (typeof value === "boolean") { + // 불리언 속성 처리 + if (value) { + $el.setAttribute(key, ""); + } + } else { + // 일반 속성 처리 + $el.setAttribute(key, value); + } + }); +} diff --git a/src/lib/createVNode.js b/src/lib/createVNode.js index 9991337f..8c927bfe 100644 --- a/src/lib/createVNode.js +++ b/src/lib/createVNode.js @@ -1,3 +1,11 @@ export function createVNode(type, props, ...children) { - return {}; + return { + type, + props, + children: flattenChildren(children), + }; +} + +function flattenChildren(children) { + return children.flat(Infinity).filter((value) => value === 0 || Boolean(value)); } diff --git a/src/lib/eventManager.js b/src/lib/eventManager.js index 24e4240f..be8e5b25 100644 --- a/src/lib/eventManager.js +++ b/src/lib/eventManager.js @@ -1,5 +1,65 @@ -export function setupEventListeners(root) {} +// 이벤트 핸들러를 저장할 Map +const eventHandlers = new Map(); -export function addEvent(element, eventType, handler) {} +export function setupEventListeners(root) { + // 이벤트 위임을 위한 이벤트 리스너 설정 + const eventTypes = ["click", "mouseover", "focus", "keydown", "input", "change", "submit"]; -export function removeEvent(element, eventType, handler) {} + eventTypes.forEach((eventType) => { + root.addEventListener( + eventType, + (event) => { + // 이벤트가 발생한 요소에서 핸들러 찾기 + let target = event.target; + + while (target && target !== root) { + const handlers = eventHandlers.get(target); + if (handlers && handlers[eventType]) { + // 핸들러 실행 + handlers[eventType].forEach((handler) => { + handler(event); + }); + // 이벤트가 처리되었으므로 더 이상 전파하지 않음 + return; + } + target = target.parentNode; + } + }, + false, + ); // 버블링 단계에서 이벤트 처리 + }); +} + +export function addEvent(element, eventType, handler) { + if (!eventHandlers.has(element)) { + eventHandlers.set(element, {}); + } + + const elementHandlers = eventHandlers.get(element); + if (!elementHandlers[eventType]) { + elementHandlers[eventType] = []; + } + + elementHandlers[eventType].push(handler); +} + +export function removeEvent(element, eventType, handler) { + const elementHandlers = eventHandlers.get(element); + if (elementHandlers && elementHandlers[eventType]) { + const handlers = elementHandlers[eventType]; + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + + // 핸들러가 없으면 해당 이벤트 타입 제거 + if (handlers.length === 0) { + delete elementHandlers[eventType]; + } + + // 요소에 핸들러가 없으면 Map에서 제거 + if (Object.keys(elementHandlers).length === 0) { + eventHandlers.delete(element); + } + } +} diff --git a/src/lib/normalizeVNode.js b/src/lib/normalizeVNode.js index 7dc6f175..8ee9f619 100644 --- a/src/lib/normalizeVNode.js +++ b/src/lib/normalizeVNode.js @@ -1,3 +1,49 @@ export function normalizeVNode(vNode) { - return vNode; + // null, undefined, boolean 값 처리 + if (vNode === null || vNode === undefined || vNode === true || vNode === false) { + return ""; + } + + // 문자열과 숫자 처리 + if (typeof vNode === "string" || typeof vNode === "number") { + return String(vNode); + } + + // 배열 처리 - 평탄화 + if (Array.isArray(vNode)) { + const flattened = vNode.flat(Infinity); + const normalizedChildren = flattened.map((child) => normalizeVNode(child)).filter((child) => child !== ""); + if (normalizedChildren.length === 0) return ""; + if (normalizedChildren.length === 1) return normalizedChildren[0]; + return normalizedChildren; + } + + // vNode 객체가 아닌 경우 + if (!vNode || typeof vNode !== "object" || !vNode.type) { + return ""; + } + + // 함수형 컴포넌트 처리 + if (typeof vNode.type === "function") { + const component = vNode.type; + const props = { ...(vNode.props || {}), children: vNode.children }; + const result = component(props); + // 컴포넌트 실행 결과를 재귀적으로 정규화 + return normalizeVNode(result); + } + // 여기서 children이 배열이 아닐 수도 있으니 분기! + let normalizedChildren = []; + if (vNode.children !== undefined && vNode.children !== null) { + if (Array.isArray(vNode.children)) { + normalizedChildren = vNode.children.map((child) => normalizeVNode(child)).filter((child) => child !== ""); + } else { + const norm = normalizeVNode(vNode.children); + if (norm !== "") normalizedChildren = [norm]; + } + } + return { + type: vNode.type, + props: vNode.props, + children: normalizedChildren, + }; } diff --git a/src/lib/renderElement.js b/src/lib/renderElement.js index 04295728..1d6c7782 100644 --- a/src/lib/renderElement.js +++ b/src/lib/renderElement.js @@ -1,10 +1,27 @@ -import { setupEventListeners } from "./eventManager"; -import { createElement } from "./createElement"; -import { normalizeVNode } from "./normalizeVNode"; -import { updateElement } from "./updateElement"; +import { createElement } from "./createElement.js"; +import { normalizeVNode } from "./normalizeVNode.js"; +import { setupEventListeners } from "./eventManager.js"; +import { updateElement } from "./updateElement.js"; export function renderElement(vNode, container) { - // 최초 렌더링시에는 createElement로 DOM을 생성하고 - // 이후에는 updateElement로 기존 DOM을 업데이트한다. - // 렌더링이 완료되면 container에 이벤트를 등록한다. + // vNode를 정규화 + const normalizedVNode = normalizeVNode(vNode); + + if (container._vNode) { + // 이전 가상 DOM과 비교하여 업데이트 + updateElement(container, normalizedVNode, container._vNode); + } else { + // 최초 렌더링 시 DOM 생성 + const element = createElement(normalizedVNode); + container.appendChild(element); + } + + // 최신 vNode를 저장 + container._vNode = normalizedVNode; + + // 이벤트 리스너는 한 번만 설정 + if (!container._eventListenersSetup) { + setupEventListeners(container); + container._eventListenersSetup = true; + } } diff --git a/src/lib/updateElement.js b/src/lib/updateElement.js index ac321861..1c360e87 100644 --- a/src/lib/updateElement.js +++ b/src/lib/updateElement.js @@ -1,6 +1,149 @@ -import { addEvent, removeEvent } from "./eventManager"; +import { addEvent, removeEvent } from "./eventManager.js"; import { createElement } from "./createElement.js"; -function updateAttributes(target, originNewProps, originOldProps) {} +function updateProps($el, newProps = {}, oldProps = {}) { + // 새로운 key 와 기존 key 모두 순회 + const keys = new Set([...Object.keys(newProps), ...Object.keys(oldProps)]); -export function updateElement(parentElement, newNode, oldNode, index = 0) {} + keys.forEach((key) => { + if (key === "children") return; + + const newVal = newProps[key]; + const oldVal = oldProps[key]; + + // 이벤트 핸들러 + if (key.startsWith("on") && typeof (newVal ?? oldVal) === "function") { + const evt = key.toLowerCase().substring(2); + if (oldVal) removeEvent($el, evt, oldVal); + if (newVal) addEvent($el, evt, newVal); + return; + } + + // 속성이 제거된 경우 + if (newVal === undefined) { + if (key === "className") { + $el.removeAttribute("class"); + } else if (typeof oldVal === "boolean") { + $el[key] = false; + if (key === "disabled") { + $el.removeAttribute("disabled"); + } else if (key === "readOnly") { + $el.removeAttribute("readonly"); + } else { + $el.removeAttribute(key); + } + } else { + $el.removeAttribute(key); + } + return; + } + + // className + if (key === "className") { + if (newVal) { + $el.className = newVal; + } else { + $el.removeAttribute("class"); + $el.className = ""; + } + return; + } + + // data-* 속성 처리 + if (key.startsWith("data-")) { + const dataKey = key.slice(5); + if (newVal == null) { + delete $el.dataset[dataKey]; + } else { + $el.dataset[dataKey] = newVal; + } + return; + } + + // 불리언 속성 처리 + if (typeof newVal === "boolean") { + $el[key] = newVal; + if (key === "disabled") { + if (newVal) $el.setAttribute("disabled", ""); + else $el.removeAttribute("disabled"); + } else if (key === "readOnly") { + if (newVal) $el.setAttribute("readonly", ""); + else $el.removeAttribute("readonly"); + } else { + // checked, selected 등은 attribute 를 두지 않음 + if ($el.hasAttribute(key)) $el.removeAttribute(key); + } + return; + } + + // 일반 문자열 / 숫자 속성 + if (newVal !== oldVal) { + $el.setAttribute(key, newVal); + } + }); +} + +export function updateElement(parent, newVNode, oldVNode, index = 0) { + const $existing = parent.childNodes[index]; + + // oldVNode 가 없으면 새로 추가 + if (oldVNode == null) { + const $new = createElement(newVNode); + parent.appendChild($new); + return; + } + + // newVNode 가 없으면 제거 + if (newVNode == null) { + parent.removeChild($existing); + return; + } + + // 텍스트 노드 처리 + if (typeof newVNode === "string" || typeof newVNode === "number") { + if (typeof oldVNode === "string" || typeof oldVNode === "number") { + if (newVNode !== oldVNode) { + $existing.textContent = String(newVNode); + } + } else { + const $new = createElement(newVNode); + parent.replaceChild($new, $existing); + } + return; + } + + // 타입이 다르면 교체 + if (!oldVNode || typeof oldVNode !== "object" || newVNode.type !== oldVNode.type) { + const $new = createElement(newVNode); + parent.replaceChild($new, $existing); + return; + } + + // 동일한 타입 -> 속성 업데이트 + updateProps($existing, newVNode.props || {}, oldVNode.props || {}); + + // 자식 비교 + const newChildren = newVNode.children || []; + const oldChildren = oldVNode.children || []; + + // 1) 공통 길이만큼 재귀 업데이트 + const commonLength = Math.min(newChildren.length, oldChildren.length); + for (let i = 0; i < commonLength; i++) { + updateElement($existing, newChildren[i], oldChildren[i], i); + } + + // 2) 새 자식이 더 많은 경우 추가 + if (newChildren.length > oldChildren.length) { + for (let i = commonLength; i < newChildren.length; i++) { + const $child = createElement(newChildren[i]); + $existing.appendChild($child); + } + } + + // 3) 이전 자식이 더 많은 경우 제거 (역순으로 삭제해 인덱스 변동 방지) + if (oldChildren.length > newChildren.length) { + for (let i = oldChildren.length - 1; i >= newChildren.length; i--) { + $existing.removeChild($existing.childNodes[i]); + } + } +}