diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..5cd684c2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - easy + workflow_dispatch: + +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@v4 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Create 404.html for SPA routing + run: | + cp ./dist/index.html ./dist/404.html + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/404.html b/404.html new file mode 100644 index 00000000..f757e4d9 --- /dev/null +++ b/404.html @@ -0,0 +1,23 @@ + + + + + + Page Not Found + + + + + + diff --git a/package.json b/package.json index d623c0c1..4081eb7d 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:e2e:report": "npx playwright show-report", "test:generate": "playwright codegen localhost:5173", "prepare": "husky", - "gh-pages": "pnpm run build && gh-pages -d dist" + "deploy": "pnpm run build && gh-pages -d dist" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ diff --git a/src/components/ProductList.jsx b/src/components/ProductList.jsx index 56240476..eab658c3 100644 --- a/src/components/ProductList.jsx +++ b/src/components/ProductList.jsx @@ -21,15 +21,27 @@ const goToDetailPage = async (productId) => { /** * 상품 목록 컴포넌트 */ -export function ProductList({ products = [], loading = false, error = null, totalCount = 0, hasMore = true }) { +export function ProductList({ + products = [], + loading = false, + error = null, + totalCount = 0, + hasMore = true, +}) { // 에러 상태 if (error) { return (
- +
-

오류가 발생했습니다

+

+ 오류가 발생했습니다 +

{error}

); @@ -60,25 +78,41 @@ export function ProductList({ products = [], loading = false, error = null, tota {/* 상품 개수 정보 */} {totalCount > 0 && (
- 총 {totalCount.toLocaleString()}개의 상품 + 총{" "} + + {totalCount.toLocaleString()}개 + + 의 상품
)} {/* 상품 그리드 */}
- {/* 로딩 스켈레톤 */} + {/* 상품 카드 */} {products.map((product) => ( ))} - {loading && Array.from({ length: 6 }).map(() => )} + {/* 무한 스크롤 로딩: 상품이 있고 로딩 중일 때만 */} + {loading && + products.length > 0 && + Array.from({ length: 6 }).map(() => )} + + {/* 초기 로딩: 상품이 없고 로딩 중일 때만 */} + {loading && + products.length === 0 && + Array.from({ length: 6 }).map(() => )}
{/* 무한 스크롤 로딩 */} {loading && products.length > 0 && (
- + 상품을 불러오는 중...
@@ -86,7 +120,9 @@ export function ProductList({ products = [], loading = false, error = null, tota {/* 더 이상 로드할 상품이 없음 */} {!hasMore && products.length > 0 && !loading && ( -
모든 상품을 확인했습니다
+
+ 모든 상품을 확인했습니다 +
)} {/* 무한 스크롤 트리거 */} diff --git a/src/lib/createElement.js b/src/lib/createElement.js index 5d39ae7d..ac48d8fc 100644 --- a/src/lib/createElement.js +++ b/src/lib/createElement.js @@ -1,5 +1,353 @@ import { addEvent } from "./eventManager"; -export function createElement(vNode) {} +/** + * Boolean 속성 목록 + * + * 이 속성들은 setAttribute가 아닌 property로 직접 설정해야 합니다. + * + * 왜 구분이 필요한가? + * - setAttribute("checked", false) → HTML: checked="false" + * → 문자열 "false"는 truthy이므로 체크박스가 체크됨! (버그) + * - element.checked = false → 올바르게 체크 해제됨 + * + * Attribute vs Property: + * - Attribute: HTML에 저장되는 문자열 값 (setAttribute로 설정) + * - Property: DOM 객체의 JavaScript 속성 (element.checked = true) + */ +const BOOLEAN_PROPS = new Set(["checked", "disabled", "selected", "readOnly"]); -function updateAttributes($el, props) {} +/** + * 정규화된 vNode를 실제 브라우저 DOM 요소로 변환하는 함수 + * + * normalizeVNode를 거친 vNode만 입력으로 받습니다. + * 함수형 컴포넌트가 입력으로 들어오면 에러를 발생시킵니다. + * + * @param {string|number|Object|Array} vNode - 정규화된 vNode + * @returns {Node} 실제 DOM 노드 (Element, Text, DocumentFragment) + * + * @example + * // 문자열 → 텍스트 노드 + * createElement("Hello") // → Text Node("Hello") + * + * // vNode → DOM 요소 + * createElement({ + * type: "div", + * props: { className: "box" }, + * children: ["Hello"] + * }) + * // →
Hello
+ */ +export function createElement(vNode) { + // ======================================== + // 케이스 1: 함수형 컴포넌트는 에러 + // ======================================== + // + // 안전장치 역할 + // - normalizeVNode를 거치지 않은 vNode가 들어오면 감지 + // - 개발 중 실수를 빠르게 발견할 수 있음 + // + // 예: createElement({ type: Welcome, props: {...} }) + // → 에러: "normalizeVNode를 먼저 사용하세요" + if (vNode && typeof vNode.type === "function") { + throw new Error( + "함수형 컴포넌트는 createElement로 직접 변환할 수 없습니다. normalizeVNode를 먼저 사용하세요.", + ); + } + + // ======================================== + // 케이스 2: null, undefined, boolean → 빈 텍스트 노드 + // ======================================== + // + // 왜 빈 텍스트 노드를 만드는가? + // - DOM에는 null을 추가할 수 없음 + // - 빈 텍스트 노드는 화면에 아무것도 표시하지 않음 + // - appendChild할 때 에러가 발생하지 않음 + // + // normalizeVNode에서 이미 처리되지만, + // 방어적 코딩으로 한 번 더 확인 + if (vNode == null || typeof vNode === "boolean") { + return document.createTextNode(""); + } + + // ======================================== + // 케이스 3: 문자열이나 숫자 → 텍스트 노드 + // ======================================== + // + // DOM의 텍스트 노드 생성 + // - document.createTextNode()는 문자열만 받음 + // - 숫자는 String()으로 변환 + // + // 예: createElement("Hello") → Text Node("Hello") + // createElement(42) → Text Node("42") + // + // 사용 예시: + //
Count: {count}
+ // → div의 children: ["Count: ", count(숫자)] + // → ["Count: ", "42"] (정규화 후) + // → [Text("Count: "), Text("42")] + if (typeof vNode === "string" || typeof vNode === "number") { + return document.createTextNode(String(vNode)); + } + + // ======================================== + // 케이스 4: 배열 → DocumentFragment + // ======================================== + // + // DocumentFragment란? + // - 여러 DOM 노드를 담을 수 있는 임시 컨테이너 + // - 실제 DOM에 추가될 때 Fragment는 사라지고 children만 남음 + // - 성능 최적화: 여러 노드를 한 번에 추가 (reflow 최소화) + // + // 왜 필요한가? + // - JSX에서 map 사용: {items.map(item =>
  • {item}
  • )} + // - 배열의 각 요소를 DOM으로 변환하여 Fragment에 담음 + // + // 예시: + // createElement([ + // { type: "li", children: ["A"] }, + // { type: "li", children: ["B"] } + // ]) + // → DocumentFragment [
  • A
  • ,
  • B
  • ] + // → ul.appendChild(fragment) → + if (Array.isArray(vNode)) { + const fragment = document.createDocumentFragment(); + vNode.forEach((child) => { + // 각 요소를 재귀적으로 DOM으로 변환하여 추가 + fragment.appendChild(createElement(child)); + }); + return fragment; + } + + // ======================================== + // 케이스 5: 일반 vNode → 실제 DOM 엘리먼트 생성 + // ======================================== + // + // 가장 일반적인 케이스: HTML 요소 생성 + // + // 처리 순서: + // 1. DOM 요소 생성 (document.createElement) + // 2. 속성 설정 (updateAttributes) + // 3. 자식 노드 추가 (재귀적으로 createElement 호출) + + // 1단계: DOM 요소 생성 + // 예: vNode.type = "div" →
    + // vNode.type = "button" → + const $el = document.createElement(vNode.type); + + // 2단계: 속성 업데이트 + // props의 각 속성을 DOM 요소에 설정 + // (className, onClick, disabled 등) + updateAttributes($el, vNode.props); + + // 3단계: 자식 노드 추가 + // children 배열의 각 요소를 재귀적으로 DOM으로 변환 + vNode.children.forEach((child) => { + // undefined나 null은 건너뛰기 + // (normalizeVNode에서 대부분 제거되지만 방어적 코딩) + if (child == null) return; + + // 재귀 호출: 자식 vNode도 DOM으로 변환 + // 예: child = "Hello" → Text Node("Hello") + // child = { type: "span", ... } → ... + $el.appendChild(createElement(child)); + }); + + // 완성된 DOM 요소 반환 + // 예:
    Hello
    + return $el; +} + +/** + * DOM 요소의 속성을 설정하는 헬퍼 함수 + * + * 4가지 속성 타입을 다르게 처리: + * 1. 이벤트 핸들러 (onClick, onChange 등) → eventManager 사용 + * 2. className → HTML의 "class" 속성으로 변환 + * 3. Boolean 속성 (checked, disabled 등) → property로 직접 설정 + * 4. 일반 속성 → setAttribute 사용 + * + * @param {Element} $el - 속성을 설정할 DOM 요소 + * @param {Object|null} props - 설정할 속성들의 객체 + * + * @example + * updateAttributes(button, { + * className: "btn", + * disabled: true, + * onClick: handleClick + * }) + * // → button.className = "btn" + * // → button.disabled = true + * // → addEvent(button, "click", handleClick) + */ +function updateAttributes($el, props) { + // props가 없으면 아무것도 하지 않음 + // 예:
    Hello
    → props = null + if (!props) return; + + // props 객체의 각 [key, value] 쌍을 순회 + // 예: { className: "btn", onClick: handler, disabled: true } + Object.entries(props).forEach(([key, value]) => { + // ---------------------------------------- + // 1. 이벤트 핸들러 (onClick, onChange 등) + // ---------------------------------------- + // + // 조건: key가 "on"으로 시작하고 value가 함수 + // 예: onClick, onChange, onSubmit, onMouseOver 등 + // + // 왜 addEventListener를 직접 사용하지 않는가? + // - 이벤트 위임(Event Delegation) 사용 + // - 메모리 효율적 + // - 동적으로 추가/제거되는 요소도 자동으로 처리 + // + // eventManager.addEvent가 하는 일: + // 1. WeakMap에 핸들러 저장 + // 2. root 요소에서 이벤트 캐치 (위임) + // 3. 실제 클릭된 요소를 찾아 핸들러 실행 + if (key.startsWith("on") && typeof value === "function") { + // "onClick" → "click"으로 변환 + // slice(2): "on" 제거 + // toLowerCase(): "Click" → "click" + const eventType = key.slice(2).toLowerCase(); + + // 이벤트 위임 시스템에 핸들러 등록 + // 예: addEvent($el, "click", handleClick) + addEvent($el, eventType, value); + } + + // ---------------------------------------- + // 2. className 속성 + // ---------------------------------------- + // + // 왜 특별 처리가 필요한가? + // - JavaScript에서 "class"는 예약어 + // - React/JSX에서는 "className" 사용 + // - HTML에서는 "class" 속성 사용 + // + // 따라서 "className" → "class"로 변환 + else if (key === "className") { + $el.setAttribute("class", value); + // 예:
    + } + + // ---------------------------------------- + // 3. Boolean 속성 (checked, disabled, selected, readOnly) + // ---------------------------------------- + // + // ❌ 잘못된 방법: + // $el.setAttribute("checked", false) + // → HTML: + // → "false" 문자열은 truthy → 체크박스가 체크됨! + // + // ✅ 올바른 방법: + // $el.checked = false + // → DOM property로 직접 설정 + // → false면 체크 해제, true면 체크 + // + // 왜 이런 차이가? + // - Attribute는 항상 문자열 + // - Property는 JavaScript 값 (boolean, number 등) + // - Boolean 속성은 property로 설정해야 의도대로 작동 + else if (BOOLEAN_PROPS.has(key)) { + $el[key] = value; // property로 직접 설정 + // 예: button.disabled = true + // input.checked = true + // select.selected = true + } + + // ---------------------------------------- + // 4. 일반 속성 (id, type, placeholder, data-* 등) + // ---------------------------------------- + // + // setAttribute로 HTML 속성 설정 + // + // 예시: + // - id="main" →
    + // - type="text" → + // - placeholder="Enter name" → + // - data-id="123" →
    + else { + $el.setAttribute(key, value); + } + }); +} + +/* + * 전체 흐름 예시: + * + * // 1. JSX + *
    + * + * + *
    + * + * // 2. normalizeVNode 후 vNode + * { + * type: "form", + * props: { onSubmit: handleSubmit }, + * children: [ + * { + * type: "input", + * props: { + * type: "text", + * className: "input", + * placeholder: "Enter text", + * disabled: true + * }, + * children: [] + * }, + * { + * type: "button", + * props: { type: "submit", disabled: false }, + * children: ["Submit"] + * } + * ] + * } + * + * // 3. createElement 처리 + * + * // 3-1. form 요소 생성 + * const $form = document.createElement("form"); + * + * // 3-2. form 속성 설정 + * updateAttributes($form, { onSubmit: handleSubmit }); + * // → addEvent($form, "submit", handleSubmit) + * + * // 3-3. input 요소 생성 및 속성 설정 + * const $input = document.createElement("input"); + * updateAttributes($input, { + * type: "text", // → $input.setAttribute("type", "text") + * className: "input", // → $input.setAttribute("class", "input") + * placeholder: "Enter text", // → $input.setAttribute("placeholder", "Enter text") + * disabled: true // → $input.disabled = true (property!) + * }); + * + * // 3-4. button 요소 생성 및 속성 설정 + * const $button = document.createElement("button"); + * updateAttributes($button, { + * type: "submit", // → $button.setAttribute("type", "submit") + * disabled: false // → $button.disabled = false (property!) + * }); + * + * // 3-5. button의 children 추가 + * $button.appendChild(document.createTextNode("Submit")); + * + * // 3-6. form에 children 추가 + * $form.appendChild($input); + * $form.appendChild($button); + * + * // 4. 최종 DOM 구조 + *
    + * + * + *
    + * + * // 이벤트는 eventManager의 WeakMap에 저장됨 + * // 실제 DOM에는 이벤트 리스너가 root에만 있음 (이벤트 위임) + */ diff --git a/src/lib/createVNode.js b/src/lib/createVNode.js index 9991337f..adfe0094 100644 --- a/src/lib/createVNode.js +++ b/src/lib/createVNode.js @@ -1,3 +1,141 @@ +/** + * JSX를 가상 DOM 객체(vNode)로 변환하는 함수 + * + * Babel이 JSX를 변환할 때 자동으로 호출됩니다. + * 예:
    Hello
    + * → createVNode("div", { className: "box" }, "Hello") + * + * @param {string|function} type - HTML 태그명 (예: "div", "span") 또는 컴포넌트 함수 + * @param {Object|null} props - 속성 객체 (예: { className: "box", id: "main" }) + * @param {...any} children - 자식 요소들 (가변 인자로 받음) + * @returns {Object} vNode 객체 { type, props, children } + * + * @example + * // JSX: + * // 변환: createVNode("button", { className: "btn", onClick: handler }, "Click") + * // 결과: { type: "button", props: { className: "btn", onClick: handler }, children: ["Click"] } + */ export function createVNode(type, props, ...children) { - return {}; + // children 배열을 평탄화(flatten)하고 falsy 값 제거 + // + // 왜 필요한가? + // 1. 배열 평탄화: JSX에서 map 사용 시 중첩 배열이 생성될 수 있음 + // 예: [["item1", "item2"], ["item3"]] → ["item1", "item2", "item3"] + // + // 2. falsy 값 제거: 조건부 렌더링에서 false, null, undefined가 포함될 수 있음 + // 예: {isVisible &&
    Content
    } → isVisible이 false면 false 값이 children에 포함됨 + // 이런 값들은 화면에 렌더링되지 않아야 하므로 제거 + const flattenChildren = children + .flat(Infinity) // Infinity: 중첩 깊이와 관계없이 완전히 평탄화 + .filter( + (child) => + child !== null && // null 제거 + child !== undefined && // undefined 제거 + child !== false && // false 제거 (조건부 렌더링) + child !== true, // true 제거 (조건부 렌더링) + ); + + // vNode 객체 반환 + // 이 객체는 나중에 normalizeVNode를 거쳐 정규화되고, + // createElement로 실제 DOM 요소로 변환됩니다. + return { + type, // 요소 타입 (HTML 태그명 또는 컴포넌트 함수) + props, // 속성 객체 (className, onClick 등) + children: flattenChildren, // 정리된 자식 요소 배열 + }; } + +/* + * 실제 사용 예시: + * + * // 1. 단순한 요소 + *
    Hello
    + * → createVNode("div", null, "Hello") + * → { type: "div", props: null, children: ["Hello"] } + * + * // 2. 속성이 있는 요소 + * + * → createVNode("button", { className: "btn", disabled: true }, "Click") + * → { type: "button", props: { className: "btn", disabled: true }, children: ["Click"] } + * + * // 3. 중첩된 요소 + *
    + *

    Title

    + *

    Content

    + *
    + * → createVNode("div", null, + * createVNode("h1", null, "Title"), + * createVNode("p", null, "Content") + * ) + * → { + * type: "div", + * props: null, + * children: [ + * { type: "h1", props: null, children: ["Title"] }, + * { type: "p", props: null, children: ["Content"] } + * ] + * } + * + * // 4. 배열 평탄화가 필요한 경우 + * + * → createVNode("ul", null, + * [li1, li2, li3] // map이 배열을 반환 + * ) + * → flat(Infinity)로 [li1, li2, li3]으로 평탄화 + * + * // 5. 조건부 렌더링 + * + * // JSX에서 조건부 렌더링: + *
    + * {isLoggedIn && } + * {!isLoggedIn && } + *
    + * + * // JavaScript의 && 연산자 동작: + * // - true && (두 번째 값 반환) + * // - false && → false (첫 번째 값 반환) + * + * // === 시나리오 1: isLoggedIn = true === + * // JSX 평가: + * // {true && } → (컴포넌트) + * // {!true && } → {false && } → false + * + * // Babel 변환: + * createVNode("div", null, + * , // 첫 번째 자식 + * false // 두 번째 자식 + * ) + * + * // filter 처리: + * // children = [, false] + * // → false 제거 + * // → children = [] + * + * // 최종 결과:
    + * + * // === 시나리오 2: isLoggedIn = false === + * // JSX 평가: + * // {false && } → false + * // {!false && } → {true && } → + * + * // Babel 변환: + * createVNode("div", null, + * false, // 첫 번째 자식 + * // 두 번째 자식 + * ) + * + * // filter 처리: + * // children = [false, ] + * // → false 제거 + * // → children = [] + * + * // 최종 결과:
    + * + * // 핵심 포인트: + * // 1. JSX의 {} 안에서 JavaScript 표현식이 평가됨 + * // 2. && 연산자는 조건에 따라 false 또는 컴포넌트를 반환 + * // 3. createVNode의 filter가 false를 제거 + * // 4. 결과적으로 조건에 맞는 컴포넌트만 렌더링됨 + */ diff --git a/src/lib/eventManager.js b/src/lib/eventManager.js index 24e4240f..ee5a841a 100644 --- a/src/lib/eventManager.js +++ b/src/lib/eventManager.js @@ -1,5 +1,475 @@ -export function setupEventListeners(root) {} +/** + * =================================== + * 이벤트 위임(Event Delegation) 시스템 + * =================================== + * + * 이벤트 위임이란? + * - 개별 요소에 이벤트 리스너를 추가하는 대신 + * - 부모(root) 요소 하나에만 리스너를 추가 + * - 이벤트 버블링을 이용해 실제 클릭된 요소를 찾아 처리 + * + * 왜 사용하는가? + * 1. 메모리 효율: 1000개 버튼 → 1000개 리스너 대신 1개 리스너 + * 2. 동적 요소 처리: 나중에 추가되는 요소도 자동으로 작동 + * 3. 메모리 누수 방지: WeakMap 사용으로 자동 정리 + * + * 데이터 구조: + * eventHandlers = WeakMap { + * button1 => Map { + * "click" => Set { handler1, handler2 }, + * "mouseover" => Set { handler3 } + * }, + * input1 => Map { + * "input" => Set { handler4 } + * } + * } + */ -export function addEvent(element, eventType, handler) {} +/** + * 이벤트 핸들러를 저장하는 WeakMap + * + * 왜 WeakMap을 사용하는가? + * + * ✅ WeakMap 사용 (현재): + * - DOM 요소가 제거되면 자동으로 가비지 컬렉션됨 + * - 메모리 누수 방지 + * + * 예시: + * let button = document.createElement('button'); + * eventHandlers.set(button, handlers); + * button.remove(); // DOM에서 제거 + * button = null; // 참조 제거 + * → WeakMap은 자동으로 해당 엔트리 삭제 (메모리 정리) + * + * ❌ 일반 Map 사용했다면: + * - DOM 요소가 제거되어도 Map에는 남아있음 + * - 메모리 누수 발생 + * - 수동으로 delete 호출 필요 + * + * 타입: WeakMap>> + * - Key: DOM 요소 (Element) + * - Value: Map { + * eventType(string): Set + * } + */ +const eventHandlers = new WeakMap(); -export function removeEvent(element, eventType, handler) {} +/** + * 이벤트 리스너가 설정된 루트 엘리먼트를 추적하는 Set + * + * 왜 필요한가? + * - setupEventListeners가 여러 번 호출되는 것을 방지 + * - 같은 root에 중복으로 이벤트 리스너를 추가하지 않기 위함 + * + * 예시: + * setupEventListeners(root); // root 추가 + * setupEventListeners(root); // 이미 있으므로 무시 + */ +const roots = new Set(); + +/** + * 루트 엘리먼트에 이벤트 위임을 설정하는 함수 + * + * 이벤트 위임의 핵심 함수입니다. + * root 요소에 이벤트 리스너를 추가하여, + * 하위의 모든 요소에서 발생하는 이벤트를 캐치합니다. + * + * @param {Element} root - 이벤트 위임을 설정할 루트 요소 (보통 #app 또는 #root) + * + * @example + * const app = document.getElementById('app'); + * setupEventListeners(app); + * // 이제 app 안의 모든 요소의 이벤트가 app에서 처리됨 + * + * 작동 원리: + * 1. root에만 이벤트 리스너 추가 + * 2. 이벤트 발생 시 실제 클릭된 요소(event.target)부터 시작 + * 3. 버블링을 따라 상위로 올라가며 각 요소의 핸들러 찾기 + * 4. 등록된 핸들러가 있으면 실행 + */ +export function setupEventListeners(root) { + // ---------------------------------------- + // 중복 설정 방지 + // ---------------------------------------- + // + // 같은 root에 여러 번 setupEventListeners를 호출하면 + // 이벤트 리스너가 중복으로 추가되어 핸들러가 여러 번 실행됨 + // + // roots Set에 이미 있는지 확인하여 중복 방지 + if (roots.has(root)) return; + roots.add(root); + + // ---------------------------------------- + // 지원하는 이벤트 타입들 + // ---------------------------------------- + // + // 필요한 이벤트 타입만 추가 + // - click: 버튼, 링크 클릭 + // - input: 입력 필드 값 변경 (실시간) + // - change: 입력 필드 값 변경 완료, select, checkbox 등 + // - submit: 폼 제출 + // - mouseover: 마우스 올렸을 때 + // - focus: 포커스 받았을 때 (버블링 안되므로 캡처 페이즈 필요할 수 있음) + // - keydown: 키보드 입력 + // + // 더 많은 이벤트 추가 가능: mouseenter, blur, scroll 등 + const eventTypes = [ + "click", + "input", + "change", + "submit", + "mouseover", + "focus", + "keydown", + ]; + + // 각 이벤트 타입에 대해 루트에 리스너 추가 + eventTypes.forEach((eventType) => { + // ---------------------------------------- + // root에 이벤트 리스너 등록 + // ---------------------------------------- + // + // 모든 하위 요소에서 발생하는 이벤트가 버블링되어 + // 이 리스너에 도달합니다. + root.addEventListener(eventType, (event) => { + // 실제로 이벤트가 발생한 요소 + // 예: 버튼을 클릭했다면 그 button 요소 + let target = event.target; + + // ---------------------------------------- + // 이벤트 버블링을 따라 상위로 올라가며 핸들러 찾기 + // ---------------------------------------- + // + // DOM 구조: + //
    + //
    + // + //
    + //
    + // + // 버튼 클릭 시 이벤트 전파 순서: + // 1. button#btn (event.target) + // 2. div.container + // 3. div#root (우리의 root, 여기서 멈춤) + // + // 각 단계에서 등록된 핸들러가 있는지 확인하고 실행 + while (target && target !== root) { + // 현재 target에 등록된 핸들러들 가져오기 + // eventHandlers는 WeakMap>> + const handlers = eventHandlers.get(target); + + // handlers가 있고, 현재 eventType에 대한 핸들러가 있는지 확인 + if (handlers && handlers.has(eventType)) { + // 해당 eventType의 핸들러 Set 가져오기 + // Set을 사용하는 이유: 같은 핸들러 중복 등록 방지 + const handlerSet = handlers.get(eventType); + + // Set의 모든 핸들러 실행 + // 예: button에 onClick 핸들러가 2개 등록되어 있으면 둘 다 실행 + handlerSet.forEach((handler) => { + handler(event); + }); + } + + // 부모 요소로 이동 (버블링) + // 예: button → div.container → div#root + target = target.parentElement; + } + // root에 도달하면 while 루프 종료 + }); + }); +} + +/* + * setupEventListeners 실행 흐름 예시: + * + * // HTML + *
    + *
    + * + *
    + *
    + * + * // JavaScript + * const app = document.getElementById('app'); + * setupEventListeners(app); + * + * const button = document.getElementById('myBtn'); + * addEvent(button, 'click', () => console.log('Clicked!')); + * + * // 사용자가 버튼 클릭 시: + * + * 1. 클릭 이벤트 발생 → event.target = button#myBtn + * + * 2. 이벤트 버블링으로 app의 리스너에 도달 + * + * 3. while 루프 시작: + * - target = button#myBtn + * - eventHandlers.get(button#myBtn) 확인 + * - handlers.get('click') → Set { handler } + * - handler 실행 → "Clicked!" 출력 + * + * 4. target = div.container (parentElement) + * - eventHandlers.get(div.container) → undefined + * - 건너뛰기 + * + * 5. target = div#app (root) + * - while 조건 불만족 (target !== root) + * - 루프 종료 + * + * 결과: 버튼의 클릭 핸들러만 실행됨! + */ + +/** + * 엘리먼트에 이벤트 핸들러를 등록하는 함수 + * + * addEventListener를 직접 사용하지 않고, + * WeakMap에 핸들러를 저장하여 이벤트 위임 시스템에서 사용합니다. + * + * @param {Element} element - 핸들러를 등록할 DOM 요소 + * @param {string} eventType - 이벤트 타입 (예: "click", "input", "change") + * @param {Function} handler - 이벤트 핸들러 함수 + * + * @example + * const button = document.querySelector('button'); + * addEvent(button, 'click', () => console.log('Clicked!')); + * addEvent(button, 'click', () => console.log('Also clicked!')); + * // 같은 요소에 같은 타입의 핸들러 여러 개 등록 가능 + * + * 데이터 구조: + * eventHandlers = WeakMap { + * button => Map { + * "click" => Set { handler1, handler2 } + * } + * } + */ +export function addEvent(element, eventType, handler) { + // ---------------------------------------- + // 1단계: element에 대한 Map이 없으면 생성 + // ---------------------------------------- + // + // eventHandlers는 WeakMap이므로 + // 처음 element에 핸들러를 추가할 때는 + // Map을 생성해야 함 + if (!eventHandlers.has(element)) { + eventHandlers.set(element, new Map()); + // 이제: eventHandlers = WeakMap { element => Map {} } + } + + // element에 대한 Map 가져오기 + // Map> + const handlers = eventHandlers.get(element); + + // ---------------------------------------- + // 2단계: eventType에 대한 Set이 없으면 생성 + // ---------------------------------------- + // + // Map에 해당 eventType이 없으면 + // 새로운 Set을 생성하여 추가 + // + // Set을 사용하는 이유: + // - 같은 핸들러가 중복으로 등록되는 것을 자동으로 방지 + // - 예: addEvent(btn, 'click', handler) 두 번 호출해도 + // Set에는 handler가 1개만 저장됨 + if (!handlers.has(eventType)) { + handlers.set(eventType, new Set()); + // 이제: handlers = Map { eventType => Set {} } + } + + // ---------------------------------------- + // 3단계: handler를 Set에 추가 + // ---------------------------------------- + // + // Set.add()는 중복을 자동으로 처리 + // 같은 함수 참조를 여러 번 추가해도 1개만 저장됨 + handlers.get(eventType).add(handler); + + /* + * 최종 상태 예시: + * + * addEvent(button, 'click', handler1); + * addEvent(button, 'click', handler2); + * addEvent(button, 'mouseover', handler3); + * + * eventHandlers = WeakMap { + * button => Map { + * "click" => Set { handler1, handler2 }, + * "mouseover" => Set { handler3 } + * } + * } + */ +} + +/* + * addEvent 사용 예시: + * + * // 1. 단일 핸들러 + * const button = document.querySelector('button'); + * addEvent(button, 'click', () => console.log('Clicked')); + * + * // 2. 같은 요소에 여러 핸들러 + * addEvent(button, 'click', handleClick1); + * addEvent(button, 'click', handleClick2); + * // 클릭 시 둘 다 실행됨 + * + * // 3. 여러 이벤트 타입 + * addEvent(button, 'click', handleClick); + * addEvent(button, 'mouseover', handleHover); + * + * // 4. 같은 핸들러 중복 등록 방지 + * const handler = () => console.log('Only once'); + * addEvent(button, 'click', handler); + * addEvent(button, 'click', handler); // Set이 중복 제거 + * // 클릭 시 1번만 실행됨 + */ + +/** + * 엘리먼트에서 이벤트 핸들러를 제거하는 함수 + * + * addEvent로 등록한 핸들러를 제거합니다. + * 사용하지 않는 핸들러를 정리하여 메모리 누수를 방지합니다. + * + * @param {Element} element - 핸들러를 제거할 DOM 요소 + * @param {string} eventType - 이벤트 타입 (예: "click", "input") + * @param {Function} handler - 제거할 이벤트 핸들러 함수 + * + * @example + * const handler = () => console.log('Clicked'); + * addEvent(button, 'click', handler); + * // ... 나중에 + * removeEvent(button, 'click', handler); // 핸들러 제거 + * + * 메모리 정리: + * - Set이 비면 Map에서 제거 + * - Map이 비면 WeakMap에서 제거 + * - 깨끗한 상태 유지 + */ +export function removeEvent(element, eventType, handler) { + // ---------------------------------------- + // 1단계: element에 등록된 핸들러가 있는지 확인 + // ---------------------------------------- + // + // eventHandlers에 element가 없으면 + // 아무 핸들러도 등록되지 않은 것이므로 종료 + const handlers = eventHandlers.get(element); + if (!handlers) return; + + // ---------------------------------------- + // 2단계: eventType에 대한 핸들러 Set 가져오기 + // ---------------------------------------- + // + // handlers Map에서 해당 eventType의 Set을 가져옴 + // 없으면 해당 이벤트 타입에 핸들러가 없으므로 종료 + const handlerSet = handlers.get(eventType); + if (!handlerSet) return; + + // ---------------------------------------- + // 3단계: handler를 Set에서 제거 + // ---------------------------------------- + // + // Set.delete()는 해당 요소를 제거 + // 없으면 아무 일도 일어나지 않음 + handlerSet.delete(handler); + + // ---------------------------------------- + // 4단계: 메모리 정리 - 빈 Set 제거 + // ---------------------------------------- + // + // Set이 비어있으면 (모든 핸들러가 제거됨) + // Map에서 해당 eventType을 제거 + // + // 예: Map { "click" => Set {}, "input" => Set { handler } } + // → Map { "input" => Set { handler } } + if (handlerSet.size === 0) { + handlers.delete(eventType); + } + + // ---------------------------------------- + // 5단계: 메모리 정리 - 빈 Map 제거 + // ---------------------------------------- + // + // Map도 비어있으면 (모든 이벤트 타입이 제거됨) + // WeakMap에서 element 엔트리를 제거 + // + // 이렇게 하면 eventHandlers가 항상 깨끗하게 유지됨 + // 메모리 효율적 + if (handlers.size === 0) { + eventHandlers.delete(element); + } +} + +/* + * removeEvent 메모리 정리 예시: + * + * // 초기 상태 + * eventHandlers = WeakMap { + * button => Map { + * "click" => Set { handler1, handler2 }, + * "mouseover" => Set { handler3 } + * } + * } + * + * // 1. handler1 제거 + * removeEvent(button, 'click', handler1); + * + * eventHandlers = WeakMap { + * button => Map { + * "click" => Set { handler2 }, // handler1 제거됨 + * "mouseover" => Set { handler3 } + * } + * } + * + * // 2. handler2도 제거 + * removeEvent(button, 'click', handler2); + * + * // Set이 비어있음 → eventType 제거 + * eventHandlers = WeakMap { + * button => Map { + * "mouseover" => Set { handler3 } // "click" 제거됨 + * } + * } + * + * // 3. handler3도 제거 + * removeEvent(button, 'mouseover', handler3); + * + * // Map도 비어있음 → element 엔트리 제거 + * eventHandlers = WeakMap { } // 완전히 비어있음 + * + * // 메모리가 깨끗하게 정리됨! + */ + +/* + * 전체 시스템 통합 예시: + * + * // 1. 앱 초기화 + * const app = document.getElementById('app'); + * setupEventListeners(app); + * // → app에 이벤트 리스너 설정 (위임) + * + * // 2. JSX 렌더링 + * function TodoItem({ onDelete }) { + * return ; + * } + * + * // 3. createElement에서 addEvent 호출 + * const button = document.createElement('button'); + * addEvent(button, 'click', handleDelete); + * // → eventHandlers에 저장 + * + * // 4. 사용자가 버튼 클릭 + * // → app의 리스너가 이벤트 캐치 (위임) + * // → 버블링으로 button까지 도달 + * // → eventHandlers에서 handleDelete 찾기 + * // → handleDelete 실행! + * + * // 5. 컴포넌트 제거 (DOM에서 삭제) + * button.remove(); + * button = null; + * // → WeakMap이 자동으로 정리 (가비지 컬렉션) + * // → 메모리 누수 없음! + * + * // 6. 명시적으로 핸들러만 제거하고 싶을 때 + * removeEvent(button, 'click', handleDelete); + * // → eventHandlers에서 핸들러 제거 + * // → 버튼은 남아있지만 클릭 이벤트 없음 + */ diff --git a/src/lib/normalizeVNode.js b/src/lib/normalizeVNode.js index 7dc6f175..34f2c5e2 100644 --- a/src/lib/normalizeVNode.js +++ b/src/lib/normalizeVNode.js @@ -1,3 +1,233 @@ +/** + * vNode를 렌더링 가능한 일관된 형식으로 정규화(normalize)하는 함수 + * + * createVNode가 생성한 다양한 형태의 vNode를 + * createElement가 처리할 수 있는 표준 형식으로 변환합니다. + * + * 처리하는 6가지 타입: + * 1. null/undefined/boolean → 빈 문자열 + * 2. 숫자 → 문자열 + * 3. 문자열 → 그대로 반환 + * 4. 배열 → 재귀적으로 정규화 + * 5. 함수형 컴포넌트 → 실행 후 정규화 + * 6. 일반 vNode → children만 정규화 + * + * @param {any} vNode - 정규화할 vNode (다양한 타입 가능) + * @returns {string|Object|Array} 정규화된 vNode + * + * @example + * // null/boolean → "" + * normalizeVNode(null) // → "" + * normalizeVNode(false) // → "" + * + * // 숫자 → 문자열 + * normalizeVNode(42) // → "42" + * + * // 컴포넌트 → 실행 후 정규화 + * normalizeVNode({ type: Welcome, props: {...}, children: [...] }) + * // → Welcome 함수 실행 → 결과를 다시 정규화 + */ export function normalizeVNode(vNode) { - return vNode; + // ======================================== + // 케이스 1: null, undefined, boolean → 빈 문자열 + // ======================================== + // + // 왜 필요한가? + // - 조건부 렌더링: {isVisible &&
    Content
    } + // isVisible이 false면 false 값이 전달됨 + // - 이런 값들은 화면에 아무것도 렌더링하지 않아야 함 + // + // 왜 빈 문자열인가? + // - null을 반환하면 나중에 처리가 복잡해짐 + // - 빈 문자열("")은 빈 텍스트 노드로 변환되어 안전하게 처리됨 + if (vNode == null || typeof vNode === "boolean") { + return ""; + } + + // ======================================== + // 케이스 2: 숫자 → 문자열 + // ======================================== + // + // 왜 필요한가? + // - JSX에서 숫자를 직접 사용:
    {42}
    + // - DOM 텍스트 노드는 문자열만 받을 수 있음 + // - 숫자를 문자열로 변환해야 함 + // + // 예:

    Count: {count}

    + // count가 5면 → "5"로 변환 + if (typeof vNode === "number") { + return String(vNode); + } + + // ======================================== + // 케이스 3: 문자열 → 그대로 반환 + // ======================================== + // + // 이미 렌더링 가능한 형태이므로 변환 불필요 + // 예:
    Hello
    → "Hello"는 그대로 사용 + if (typeof vNode === "string") { + return vNode; + } + + // ======================================== + // 케이스 4: 배열 → 각 요소를 정규화 + // ======================================== + // + // 왜 배열이 나오는가? + // - JSX에서 map 사용: {items.map(item =>
  • {item}
  • )} + // - 여러 요소를 반환할 때: [
    A
    ,
    B
    ] + // + // 처리 과정: + // 1. 각 요소에 대해 normalizeVNode 재귀 호출 + // 2. 빈 문자열이나 null 제거 (의미 없는 값) + if (Array.isArray(vNode)) { + return vNode + .map(normalizeVNode) // 각 요소를 재귀적으로 정규화 + .filter((child) => child !== "" && child != null); // 빈 값 제거 + } + + // ======================================== + // 케이스 5: 함수형 컴포넌트 → 실행하여 결과를 정규화 + // ======================================== + // + // 함수형 컴포넌트란? + // - props를 받아서 JSX를 반환하는 함수 + // - 예: function Welcome({ name }) { return
    Hello {name}
    ; } + // + // 왜 이렇게 처리하는가? + // - createVNode는 컴포넌트를 실행하지 않고 { type: function, ... } 형태로 저장 + // - normalizeVNode에서 실제로 컴포넌트를 실행 + // - React의 props.children 패턴을 구현하기 위해 children을 props에 포함 + if (typeof vNode.type === "function") { + // children을 props에 포함시켜 전달 + // + // 왜 children을 props에 넣는가? + // - React 패턴: title, function Card({ children }) { ... } + // - 컴포넌트 내부에서 props.children으로 접근 가능하게 하기 위함 + // + // 예:

    Content

    + // → Card({ title: "Hello", children: [

    Content

    ] }) + const props = { + ...vNode.props, // 기존 props 복사 (title, className 등) + children: vNode.children.length > 0 ? vNode.children : undefined, + // children이 있으면 배열로 전달, 없으면 undefined + }; + + // 컴포넌트 함수 실행 + // 예: Welcome({ name: "John" }) →
    Hello John
    + const result = vNode.type(props); + + // 실행 결과를 다시 정규화 (재귀 호출) + // 왜 재귀 호출? + // - 컴포넌트가 반환한 JSX도 정규화가 필요 + // - 컴포넌트 안에서 또 다른 컴포넌트를 사용할 수 있음 (중첩 컴포넌트) + // + // 예: Welcome 실행 →
    ...
    (vNode) + // → 이 vNode의 children도 정규화 필요 + return normalizeVNode(result); + } + + // ======================================== + // 케이스 6: 일반 vNode → children을 정규화 + // ======================================== + // + // 일반 vNode란? + // - type이 문자열인 HTML 요소:
    , , } + * + * + * // 3. createVNode 결과 + * // isAvailable이 true이므로: true && + * { + * type: Card, // 함수 + * props: { title: "Product" }, + * children: [ + * { type: "p", props: null, children: ["Price: ", 100] }, + * { type: "button", props: null, children: ["Buy"] } // button vNode! + * ] + * } + * + * // 만약 isAvailable이 false였다면: + * // false && → false + * // children: [..., false] ← false가 들어감 + * + * // 4. normalizeVNode 처리 + * + * // 4-1. type이 함수이므로 케이스 5 + * const props = { + * title: "Product", + * children: [ + * { type: "p", ... }, + * { type: "button", ... } // button vNode (true일 때) + * ] + * }; + * + * // 4-2. Card 함수 실행 + * const result = Card(props); + * // →
    + * //

    Product

    + * //
    + * // {children} ← 여기에 [p, button]이 렌더링됨 + * //
    + * //
    + * + * // 4-3. 결과를 재귀적으로 정규화 + * // - div (케이스 6): children 정규화 + * // - h2 (케이스 6): children ["Product"] → 그대로 + * // - div.content (케이스 6): children 정규화 + * // - p (케이스 6): children ["Price: ", 100] 정규화 + * // - "Price: " (케이스 3): 그대로 + * // - 100 (케이스 2): "100"으로 변환 + * // - button (케이스 6): children ["Buy"] → 그대로 + * + * // 5. 최종 정규화된 vNode + * { + * type: "div", + * props: { className: "card" }, + * children: [ + * { type: "h2", props: null, children: ["Product"] }, + * { + * type: "div", + * props: { className: "content" }, + * children: [ + * { type: "p", props: null, children: ["Price: ", "100"] }, + * { type: "button", props: null, children: ["Buy"] } + * ] + * } + * ] + * } + * + * // 모든 타입이 정규화되어 createElement가 처리할 수 있는 형태가 됨! + */ diff --git a/src/lib/renderElement.js b/src/lib/renderElement.js index 04295728..26779cbf 100644 --- a/src/lib/renderElement.js +++ b/src/lib/renderElement.js @@ -3,8 +3,230 @@ import { createElement } from "./createElement"; import { normalizeVNode } from "./normalizeVNode"; import { updateElement } from "./updateElement"; +/** + * Virtual DOM을 실제 DOM으로 렌더링하는 메인 함수 + * + * React의 ReactDOM.render()와 유사한 역할을 합니다. + * 최초 렌더링과 재렌더링을 구분하여 처리합니다. + * + * @param {any} vNode - 렌더링할 vNode (컴포넌트 또는 JSX) + * @param {Element} container - vNode를 렌더링할 DOM 컨테이너 (보통 #app 또는 #root) + * + * @example + * // 최초 렌더링 + * const app = document.getElementById('app'); + * renderElement(, app); + * + * // 상태 변경 후 재렌더링 + * renderElement(, app); // Diff 알고리즘으로 변경된 부분만 업데이트 + * + * 처리 흐름: + * 1. vNode 정규화 (컴포넌트 실행, 타입 변환) + * 2. 최초 vs 재렌더링 판단 + * 3. 최초: createElement로 새로 생성 + * 재렌더링: updateElement로 차이점만 업데이트 + * 4. vNode 저장 (다음 비교용) + * 5. 이벤트 위임 설정 + */ export function renderElement(vNode, container) { - // 최초 렌더링시에는 createElement로 DOM을 생성하고 - // 이후에는 updateElement로 기존 DOM을 업데이트한다. - // 렌더링이 완료되면 container에 이벤트를 등록한다. + // ======================================== + // 1단계: vNode 정규화 + // ======================================== + // + // 왜 정규화가 필요한가? + // - vNode는 다양한 형태일 수 있음: + // * 함수형 컴포넌트: { type: function, ... } + // * 배열: [vNode1, vNode2, ...] + // * 숫자: 42 + // * 문자열: "Hello" + // + // normalizeVNode가 하는 일: + // - 함수형 컴포넌트 실행 + // - 숫자 → 문자열 변환 + // - 배열 평탄화 + // - null/boolean 제거 + // + // 결과: createElement가 처리할 수 있는 표준 형식 + const normalizedVNode = normalizeVNode(vNode); + + // ======================================== + // 2단계: 최초 렌더링 vs 재렌더링 판단 + // ======================================== + // + // container._vNode: 이전에 렌더링한 vNode를 저장 + // + // - undefined: 최초 렌더링 (처음 renderElement 호출) + // - 있음: 재렌더링 (두 번째 이상 호출) + const oldVNode = container._vNode; + + if (!oldVNode) { + // ---------------------------------------- + // 최초 렌더링: 전체 DOM 새로 생성 + // ---------------------------------------- + // + // 왜 innerHTML = ""? + // - 컨테이너에 기존 HTML이 있을 수 있음 + // (예: index.html에
    Loading...
    ) + // - 기존 내용을 모두 제거하고 새로 시작 + container.innerHTML = ""; + + // createElement: vNode → 실제 DOM 요소로 변환 + // 재귀적으로 모든 자식 요소들도 생성 + const element = createElement(normalizedVNode); + + // 생성한 DOM을 컨테이너에 추가 + container.appendChild(element); + + /* + * 예시: + * vNode = { type: "div", props: { className: "app" }, children: [...] } + * → element =
    ...
    + * → container.appendChild(element) + * + * 결과: + *
    + *
    ...
    + *
    + */ + } else { + // ---------------------------------------- + // 재렌더링: Diff 알고리즘으로 차이점만 업데이트 + // ---------------------------------------- + // + // 왜 전체를 다시 만들지 않는가? + // - DOM 조작은 비용이 큼 (느림) + // - 변경된 부분만 업데이트하는 것이 훨씬 빠름 + // - 사용자 경험 향상 (input focus 유지, 애니메이션 유지 등) + // + // updateElement가 하는 일: + // - oldVNode와 normalizedVNode 비교 + // - 차이점 찾기 (Diff 알고리즘) + // - 최소한의 DOM 조작으로 업데이트 + // * 텍스트만 변경: textContent 업데이트 + // * 속성만 변경: setAttribute + // * 노드 추가/제거: appendChild/removeChild + updateElement(container, normalizedVNode, oldVNode); + + /* + * 예시: + * oldVNode = { type: "div", children: [{ type: "h1", children: ["Count: 0"] }] } + * newVNode = { type: "div", children: [{ type: "h1", children: ["Count: 1"] }] } + * + * updateElement: + * 1. div 비교 → 같은 타입, 재사용 + * 2. h1 비교 → 같은 타입, 재사용 + * 3. "Count: 0" vs "Count: 1" → 텍스트만 변경 + * + * 결과: h1의 textContent만 "Count: 1"로 업데이트 + * DOM 요소는 재생성하지 않음! + */ + } + + // ======================================== + // 3단계: 현재 vNode 저장 + // ======================================== + // + // 다음 renderElement 호출 시 비교용으로 사용 + // + // container는 DOM 요소이므로 커스텀 속성 추가 가능 + // _vNode는 표준 속성이 아니지만 JavaScript 객체에 추가 가능 + // + // 예시: + // 1회차: container._vNode = undefined → normalizedVNode로 설정 + // 2회차: container._vNode = 1회차 vNode + // → oldVNode로 사용하여 비교 + // → 새로운 normalizedVNode로 교체 + container._vNode = normalizedVNode; + + // ======================================== + // 4단계: 이벤트 리스너 설정 (위임 방식) + // ======================================== + // + // setupEventListeners: container에 이벤트 위임 설정 + // + // 이미 설정되어 있으면? + // - setupEventListeners 내부에서 중복 체크 + // - roots Set에 있으면 무시 + // - 최초 1회만 실제로 설정됨 + // + // 왜 매번 호출하는가? + // - 안전성: 혹시 설정 안 되어 있으면 설정 + // - 단순성: 코드 복잡도 낮춤 + // - 성능: roots.has() 체크는 매우 빠름 (O(1)) + setupEventListeners(container); } + +/* + * renderElement 전체 흐름 예시: + * + * // 컴포넌트 정의 + * function Counter() { + * const [count, setCount] = useState(0); + * + * return ( + *
    + *

    Count: {count}

    + * + *
    + * ); + * } + * + * // 앱 시작 + * const app = document.getElementById('app'); + * + * // ===== 1회차 렌더링 (count = 0) ===== + * renderElement(, app); + * + * // 1. normalizeVNode() + * // → Counter() 실행 + * // → { type: "div", props: { className: "counter" }, children: [...] } + * + * // 2. oldVNode = undefined (최초) + * + * // 3. createElement 사용 + * // →
    + * //

    Count: 0

    + * // + * //
    + * + * // 4. app.appendChild(element) + * + * // 5. container._vNode = normalizedVNode 저장 + * + * // 6. setupEventListeners(app) → 이벤트 위임 설정 + * + * + * // ===== 2회차 렌더링 (버튼 클릭 후, count = 1) ===== + * renderElement(, app); + * + * // 1. normalizeVNode() + * // → Counter() 실행 + * // → { type: "div", props: { className: "counter" }, children: [...] } + * // → h1의 children: ["Count: 1"] (변경됨!) + * + * // 2. oldVNode = 1회차의 vNode (app._vNode) + * + * // 3. updateElement 사용 (Diff) + * // - div: 같은 타입 → 재사용 + * // - h1: 같은 타입 → 재사용 + * // - "Count: 0" vs "Count: 1" → textContent만 변경 + * // - button: 동일 → 유지 + * + * // 4. 최소 DOM 조작: + * // h1의 firstChild.textContent = "Count: 1" + * // (단 1줄의 DOM 조작!) + * + * // 5. container._vNode = 새 vNode로 교체 + * + * // 6. setupEventListeners(app) → 이미 설정됨, 무시 + * + * + * // 결과: + * // - 전체 DOM 재생성 없음 + * // - h1의 텍스트만 변경 + * // - button의 포커스/상태 유지 + * // - 애니메이션 중단 없음 + * // - 매우 빠른 업데이트! + */ diff --git a/src/lib/updateElement.js b/src/lib/updateElement.js index ac321861..d05bee11 100644 --- a/src/lib/updateElement.js +++ b/src/lib/updateElement.js @@ -1,6 +1,623 @@ import { addEvent, removeEvent } from "./eventManager"; import { createElement } from "./createElement.js"; -function updateAttributes(target, originNewProps, originOldProps) {} +/** + * Boolean 속성 목록 + * createElement.js와 동일한 이유로 property로 직접 설정해야 함 + */ +const BOOLEAN_PROPS = new Set(["checked", "disabled", "selected", "readOnly"]); -export function updateElement(parentElement, newNode, oldNode, index = 0) {} +/** + * DOM 요소의 속성을 업데이트하는 헬퍼 함수 + * + * 이전 속성과 새 속성을 비교하여: + * 1. 제거된 속성 처리 + * 2. 추가/변경된 속성만 업데이트 + * + * @param {Element} target - 업데이트할 DOM 요소 + * @param {Object|null} originNewProps - 새로운 속성 객체 + * @param {Object|null} originOldProps - 이전 속성 객체 + * + * @example + * // Old: + * // New: + * updateAttributes(button, + * { className: "btn primary" }, + * { className: "btn", disabled: true } + * ) + * // → className 변경, disabled 제거 + */ +function updateAttributes(target, originNewProps, originOldProps) { + // null 체크: props가 없으면 빈 객체로 처리 + const newProps = originNewProps || {}; + const oldProps = originOldProps || {}; + + // ======================================== + // 1단계: 이전 속성 중 제거된 것 처리 + // ======================================== + // + // oldProps에는 있지만 newProps에는 없는 속성을 찾아서 제거 + // + // 예시: + // oldProps = { className: "btn", disabled: true, onClick: handler } + // newProps = { className: "btn primary", onClick: handler } + // → disabled가 제거됨 + Object.keys(oldProps).forEach((key) => { + // newProps에 해당 key가 없으면 제거해야 함 + if (!(key in newProps)) { + // ---------------------------------------- + // 이벤트 핸들러 제거 + // ---------------------------------------- + if (key.startsWith("on")) { + const eventType = key.slice(2).toLowerCase(); + // eventManager에서 핸들러 제거 + // WeakMap에서 삭제되어 메모리 정리 + removeEvent(target, eventType, oldProps[key]); + } + // ---------------------------------------- + // className 제거 + // ---------------------------------------- + else if (key === "className") { + target.removeAttribute("class"); + // HTML에서 class="" 제거 + } + // ---------------------------------------- + // Boolean 속성 제거 + // ---------------------------------------- + else if (BOOLEAN_PROPS.has(key)) { + // setAttribute로 제거하면 안 됨! + // property를 false로 설정 + target[key] = false; + // 예: button.disabled = false + // input.checked = false + } + // ---------------------------------------- + // 일반 속성 제거 + // ---------------------------------------- + else { + target.removeAttribute(key); + // 예: id, type, placeholder 등 + } + } + }); + + // ======================================== + // 2단계: 새 속성 추가 또는 값 변경 + // ======================================== + // + // newProps의 각 속성을 oldProps와 비교 + // 값이 다르면 업데이트 + Object.entries(newProps).forEach(([key, value]) => { + // ---------------------------------------- + // 성능 최적화: 값이 같으면 스킵 + // ---------------------------------------- + // + // oldProps[key] === value면 변경 없음 + // DOM 조작을 최소화하여 성능 향상 + // + // 예: + // oldProps = { className: "btn", id: "submit" } + // newProps = { className: "btn", id: "submit" } + // → 모두 같으므로 아무것도 안 함 + if (oldProps[key] !== value) { + // ---------------------------------------- + // 이벤트 핸들러 업데이트 + // ---------------------------------------- + if (key.startsWith("on")) { + const eventType = key.slice(2).toLowerCase(); + + // 이전 핸들러가 있으면 먼저 제거 + // 중요: 같은 eventType에 여러 핸들러가 등록되지 않도록 + if (oldProps[key]) { + removeEvent(target, eventType, oldProps[key]); + } + + // 새 핸들러 등록 + addEvent(target, eventType, value); + + /* + * 예시: + * oldProps = { onClick: oldHandler } + * newProps = { onClick: newHandler } + * + * 1. removeEvent(target, "click", oldHandler) + * 2. addEvent(target, "click", newHandler) + * + * 이제 클릭 시 newHandler만 실행됨 + */ + } + // ---------------------------------------- + // className 업데이트 + // ---------------------------------------- + else if (key === "className") { + target.setAttribute("class", value); + // 예: "btn" → "btn primary" + } + // ---------------------------------------- + // Boolean 속성 업데이트 + // ---------------------------------------- + else if (BOOLEAN_PROPS.has(key)) { + target[key] = value; + // 예: button.disabled = true + // input.checked = false + } + // ---------------------------------------- + // 일반 속성 업데이트 + // ---------------------------------------- + else { + target.setAttribute(key, value); + // 예: id="main", type="text", placeholder="Enter..." + } + } + }); +} + +/* + * updateAttributes 실행 예시: + * + * // 변경 전 + * + * + * // 변경 후 + * + * + * updateAttributes(button, + * { className: "btn primary", onClick: newHandler, id: "submit", type: "button" }, + * { className: "btn", disabled: true, onClick: oldHandler, id: "submit" } + * ) + * + * 실행 과정: + * + * 1단계: 제거된 속성 처리 + * - disabled가 newProps에 없음 + * → button.disabled = false + * + * 2단계: 추가/변경된 속성 처리 + * - className: "btn" !== "btn primary" + * → setAttribute("class", "btn primary") + * + * - onClick: oldHandler !== newHandler + * → removeEvent(button, "click", oldHandler) + * → addEvent(button, "click", newHandler) + * + * - id: "submit" === "submit" + * → 스킵 (변경 없음) + * + * - type: undefined !== "button" + * → setAttribute("type", "button") + * + * 결과: + * + * (disabled 제거됨, onClick 핸들러 교체됨) + */ + +/** + * =================================== + * Diff 알고리즘: Virtual DOM의 핵심 + * =================================== + * + * 이전 vNode와 새 vNode를 비교하여 + * 최소한의 DOM 조작으로 화면을 업데이트합니다. + * + * React의 Reconciliation과 유사한 역할을 합니다. + * + * @param {Element} parentElement - 업데이트할 부모 DOM 요소 + * @param {any} newNode - 새로운 vNode (업데이트 후 상태) + * @param {any} oldNode - 이전 vNode (업데이트 전 상태) + * @param {number} index - 부모의 childNodes에서의 인덱스 (기본값: 0) + * + * @example + * // 이전:

    Count: 0

    + * // 새로운:

    Count: 1

    + * updateElement( + * div, + * { type: "h1", children: ["Count: 1"] }, + * { type: "h1", children: ["Count: 0"] }, + * 0 + * ) + * // → h1의 textContent만 "Count: 1"로 변경 + * // h1 요소는 재사용! + * + * 처리하는 7가지 케이스: + * 1. oldNode만 있음 → 제거 + * 2. newNode만 있음 → 추가 + * 3. 둘 다 텍스트/숫자 → textContent 업데이트 + * 4. 타입 변경 (텍스트 → 요소) → 교체 + * 5. 타입 변경 (요소 → 텍스트) → 교체 + * 6. 요소 타입 변경 (div → span) → 교체 + * 7. 같은 타입 요소 → 속성 업데이트 + 자식 재귀 처리 + */ +export function updateElement(parentElement, newNode, oldNode, index = 0) { + // ======================================== + // 케이스 1: oldNode만 있는 경우 → 노드 제거 + // ======================================== + // + // 언제 발생하는가? + // - 조건부 렌더링에서 요소가 사라짐 + // 예: {isVisible &&
    Content
    } + // isVisible: true → false + // + // - 배열 요소 감소 + // 예: [li1, li2, li3] → [li1, li2] + // + // 처리: DOM에서 해당 노드 제거 + if (!newNode && oldNode) { + const child = parentElement.childNodes[index]; + if (child) { + return parentElement.removeChild(child); + } + return; + } + + /* + * 예시: + * // Old:
    • A
    • B
    • C
    + * // New:
    • A
    • B
    + * + * updateElement(ul, undefined, liC, 2); + * // → ul.removeChild(ul.childNodes[2]) + * + * 결과:
    • A
    • B
    + */ + + // ======================================== + // 케이스 2: newNode만 있는 경우 → 노드 추가 + // ======================================== + // + // 언제 발생하는가? + // - 조건부 렌더링에서 요소가 나타남 + // 예: {isVisible &&
    Content
    } + // isVisible: false → true + // + // - 배열 요소 증가 + // 예: [li1, li2] → [li1, li2, li3] + // + // 처리: createElement로 새 DOM 생성 후 추가 + if (newNode && !oldNode) { + return parentElement.appendChild(createElement(newNode)); + } + + /* + * 예시: + * // Old:
    • A
    • B
    + * // New:
    • A
    • B
    • C
    + * + * updateElement(ul, liC, undefined); + * // → ul.appendChild(createElement(liC)) + * + * 결과:
    • A
    • B
    • C
    + */ + + // ======================================== + // 케이스 3: 둘 다 문자열/숫자 → 텍스트 노드 업데이트 + // ======================================== + // + // 가장 흔한 케이스: 텍스트 내용 변경 + // + // 왜 중요한가? + // - 텍스트 노드를 새로 만들지 않고 textContent만 변경 + // - 매우 효율적 (DOM 생성 비용 없음) + // + // 예:

    Count: 0

    Count: 1

    + if (typeof newNode === "string" || typeof newNode === "number") { + if (typeof oldNode === "string" || typeof oldNode === "number") { + // 둘 다 텍스트/숫자 + if (newNode !== oldNode) { + // 값이 다르면 textContent 업데이트 + const child = parentElement.childNodes[index]; + if (child) { + child.textContent = String(newNode); + } + } + // 같으면 아무것도 안 함 (최적화) + return; + } else { + // oldNode는 요소, newNode는 텍스트 → 교체 (케이스 4) + const child = parentElement.childNodes[index]; + if (child) { + return parentElement.replaceChild(createElement(newNode), child); + } + return; + } + } + + /* + * 예시: + * // Old:
    Hello
    + * // New:
    Hi
    + * + * // div의 children[0]: + * updateElement(div, "Hi", "Hello", 0); + * // → div.childNodes[0].textContent = "Hi" + * + * 결과:
    Hi
    + * (Text 노드 재사용, textContent만 변경!) + */ + + // ======================================== + // 케이스 4: oldNode가 텍스트, newNode가 요소 → 교체 + // ======================================== + // + // 타입이 완전히 변경됨 → 재사용 불가능 → 교체 + // + // 예:
    Hello
    Hello
    + if (typeof oldNode === "string" || typeof oldNode === "number") { + const child = parentElement.childNodes[index]; + if (child) { + return parentElement.replaceChild(createElement(newNode), child); + } + return; + } + + /* + * 예시: + * // Old:
    Text
    + * // New:
    Text
    + * + * // div의 children[0]: + * // oldNode = "Text" (문자열) + * // newNode = { type: "strong", children: ["Text"] } + * + * updateElement(div, strongVNode, "Text", 0); + * // → div.replaceChild(Text, textNode) + * + * 결과:
    Text
    + */ + + // ======================================== + // 케이스 5: 요소 타입이 변경됨 → 교체 + // ======================================== + // + // 다른 타입의 요소는 재사용 불가능 + // + // 왜 재사용하지 않는가? + // - 구조가 완전히 다를 수 있음 + // - 속성, 자식이 전혀 다를 수 있음 + // - 새로 만드는 게 더 안전하고 간단 + // + // 예:
    ,
  • + if (newNode.type !== oldNode.type) { + const child = parentElement.childNodes[index]; + if (child) { + return parentElement.replaceChild(createElement(newNode), child); + } + return; + } + + /* + * 예시: + * // Old:
    • Item
    + * // New:
      Item
    + * + * // ul의 children[0]: + * // oldNode.type = "li" + * // newNode.type = "div" + * + * updateElement(ul, divVNode, liVNode, 0); + * // → ul.replaceChild(
    Item
    ,
  • Item
  • ) + * + * 결과:
      Item
    + */ + + // ======================================== + // 케이스 6 & 7: 같은 타입의 요소 → 재사용! + // ======================================== + // + // 가장 최적화된 경로 + // - DOM 요소 재사용 + // - 속성만 업데이트 + // - 자식 노드 재귀적으로 비교 + // + // 이것이 Virtual DOM의 핵심 장점! + const targetElement = parentElement.childNodes[index]; + + if (targetElement) { + // ---------------------------------------- + // 속성 업데이트 + // ---------------------------------------- + // + // 같은 타입이지만 속성이 변경되었을 수 있음 + // 예:
    + updateAttributes(targetElement, newNode.props, oldNode.props); + + // ---------------------------------------- + // 자식 노드 재귀적으로 업데이트 + // ---------------------------------------- + // + // children 배열을 순회하며 각 자식 비교 + const newLength = newNode.children.length; + const oldLength = oldNode.children.length; + const maxLength = Math.max(newLength, oldLength); + + // 앞에서부터 순차적으로 비교 + // 예: [li1, li2, li3] vs [li1', li2', li4] + // i=0: li1 vs li1' (비교) + // i=1: li2 vs li2' (비교) + // i=2: li3 vs li4 (비교) + for (let i = 0; i < maxLength; i++) { + updateElement( + targetElement, // 부모: 현재 요소 + newNode.children[i], // 새 자식 (없을 수 있음) + oldNode.children[i], // 이전 자식 (없을 수 있음) + i, // 인덱스 + ); + } + + /* + * 재귀 호출 예시: + * + * // Old:

    A

    B

    C
    + * // New:

    A'

    B'

    + * + * updateElement(div, newDiv, oldDiv, 0); + * + * // div는 같은 타입 → 재사용 + * // updateAttributes(div, {...}, {...}) + * + * // 자식 비교: + * // maxLength = max(3, 2) = 3 + * + * // i=0: h1 vs h1 → updateElement 재귀 호출 + * // 같은 타입 → h1 재사용, children 비교 + * + * // i=1: p vs p → updateElement 재귀 호출 + * // 같은 타입 → p 재사용, children 비교 + * + * // i=2: undefined vs span → updateElement 재귀 호출 + * // 케이스 1: oldNode만 있음 → span 제거 + * + * // 결과:

    A'

    B'

    + */ + + // ---------------------------------------- + // 남은 자식 노드 제거 (역순으로!) + // ---------------------------------------- + // + // oldLength > newLength: 이전에 더 많은 자식이 있었음 + // 예: 5개 → 3개: 4번, 3번 인덱스 제거 + // + // 왜 역순으로 제거하는가? + // - 정순: 앞에서 제거하면 뒤 인덱스가 변경됨 + // - 역순: 뒤에서 제거하면 앞 인덱스는 안전함 + if (oldLength > newLength) { + for (let i = oldLength - 1; i >= newLength; i--) { + const child = targetElement.childNodes[i]; + if (child) { + targetElement.removeChild(child); + } + } + } + + /* + * 역순 제거의 중요성: + * + * // Old:
    • A
    • B
    • C
    • D
    + * // New:
    • A
    • B
    + * + * // oldLength = 4, newLength = 2 + * // C와 D를 제거해야 함 (인덱스 2, 3) + * + * // ❌ 정순 제거 (잘못됨): + * for (let i = 2; i < 4; i++) { + * ul.removeChild(ul.childNodes[i]); + * } + * // i=2: C 제거 → [A, B, D] (D가 인덱스 2로 이동!) + * // i=3: childNodes[3]은 없음! → D가 남음 + * + * // ✅ 역순 제거 (올바름): + * for (let i = 3; i >= 2; i--) { + * ul.removeChild(ul.childNodes[i]); + * } + * // i=3: D 제거 → [A, B, C] + * // i=2: C 제거 → [A, B] + * // 완벽! + */ + } +} + +/* + * =================================== + * 전체 Diff 알고리즘 실행 예시 + * =================================== + * + * // Old: + *
    + *

    Count: 0

    + *
      + *
    • Item 1
    • + *
    • Item 2
    • + *
    • Item 3
    • + *
    + *

    Total: 3

    + *
    + * + * // New: + *
    + *

    Count: 5

    + *
      + *
    • Item 1
    • + *
    • Item 2 Updated
    • + *
    + *

    Total: 2

    + * + *
    + * + * updateElement(container, newDiv, oldDiv, 0); + * + * // === 실행 과정 === + * + * // 1. div 비교 + * // - type: "div" === "div" ✓ (재사용) + * // - updateAttributes(div, { className: "wrapper" }, { className: "container" }) + * // → div.className = "wrapper" + * + * // 2. div의 children 비교 + * // maxLength = max(4, 4) = 4 + * + * // i=0: h1 vs h1 + * // - type 같음 ✓ (재사용) + * // - children: ["Count: 0"] vs ["Count: 5"] + * // → textContent = "Count: 5" + * + * // i=1: ul vs ul + * // - type 같음 ✓ (재사용) + * // - ul의 children 비교 + * // maxLength = max(3, 2) = 3 + * // + * // i=0: li("Item 1") vs li("Item 1") → 동일, 유지 + * // i=1: li("Item 2") vs li("Item 2 Updated") + * // → textContent = "Item 2 Updated" + * // i=2: undefined vs li("Item 3") + * // → removeChild(li[2]) + * + * // i=2: p vs p + * // - type 같음 ✓ (재사용) + * // - children: ["Total: 3"] vs ["Total: 2"] + * // → textContent = "Total: 2" + * + * // i=3: undefined vs button + * // - newNode만 있음 (케이스 2) + * // → appendChild(createElement(button)) + * + * // === 최종 DOM 조작 === + * + * 총 6번의 조작: + * 1. div.className = "wrapper" + * 2. h1의 textContent = "Count: 5" + * 3. li[1]의 textContent = "Item 2 Updated" + * 4. ul.removeChild(li[2]) + * 5. p의 textContent = "Total: 2" + * 6. div.appendChild(button) + * + * 재사용된 요소: + * - div (최상위) + * - h1 + * - ul + * - li[0], li[1] + * - p + * + * 새로 생성된 요소: + * - button (1개만!) + * + * 제거된 요소: + * - li[2] (1개만!) + * + * 결과: 최소한의 DOM 조작으로 효율적인 업데이트! + * 전체를 다시 만들지 않음 → 빠른 성능 + 부드러운 UX + */