diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4ce998c4..c9e26fe4 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,106 +2,228 @@ ### 배포 링크 - +https://youngh02.github.io/front_7th_chapter2-2/ ### 기본과제 #### Phase 1: VNode와 기초 유틸리티 -- [ ] `core/elements.ts`: `createElement`, `normalizeNode`, `createChildPath` -- [ ] `utils/validators.ts`: `isEmptyValue` -- [ ] `utils/equals.ts`: `shallowEquals`, `deepEquals` +- [x] `core/elements.ts`: `createElement`, `normalizeNode`, `createChildPath` +- [x] `utils/validators.ts`: `isEmptyValue` +- [x] `utils/equals.ts`: `shallowEquals`, `deepEquals` #### Phase 2: 컨텍스트와 루트 초기화 -- [ ] `core/types.ts`: VNode/Instance/Context 타입 선언 -- [ ] `core/context.ts`: 루트/훅 컨텍스트와 경로 스택 관리 -- [ ] `core/setup.ts`: 컨테이너 초기화, 컨텍스트 리셋, 루트 렌더 트리거 +- [x] `core/types.ts`: VNode/Instance/Context 타입 선언 +- [x] `core/context.ts`: 루트/훅 컨텍스트와 경로 스택 관리 +- [x] `core/setup.ts`: 컨테이너 초기화, 컨텍스트 리셋, 루트 렌더 트리거 #### Phase 3: DOM 인터페이스 구축 -- [ ] `core/dom.ts`: 속성/스타일/이벤트 적용 규칙, DOM 노드 탐색/삽입/제거 +- [x] `core/dom.ts`: 속성/스타일/이벤트 적용 규칙, DOM 노드 탐색/삽입/제거 #### Phase 4: 렌더 스케줄링 -- [ ] `utils/enqueue.ts`: `enqueue`, `withEnqueue`로 마이크로태스크 큐 구성 -- [ ] `core/render.ts`: `render`, `enqueueRender`로 루트 렌더 사이클 구현 +- [x] `utils/enqueue.ts`: `enqueue`, `withEnqueue`로 마이크로태스크 큐 구성 +- [x] `core/render.ts`: `render`, `enqueueRender`로 루트 렌더 사이클 구현 #### Phase 5: Reconciliation -- [ ] `core/reconciler.ts`: 마운트/업데이트/언마운트, 자식 비교, key/anchor 처리 -- [ ] `core/dom.ts`: Reconciliation에서 사용할 DOM 재배치 보조 함수 확인 + +- [x] `core/reconciler.ts`: 마운트/업데이트/언마운트, 자식 비교, key/anchor 처리 +- [x] `core/dom.ts`: Reconciliation에서 사용할 DOM 재배치 보조 함수 확인 #### Phase 6: 기본 Hook 시스템 -- [ ] `core/hooks.ts`: 훅 상태 저장, `useState`, `useEffect`, cleanup/queue 관리 -- [ ] `core/context.ts`: 훅 커서 증가, 방문 경로 기록, 미사용 훅 정리 +- [x] `core/hooks.ts`: 훅 상태 저장, `useState`, `useEffect`, cleanup/queue 관리 +- [x] `core/context.ts`: 훅 커서 증가, 방문 경로 기록, 미사용 훅 정리 **기본 과제 완료 기준**: `basic.equals.test.tsx`, `basic.mini-react.test.tsx` 전부 통과 ### 심화과제 #### Phase 7: 확장 Hook & HOC -- [ ] `hooks/useRef.ts`: ref 객체 유지 -- [ ] `hooks/useMemo.ts`, `hooks/useCallback.ts`: shallow 비교 기반 메모이제이션 -- [ ] `hooks/useDeepMemo.ts`, `hooks/useAutoCallback.ts`: deep 비교/자동 콜백 헬퍼 -- [ ] `hocs/memo.ts`, `hocs/deepMemo.ts`: props 비교 기반 컴포넌트 메모이제이션 +- [x] `hooks/useRef.ts`: ref 객체 유지 +- [x] `hooks/useMemo.ts`, `hooks/useCallback.ts`: shallow 비교 기반 메모이제이션 +- [x] `hooks/useDeepMemo.ts`, `hooks/useAutoCallback.ts`: deep 비교/자동 콜백 헬퍼 +- [x] `hocs/memo.ts`, `hocs/deepMemo.ts`: props 비교 기반 컴포넌트 메모이제이션 ## 과제 셀프회고 - +과제 시작 시 막막함을 느껴 easy 난이도의 Virtual DOM 구현부터 시작했는데, 이 접근이 전체 구조를 이해하는 데 도움이 되었습니다. 처음 시작이 되니 그래도..방향을 잡기 수월했습니다. 초반 Phase들(VNode, Context, DOM 조작)은 어느 정도 이해하며 구현할 수 있었지만, Hooks 부분으로 갈수록 커서 기반 상태 관리와 클로저 활용이 복잡하게 느껴졌습니다. 특히 hooks들의 실행 타이밍과 의존성 배열 비교 로직은 여전히 어려워 추가로 살펴볼 예정입니다. 그래도 React의 내부 동작 원리를 직접 구현하며 아주 조금 React를 알게된 것 같습니다... ### 아하! 모먼트 (A-ha! Moment) - + +**1. React가 불변성을 강조하는 이유** + +- **shallowEquals**: 1단계 속성의 참조만 비교 (빠름) +- **deepEquals**: 모든 깊이 재귀적으로 비교 (느림) +- **불변성을 지키면**: 상태 변경 시 새 객체 생성 → 참조가 바뀜 → shallow 비교로 변경 감지 가능 +- **불변성을 안 지키면**: 기존 객체 수정 → 참조가 같음 → shallow 비교로 변경 감지 불가 → deep 비교 필요 (느림) + + +```typescript +// 불변성 지킴 +const oldState = { user: { name: "React", age: 10 } }; +const newState = { ...oldState, user: { ...oldState.user, age: 11 } }; +shallowEquals(oldState, newState); // false (user 참조가 바뀜 → 변경 감지!) + +// 불변성 안 지킴 ❌ +const oldState2 = { user: { name: "React", age: 10 } }; +oldState2.user.age = 11; // 기존 객체 수정 +shallowEquals(oldState2, oldState2); // true (참조 같음 → 변경 감지 불가!) +``` + +**2. Hook은 호출 순서로 관리된다** + +Hook은 변수명이 아니라 **배열 인덱스**로 상태를 식별합니다. 따라서: +- 조건부 호출 시 인덱스가 꼬여서 잘못된 상태 반환 +- **클로저**로 setState가 생성 시점의 인덱스를 기억 +- "Hook은 최상위에서만 호출" 규칙의 근본적 이유를 이해함 + +```typescript +// 잘못된 사용: 조건부 Hook 호출 +function Counter() { + const [count, setCount] = useState(0); // 인덱스 0 + + if (count > 0) { + const [name, setName] = useState(""); // 인덱스 1 (조건부!) + } + + const [age, setAge] = useState(20); // 인덱스 1 또는 2? +} + +// 첫 렌더링 (count = 0) +// useState(0) → 인덱스 0에 저장 +// if 문 스킵 +// useState(20) → 인덱스 1에 저장 + +// 두 번째 렌더링 (count = 1) +// useState(0) → 인덱스 0 읽음 ✅ +// useState("") → 인덱스 1 읽음 ❌ (20이 나옴!) +// useState(20) → 인덱스 2 읽음 ❌ (undefined!) + +// 올바른 사용: 항상 같은 순서로 호출 +function Counter() { + const [count, setCount] = useState(0); + const [name, setName] = useState(""); + const [age, setAge] = useState(20); + + // 조건부로 사용하되, 호출 순서는 유지 + if (count > 0) { + // name 사용 + } +} +``` + +**3. Attribute vs Property의 차이** + +- **Attribute**: HTML 문서에 쓰인 초기값 (`setAttribute`) +- **Property**: JavaScript 객체의 현재 값 (`dom.value = ...`) +- `value`, `checked` 등은 **property로 설정해야 실제 값이 변경됨** +- 이를 놓쳐서 URL 복원 시 input에 값이 안 나타나는 문제가 있었고, property로 수정하여 해결 + +```typescript +// 잘못된 방법: setAttribute 사용 +const input = document.createElement('input'); +input.setAttribute('value', '젤리'); +console.log(input.value); // "" (빈 문자열! 실제 값은 안 바뀜) + +// 올바른 방법: property 직접 설정 +const input2 = document.createElement('input'); +input2.value = '젤리'; +console.log(input2.value); // "젤리" (실제 값 변경됨!) + + // value는 property로 처리해야 함 +``` + +**4. queueMicrotask로 렌더링 배치 처리** + +- 동기 코드 실행 → Microtask (렌더링) → Macrotask 순서 +- 여러 setState 호출을 한 번의 렌더링으로 배치 처리 +- React의 Automatic Batching 동작 원리를 이해함 + +```typescript +// 여러 setState 호출 +setCount(1); +setName("React"); +setAge(10); + +// queueMicrotask로 배치 처리 +// → 동기 코드 모두 실행 완료 후 +// → 한 번의 렌더링으로 모든 상태 업데이트 반영 + +// 실행 순서: +// 1. setState 호출들 (동기) +// 2. 렌더링 (microtask) ← 한 번만 실행! +// 3. setTimeout 등 (macrotask) +``` ### 기술적 성장 - - -### 코드 품질 - + +**핵심 개념 학습** + +1. **Reconciliation**: 타입 비교 → DOM 재사용 여부 결정 → 속성만 업데이트 → DOM 조작 최소화 +2. **커서 기반 Hook 관리**: `Map<컴포넌트경로, Hook배열>`로 각 컴포넌트의 상태를 독립적으로 관리 +3. **클로저 활용**: setState가 생성 시점의 인덱스를 기억하여 나중에 호출해도 정확한 상태 업데이트 +4. **Property vs Attribute**: input의 `value`는 property로 설정해야 실제 값 변경 + +**만족스러운 구현** + +1. **context.ts getter 패턴**: 스택이 비어있을 때 명확한 에러 제공 ("훅은 컴포넌트 내부에서만 호출") +2. **withEnqueue 클로저**: `scheduled` 플래그를 클로저로 캡슐화하여 배치 렌더링 구현 +3. **커서와 상태 분리**: cursor(순서 추적)와 state(값 저장)를 분리하여 매 렌더링마다 cursor만 초기화 ### 학습 효과 분석 - + +**React Rules의 근본적 이유를 이해함** + +| 규칙 | 이전 | 이후 (구현 후) | +|------|------|--------------| +| Hook 최상위 호출 | ESLint가 시키니까 | 커서 기반이라 순서 바뀌면 상태 꼬임 | +| key 사용 | 경고 안 뜨게 | 경로 생성 시 리스트 재정렬에도 상태 유지 | +| 의존성 배열 정확히 | 습관적으로 | shallowEquals 비교라 누락 시 effect 미실행 | +| 불변성 유지 | 권장사항 | shallow 비교가 빠르므로 참조만 바꾸면 됨 | ### 과제 피드백 - + +**과제에서 좋았던 부분** + +1. 단계별 구성: Reade.md에 순서가 정리되어 있어 따라가기 좋았습니다. VNode 정규화 → Context 관리 → DOM 조작 → 렌더 스케줄링 → Reconciliation → Hooks 순서로 자연스럽게 학습할 수 있었습니다. +2. 테스트 주도 학습: 각 Phase마다 테스트가 제공되어, 구현이 올바른지 즉시 확인할 수 있었습니다. +3. 주석과 가이드: 각 함수에 "여기를 구현하세요"와 함께 단계별 힌트가 있어서, 막막하지 않고 차근차근 진행할 수 있었습니다. + +**과제에서 모호하거나 애매했던 부분** + +1. **createChildPath의 inferredIndex**: siblings를 필터링해서 같은 타입의 인덱스를 계산하는 로직이 처음에는 이해하기 어려웠습니다. 왜 이렇게 복잡하게 계산하는지 예시가 더 있었으면 좋았을 것 같습니다. +2. **Fragment 처리**: Fragment가 DOM에 추가되면 사라진다는 특성 때문에, insertInstance와 removeInstance에서 어떻게 처리해야 할지 고민이 많았습니다. Fragment의 생명주기에 대한 설명이 더 있었으면 좋았을 것 같습니다. +3. **Effect 실행 타이밍**: useEffect가 렌더링 후 비동기로 실행된다는 것은 알았지만, 정확히 언제 cleanup이 실행되고 언제 새 effect가 실행되는지 순서가 헷갈렸습니다. 타임라인 다이어그램이 있었다면 더 좋았을 것 같습니다. ## 리뷰 받고 싶은 내용 - +현재 전역 `context` 객체를 사용 중인데, 실무에서 여러 React 루트를 지원하려면 각 루트마다 별도 context 인스턴스를 생성해야 할까요? diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ae599e60 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,54 @@ +name: Deploy to GitHub Pages + +on: + push: # push trigger + branches: + - main + - release-* # release 브랜치도 배포 + +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: 10.22.0 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Create SPA fallback + run: cp packages/app/dist/index.html packages/app/dist/404.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "packages/app/dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/docs/ENQUEUE_EVENTLOOP.md b/docs/ENQUEUE_EVENTLOOP.md new file mode 100644 index 00000000..6bbd77e9 --- /dev/null +++ b/docs/ENQUEUE_EVENTLOOP.md @@ -0,0 +1,275 @@ +# ⚡ enqueue와 Event Loop 이해하기 + +## 🎯 핵심 개념 + +**문제:** 상태가 여러 번 변경될 때마다 렌더링하면 느려요! 💀 + +**해결:** 상태 변경은 즉시, 렌더링은 나중에 1번만! ⚡ + +--- + +## 📚 주요 구성 요소 + +### 1️⃣ **enqueue** - "나중에 해줘" 예약 + +```typescript +export const enqueue = (callback: () => void) => { + queueMicrotask(callback); // JavaScript 내장 API +}; +``` + +**역할:** + +- 함수를 **지금 실행하지 않고** Microtask Queue에 추가 +- Call Stack이 비면 자동으로 실행됨 + +**예시:** + +```typescript +console.log("1"); +enqueue(() => console.log("2")); // 나중에! +console.log("3"); + +// 출력: 1, 3, 2 +``` + +--- + +### 2️⃣ **withEnqueue** - 중복 방지 + +```typescript +export const withEnqueue = (fn: AnyFunction) => { + let scheduled = false; // 플래그 (클로저!) + + return () => { + if (scheduled) return; // 이미 예약됨 → 무시 + scheduled = true; + enqueue(() => { + scheduled = false; + fn(); + }); + }; +}; +``` + +**역할:** + +- 여러 번 호출해도 **1번만 예약** +- `scheduled` 플래그로 중복 방지 + +**예시:** + +```typescript +const enqueueRender = withEnqueue(render); + +enqueueRender(); // 예약! ✅ +enqueueRender(); // 무시! (이미 예약됨) +enqueueRender(); // 무시! (이미 예약됨) + +// 결과: render() 1번만 실행! +``` + +--- + +### 3️⃣ **실제 사용 (render)** + +```typescript +// render.ts +export const render = (): void => { + // 렌더링 로직... +}; + +// 중복 방지 버전 +export const enqueueRender = withEnqueue(render); +``` + +**동작:** + +```typescript +button.onclick = () => { + setState(1); // count = 1, enqueueRender() 호출 + setState(2); // count = 2, enqueueRender() 호출 (무시) + setState(3); // count = 3, enqueueRender() 호출 (무시) +}; + +// 클릭 끝난 후 +// → render() 1번 실행 +// → count는 3 (최신 값!) +``` + +--- + +## 🔄 JavaScript Event Loop + +### **구조** + +``` +┌─────────────────┐ +│ Call Stack │ ← 지금 실행 중 +│ (실행 중) │ +└─────────────────┘ + ↓ (비면 확인) +┌─────────────────┐ +│ Microtask Queue │ ← queueMicrotask로 추가된 것 +│ (대기 중) │ (더 빠름!) +└─────────────────┘ + ↓ +┌─────────────────┐ +│ Task Queue │ ← setTimeout 등 +│ (대기 중) │ (더 느림) +└─────────────────┘ +``` + +### **규칙** + +1. **Call Stack** 실행 +2. Call Stack이 **비면** +3. **Microtask Queue** 확인 → 있으면 실행 +4. Task Queue 확인 → 있으면 실행 +5. 반복! + +--- + +## 🎬 전체 흐름 시뮬레이션 + +```typescript +// 사용자가 버튼 클릭 +button.onclick = () => { + console.log("시작"); // [Call Stack] 즉시 실행 + + setState(1); // [Call Stack] count = 1 + // → enqueueRender() + // → scheduled = false → true + // → queueMicrotask(() => render()) [Microtask Queue에 추가!] + + setState(2); // [Call Stack] count = 2 + // → enqueueRender() + // → scheduled = true → return (무시) + + setState(3); // [Call Stack] count = 3 + // → enqueueRender() + // → scheduled = true → return (무시) + + console.log("끝"); // [Call Stack] 즉시 실행 + + // === onclick 종료, Call Stack 비어짐! === + + // Event Loop: "Microtask Queue 확인!" + + // [Microtask Queue 실행] + // → render() 실행! + // → count는 3 (최신 값!) + // → 화면 업데이트 + // → scheduled = false (리셋) +}; +``` + +**콘솔 출력:** + +``` +시작 +끝 +(render 실행 - count: 3) +``` + +--- + +## 📊 비교 + +### ❌ **enqueue 없이 (동기 실행)** + +```typescript +setState(1); // count = 1, render() 즉시 실행 💀 +setState(2); // count = 2, render() 즉시 실행 💀 +setState(3); // count = 3, render() 즉시 실행 💀 + +// 문제: render 3번! (느림!) +``` + +### ✅ **enqueue + withEnqueue** + +```typescript +setState(1); // count = 1, render 예약 +setState(2); // count = 2, 예약 무시 +setState(3); // count = 3, 예약 무시 + +// 나중에 render 1번! (빠름!) +``` + +--- + +## 🎯 핵심 정리 + +### **3가지만 기억하세요!** + +1. **enqueue** = "나중에 해줘" (queueMicrotask) +2. **withEnqueue** = "1번만 예약" (scheduled 플래그) +3. **Event Loop** = Call Stack 비면 → Microtask Queue 실행 + +### **왜 사용하나요?** + +```typescript +// 렌더링 3번 (느림) → 렌더링 1번 (빠름!) +``` + +### **어떻게 가능한가요?** + +```typescript +// JavaScript의 Event Loop + queueMicrotask! +``` + +--- + +## 💡 추가 참고 + +### **비슷한 개념** + +```typescript +// 옛날 방식 (같은 동작) +Promise.resolve().then(() => render()); + +// 최신 방식 (더 명확) +queueMicrotask(() => render()); +``` + +### **다른 예시: setTimeout** + +```typescript +console.log("1"); +setTimeout(() => console.log("2"), 0); // Task Queue +queueMicrotask(() => console.log("3")); // Microtask Queue +console.log("4"); + +// 출력: 1, 4, 3, 2 +// (Microtask가 Task보다 먼저!) +``` + +--- + +## 🚀 실전 적용 + +### **React/Mini-React에서** + +```typescript +// 상태 변경 +const [count, setCount] = useState(0); + +// 버튼 클릭 +onClick={() => { + setCount(1); // enqueueRender() 예약 + setCount(2); // 무시 + setCount(3); // 무시 +}} + +// → render 1번! count: 3! +``` + +### **성능 향상** + +- ❌ 렌더링 3번 (150ms) +- ✅ 렌더링 1번 (50ms) +- 🎉 **3배 빠름!** + +--- + +끝! 🎉 diff --git a/docs/HOOKS_INTERNALS.md b/docs/HOOKS_INTERNALS.md new file mode 100644 index 00000000..6d493660 --- /dev/null +++ b/docs/HOOKS_INTERNALS.md @@ -0,0 +1,462 @@ +# React Hooks 내부 동작 원리 + +이 문서는 React Hooks가 **어떻게 구현되고 동작하는지**를 설명합니다. 코드보다는 **개념과 메커니즘**에 집중합니다. + +--- + +## 📚 목차 + +1. [Hooks의 기본 원리](#1-hooks의-기본-원리) +2. [useState 내부 동작](#2-usestate-내부-동작) +3. [useEffect 내부 동작](#3-useeffect-내부-동작) +4. [useMemo 내부 동작](#4-usememo-내부-동작) +5. [useCallback 내부 동작](#5-usecallback-내부-동작) +6. [useRef 내부 동작](#6-useref-내부-동작) +7. [Hooks가 동작하는 전체 흐름](#7-hooks가-동작하는-전체-흐름) + +--- + +## 1. Hooks의 기본 원리 + +### 🎯 핵심 개념: "컴포넌트별 상태 저장소" + +Hooks는 **전역 상태 저장소**를 사용하여 각 컴포넌트의 상태를 관리합니다. + +``` +전역 Context +├─ hooks +│ ├─ state: Map<경로, Hook배열> +│ ├─ cursor: Map<경로, 현재인덱스> +│ └─ componentStack: [렌더링중인컴포넌트경로] +└─ effects + └─ queue: [실행할effect함수들] +``` + +### 📍 컴포넌트 경로(Path)란? + +각 컴포넌트 인스턴스는 **고유한 경로**를 가집니다: + +- `"root"` - 최상위 컴포넌트 +- `"root.0"` - 첫 번째 자식 +- `"root.0.button-123"` - key가 있는 경우 포함 + +이 경로를 사용해서 "어떤 컴포넌트의 상태인지" 구분합니다. + +### 🔢 커서(Cursor)란? + +컴포넌트가 렌더링될 때, Hook이 호출될 때마다 **커서가 증가**합니다: + +1. `useState()` 호출 → 커서 0번 사용 +2. `useEffect()` 호출 → 커서 1번 사용 +3. `useState()` 호출 → 커서 2번 사용 + +**중요:** Hook은 항상 **같은 순서**로 호출되어야 합니다! + +--- + +## 2. useState 내부 동작 + +### 🔄 렌더링 흐름 + +#### 1단계: Hook 저장소 확인 + +컴포넌트가 렌더링될 때: + +1. 현재 컴포넌트의 **경로**를 확인 +2. 현재 **커서 위치**를 확인 +3. 해당 위치에 저장된 Hook이 있는지 확인 + +#### 2단계: 초기화 vs 재사용 + +**첫 렌더링 (Mount):** +- 저장소에 Hook이 없음 +- 초기값으로 상태 생성 +- 저장소에 저장 + +**재렌더링 (Update):** +- 저장소에서 기존 상태를 꺼냄 +- 그대로 반환 + +#### 3단계: setState 함수 생성 + +setState는 **클로저**를 활용합니다: + +- **저장 시점의 커서 위치**를 기억 +- 호출되면: + 1. 저장소의 **해당 위치 상태**를 업데이트 + 2. **재렌더링 예약** (batching 사용) + +### 🎯 핵심 메커니즘 + +**왜 useState가 상태를 유지할 수 있나?** + +``` +렌더링 1: + └─ useState() 호출 + └─ 저장소["root"][0] = { value: 0 } + └─ setState를 클로저로 만듦 (인덱스 0 기억) + +사용자가 setState(1) 호출: + └─ 저장소["root"][0] = { value: 1 } + └─ 재렌더링 예약 + +렌더링 2: + └─ useState() 호출 + └─ 저장소["root"][0] 확인 + └─ { value: 1 } 발견! + └─ 1 반환 +``` + +--- + +## 3. useEffect 내부 동작 + +### 📊 저장 구조 + +각 useEffect Hook은 다음을 저장합니다: + +``` +{ + deps: [이전의존성배열], + cleanup: 이전cleanup함수 +} +``` + +### 🔄 실행 흐름 + +#### 1단계: 의존성 비교 + +렌더링 중에 useEffect가 호출되면: + +1. 저장소에서 **이전 의존성 배열** 가져오기 +2. 현재 의존성과 **얕은 비교** (shallowEquals) +3. 변경 여부 판단 + +#### 2단계: Effect 예약 + +**의존성이 변경된 경우:** + +1. 이전 cleanup 함수가 있으면 **먼저 실행** +2. 새 effect 함수를 **큐에 추가** +3. 의존성 배열 업데이트 + +**의존성이 안 변한 경우:** +- 아무것도 안 함 + +#### 3단계: Effect 실행 타이밍 + +**중요:** Effect는 **렌더링 이후**에 실행됩니다! + +``` +1. 컴포넌트 렌더링 시작 +2. useEffect 호출 → 큐에 추가만 함 +3. 렌더링 완료 (DOM 업데이트) +4. Effect 큐의 모든 함수 실행 (비동기, queueMicrotask 사용) +``` + +### 🎯 핵심 메커니즘 + +**왜 useEffect는 렌더링 후에 실행되나?** + +- 렌더링 중에 실행하면 **무한 루프** 가능 +- DOM 업데이트가 완료된 후 실행해야 안전 +- **queueMicrotask**를 사용해 마이크로태스크 큐에 예약 + +--- + +## 4. useMemo 내부 동작 + +### 📊 저장 구조 + +``` +{ + deps: [이전의존성배열], + value: 계산된값 +} +``` + +### 🔄 실행 흐름 + +#### 1단계: 캐시 확인 + +useMemo가 호출되면: + +1. 저장소에서 이전 Hook 데이터 가져오기 +2. **이전 의존성**과 **현재 의존성** 비교 + +#### 2단계: 재계산 여부 결정 + +**의존성이 변경된 경우:** +1. factory 함수 **실행** +2. 결과값 저장 +3. 의존성 배열 업데이트 +4. 새 값 반환 + +**의존성이 안 변한 경우:** +- 저장된 값을 그대로 반환 (재계산 안 함!) + +### 🎯 핵심 메커니즘 + +**메모이제이션의 원리:** + +``` +렌더링 1: + └─ useMemo(() => expensiveCalc(a), [a]) + └─ a = 1 + └─ expensiveCalc 실행 (오래 걸림...) + └─ 저장: { deps: [1], value: 100 } + +렌더링 2 (a는 그대로 1): + └─ useMemo(() => expensiveCalc(a), [a]) + └─ 이전 deps [1]과 현재 [1] 비교 + └─ 같음! → expensiveCalc 실행 안 함 + └─ 저장된 value: 100 반환 + +렌더링 3 (a가 2로 변경): + └─ useMemo(() => expensiveCalc(a), [a]) + └─ 이전 deps [1]과 현재 [2] 비교 + └─ 다름! → expensiveCalc 다시 실행 + └─ 저장: { deps: [2], value: 200 } +``` + +--- + +## 5. useCallback 내부 동작 + +### 🎯 핵심: useMemo의 특수 케이스 + +useCallback은 **useMemo를 래핑**한 것입니다: + +``` +useCallback(fn, deps) = useMemo(() => fn, deps) +``` + +### 🔄 실행 흐름 + +useCallback은 내부적으로 useMemo를 호출하므로 **동작 방식이 동일**합니다. + +차이점: +- useMemo: 함수를 **실행**해서 **결과값** 저장 +- useCallback: 함수 **자체**를 저장 + +### 🎯 핵심 메커니즘 + +**왜 useCallback이 필요한가?** + +함수는 매 렌더링마다 **새로 생성**됩니다: + +``` +렌더링 1: + └─ const fn = () => console.log('hi') // 함수 객체 A + +렌더링 2: + └─ const fn = () => console.log('hi') // 함수 객체 B (새로 생성!) +``` + +같은 코드지만 **참조가 다름**! (A !== B) + +**useCallback 사용 시:** + +``` +렌더링 1: + └─ const fn = useCallback(() => console.log('hi'), []) + └─ 저장: { deps: [], value: 함수A } + +렌더링 2: + └─ const fn = useCallback(() => console.log('hi'), []) + └─ deps 비교 ([] === []) + └─ 저장된 함수A 반환 (새로 안 만듦!) +``` + +**결과:** 자식 컴포넌트에 전달된 props가 안 바뀜 → 리렌더링 방지! + +--- + +## 6. useRef 내부 동작 + +### 🎯 핵심: useState + 렌더링 스킵 + +useRef는 내부적으로 **useState를 사용**합니다: + +``` +useRef(initialValue) ≈ useState({ current: initialValue })[0] +``` + +### 🔄 실행 흐름 + +#### 1단계: 초기화 + +첫 렌더링: +- useState로 `{ current: initialValue }` 객체 생성 +- 이 객체의 **참조**만 반환 (setState는 버림) + +#### 2단계: 값 변경 + +`.current`를 변경해도: +- 단순히 **객체의 속성** 변경 +- setState를 안 호출하므로 **재렌더링 안 됨** + +#### 3단계: 참조 유지 + +다음 렌더링: +- useState가 **같은 객체 반환** +- 객체 참조가 그대로 유지됨 + +### 🎯 핵심 메커니즘 + +**useState vs useRef:** + +| | useState | useRef | +|---|---|---| +| 값 변경 | `setState(newValue)` | `ref.current = newValue` | +| 재렌더링 | ✅ 트리거함 | ❌ 트리거 안 함 | +| 참조 유지 | ❌ 새 값 반환 | ✅ 같은 객체 | +| 용도 | UI 상태 | DOM 참조, 변경 추적 안 할 값 | + +--- + +## 7. Hooks가 동작하는 전체 흐름 + +### 🔄 렌더링 사이클 + +``` +1. 사용자 액션 (버튼 클릭 등) + └─ setState() 호출 + +2. 재렌더링 예약 + └─ enqueueRender() → queueMicrotask에 render 등록 + +3. 마이크로태스크 큐 실행 + └─ render() 함수 실행 + +4. Reconciliation (재조정) + ├─ 컴포넌트 함수 호출 + ├─ Hook 호출 (useState, useEffect 등) + │ ├─ 저장소에서 이전 상태 가져오기 + │ ├─ 커서 증가 + │ └─ Effect는 큐에 추가만 + ├─ VNode 트리 생성 + └─ DOM 업데이트 + +5. Layout Effects 실행 (없으면 스킵) + +6. Effect 실행 + └─ queueMicrotask로 effect 큐의 모든 함수 실행 +``` + +### 🎯 핵심 원칙 + +#### 1. Hook 호출 순서는 항상 같아야 함 + +**왜?** 커서 기반이므로! + +``` +❌ 잘못된 사용: +if (condition) { + useState(0); // 조건부로 호출하면 순서가 바뀜! +} + +✅ 올바른 사용: +useState(0); +if (condition) { + // 상태를 사용하되, 호출 순서는 유지 +} +``` + +#### 2. Hook은 컴포넌트 최상위에서만 호출 + +**왜?** 매 렌더링마다 같은 순서를 보장하기 위해! + +#### 3. Effect는 비동기로 실행 + +**왜?** 렌더링 중 부작용을 방지하기 위해! + +### 📊 상태 저장소 구조 예시 + +``` +전역 Context.hooks.state: +{ + "root": [ + { value: 0 }, // useState (커서 0) + { deps: [], cleanup: null }, // useEffect (커서 1) + { deps: [1], value: 100 } // useMemo (커서 2) + ], + "root.0": [ + { current: null }, // useRef (커서 0) + { deps: [], value: fn } // useCallback (커서 1) + ] +} +``` + +--- + +## 🎓 정리 + +### Hook별 핵심 원리 + +| Hook | 저장 내용 | 재계산 조건 | 부작용 | +|------|----------|-----------|--------| +| **useState** | `{ value }` | setState 호출 시 | 재렌더링 | +| **useEffect** | `{ deps, cleanup }` | deps 변경 시 | Effect 실행 | +| **useMemo** | `{ deps, value }` | deps 변경 시 | 없음 | +| **useCallback** | `{ deps, value }` | deps 변경 시 | 없음 | +| **useRef** | `{ current }` | 없음 | 없음 | + +### 전체 동작 핵심 + +1. **저장소 기반:** 전역 Map에 컴포넌트별 상태 저장 +2. **커서 기반:** Hook 호출 순서로 상태 식별 +3. **의존성 비교:** 얕은 비교로 재계산 여부 결정 +4. **비동기 실행:** Effect는 렌더링 후 마이크로태스크로 실행 +5. **클로저 활용:** setState, cleanup 함수가 상태 참조 유지 + +--- + +## 🔍 더 깊이 이해하기 + +### Q1: useState가 클로저를 사용하는 이유? + +setState 함수는 **생성 시점의 커서 인덱스**를 기억해야 합니다. 나중에 호출될 때 "어떤 상태를 업데이트할지" 알아야 하기 때문입니다. + +### Q2: 왜 Hook 순서가 중요한가? + +커서로 상태를 식별하므로, 순서가 바뀌면 **잘못된 상태**를 가져옵니다: + +``` +첫 렌더링: + 0: useState(0) → 0 + 1: useState("hi") → "hi" + +두 번째 렌더링 (조건부로 첫 Hook 스킵): + 0: useState("hi") → 0을 가져옴 (잘못됨!) +``` + +### Q3: useEffect가 비동기인 이유? + +렌더링 중에 Effect를 실행하면: +- 또 다른 setState 호출 가능 +- 무한 루프 발생 가능 +- DOM 업데이트 전에 실행되어 오류 발생 + +따라서 **렌더링 완료 후** 안전하게 실행합니다. + +### Q4: useMemo와 useCallback의 실제 차이? + +**useMemo:** +``` +useMemo(() => a + b, [a, b]) +→ factory 함수 실행 → 결과값 저장 +``` + +**useCallback:** +``` +useCallback(() => doSomething(), []) +→ 함수 자체를 저장 (실행 안 함) +``` + +useMemo는 "무엇을 계산할지"를, useCallback은 "어떤 함수를 기억할지"를 정의합니다. + +--- + +이제 Hook이 **어떻게** 동작하는지 완벽하게 이해하셨을 거예요! 🎉 + diff --git a/docs/VIRTUAL_DOM_FAQ.md b/docs/VIRTUAL_DOM_FAQ.md new file mode 100644 index 00000000..1388c654 --- /dev/null +++ b/docs/VIRTUAL_DOM_FAQ.md @@ -0,0 +1,790 @@ +# Virtual DOM Q&A + +--- + +## 1. Virtual DOM과 JSX 변환 + +### Q1: JSX는 무엇이고, JavaScript와 어떻게 다른가요? + +**A**: JSX는 JavaScript의 **확장 문법**이며, JavaScript 자체는 아닙니다. + +**핵심 이해**: + +- JSX는 HTML처럼 생긴 **편의 문법** (Syntactic Sugar) +- 브라우저는 JSX를 **전혀 이해할 수 없음** +- 반드시 **빌드 도구로 변환** 필요 (Babel, ESBuild, Vite 등) + +**예시**: + +```javascript +// JSX (브라우저가 이해 못함 ❌) +const element =
Hello
; + +// JavaScript (브라우저가 이해함 ✅) +const element = createVNode("div", { className: "box" }, "Hello"); +``` + +--- + +### Q2: JSX는 누가 변환하나요? + +**A**: **빌드 도구(Vite + ESBuild)**가 변환합니다. + +**변환 프로세스**: + +1. **개발자**: JSX로 코드 작성 +2. **Vite 서버**: .jsx 파일 요청 감지 +3. **ESBuild**: JSX를 함수 호출로 변환 +4. **브라우저**: 변환된 JavaScript 실행 + +**설정 위치**: `vite.config.js` + +```javascript +esbuild: { + jsx: "transform", // JSX 변환 활성화 + jsxFactory: "createVNode", // 사용할 함수명 +} +``` + +**주석의 역할**: `/** @jsx createVNode */` + +- ESBuild에게 "이 파일은 createVNode를 사용해!"라고 알려주는 힌트 +- 실제 변환은 vite.config.js 설정이 담당 + +--- + +### Q3: 브라우저에 JSX가 전달되나요? + +**A**: 아니요! **절대 전달되지 않습니다.** + +**개발 환경**: + +``` +JSX 코드 작성 + ↓ +Vite 서버가 실시간 변환 + ↓ +JavaScript로 변환된 코드를 브라우저로 전송 + ↓ +브라우저는 JSX를 한 번도 보지 못함 +``` + +**배포 환경** (`npm run build`): + +``` +JSX 코드 + ↓ +빌드 시점에 완전히 변환 + ↓ +dist/ 폴더에 순수 JavaScript만 생성 + ↓ +JSX 흔적 완전히 제거 +``` + +**확인 방법**: + +- 브라우저 개발자 도구 → Sources 탭 → 전송된 파일 확인 +- `npm run build` 후 `dist/` 폴더 파일 확인 +- JSX 구문은 어디에도 없음! + +--- + +### Q4: Virtual DOM은 어떻게 실제 DOM이 되나요? + +**A**: `createElement` 함수가 VNode를 실제 DOM으로 변환합니다. + +**변환 과정**: + +1. **VNode 생성** (JavaScript 객체) + + ```javascript + { type: "div", props: { className: "box" }, children: ["Hello"] } + ``` + +2. **createElement 실행** + - `document.createElement("div")` 호출 + - `div.className = "box"` 속성 설정 + - `div.appendChild(textNode)` 자식 추가 + +3. **실제 DOM 생성** + + ```html +
Hello
+ ``` + +4. **DOM에 추가** + ```javascript + container.appendChild(div); + ``` + +**핵심**: VNode는 "설계도"이고, createElement는 "시공사"입니다. + +--- + +### Q5: 함수 호출은 누가 하나요? + +**A**: 우리가 설계한 시스템이 **명시적으로 호출**합니다. + +**전체 호출 체인**: + +``` +1. Store 변경 (productStore.dispatch) + ↓ +2. Store.notify() - 구독자에게 알림 + ↓ +3. render() - 구독했으므로 자동 호출 + ↓ +4. renderElement() - render 내부에서 명시적 호출 + ↓ +5. normalizeVNode() - renderElement 내부에서 호출 + ↓ +6. createElement() - renderElement 내부에서 호출 + ↓ +7. document.createElement() - 실제 DOM 생성 +``` + +**핵심**: 자동으로 호출되는 게 아니라, Observer 패턴으로 연결한 것입니다. + +--- + +### Q6: Virtual DOM과 실제 DOM의 차이는? + +**A**: Virtual DOM은 JavaScript 객체, 실제 DOM은 브라우저 API 객체입니다. + +**비교**: + +| 특징 | Virtual DOM | 실제 DOM | +| --------- | ---------------- | -------------------------- | +| 타입 | JavaScript 객체 | HTMLElement 객체 | +| 생성 | `createVNode()` | `document.createElement()` | +| 비용 | 매우 저렴 | 비쌈 (리플로우/리페인트) | +| 속도 | 빠름 (메모리) | 느림 (브라우저 렌더링) | +| 비교 | 쉬움 (객체 비교) | 어려움 (DOM API) | +| 화면 반영 | 안 됨 | 됨 | + +**전략**: Virtual DOM으로 변경사항 계산 → 최소한만 실제 DOM 반영 + +--- + +## 2. 이벤트 루프와 배치 처리 + +### Q1: withBatch는 왜 필요한가요? + +**A**: 같은 시점에 여러 Store가 변경되면 render가 여러 번 호출되는데, 이를 1번으로 줄이기 위해서입니다. + +**문제 상황**: + +```javascript +// 3개 Store가 render를 구독 +productStore.subscribe(render); +cartStore.subscribe(render); +uiStore.subscribe(render); + +// 동시에 변경 +productStore.dispatch({ ... }); // render() 호출 +cartStore.dispatch({ ... }); // render() 호출 +uiStore.dispatch({ ... }); // render() 호출 + +// 문제: render가 3번 실행되어 DOM 조작 3번 발생 +``` + +**해결**: + +```javascript +// withBatch로 render를 감쌈 +const render = withBatch(() => { + renderElement(...); +}); + +// 동시에 변경해도 +productStore.dispatch({ ... }); // render 예약 +cartStore.dispatch({ ... }); // 무시 (이미 예약됨) +uiStore.dispatch({ ... }); // 무시 (이미 예약됨) + +// 결과: render 1번만 실행 ✅ +``` + +--- + +### Q2: scheduled 플래그는 어떻게 작동하나요? + +**A**: 클로저를 이용해 "이미 예약되었는지" 추적합니다. + +**구현 핵심**: + +```javascript +export const withBatch = (fn) => { + let scheduled = false; // 클로저 변수 (공유됨) + + return (...args) => { + // 1차 방어: 이미 예약되었으면 무시 + if (scheduled) return; + + // 예약 표시 + scheduled = true; + + // Microtask에 등록 + queueMicrotask(() => { + scheduled = false; // 리셋 + fn(...args); // 실제 실행 + }); + }; +}; +``` + +**동작 타임라인**: + +``` +t=0.000ms: render() 첫 호출 + scheduled = false → true + queueMicrotask 등록 + +t=0.001ms: render() 두 번째 호출 + scheduled = true → return (무시) + +t=0.002ms: render() 세 번째 호출 + scheduled = true → return (무시) + +t=0.003ms: 동기 코드 종료 + +t=1ms: Microtask 실행 + scheduled = false (리셋) + 실제 render() 실행 ✅ +``` + +--- + +### Q3: queueMicrotask는 무엇이고 왜 사용하나요? + +**A**: 동기 코드가 끝난 직후 실행되는 작업을 예약하는 API입니다. + +**JavaScript 이벤트 루프 구조**: + +``` +1. Call Stack (동기 코드 실행) + ↓ +2. Microtask Queue ← queueMicrotask, Promise.then + ↓ +3. Render (화면 그리기) + ↓ +4. Macrotask Queue ← setTimeout, setInterval +``` + +**왜 queueMicrotask를 쓰나요?** + +**이유 1: 즉시 실행** + +- 동기 코드 끝나자마자 실행 (약 1ms 후) +- setTimeout(fn, 0)은 최소 4ms 지연 + +**이유 2: 배치 범위** + +- 같은 동기 실행 컨텍스트의 모든 변경사항 모음 +- setTimeout은 이미 scheduled가 false로 리셋되어 배치 안 됨 + +**비교 예시**: + +```javascript +// queueMicrotask 사용 ✅ +console.log("1. 시작"); +queueMicrotask(() => console.log("3. Microtask")); +console.log("2. 끝"); +// 출력: 1 → 2 → 3 + +// setTimeout 사용 ❌ +console.log("1. 시작"); +setTimeout(() => console.log("3. Timeout"), 0); +console.log("2. 끝"); +// 출력: 1 → 2 → 3 (더 느림, 최소 4ms) +``` + +--- + +### Q4: 모든 Store 변경이 render 전에 완료되나요? + +**A**: 네! **동기 코드에서 실행된 변경사항은 모두 완료**됩니다. + +**핵심 이해**: + +- Store의 `dispatch`는 **동기 함수** +- 상태 변경은 **즉시 완료** +- render는 **Microtask에서 실행** (나중에) + +**실행 순서**: + +```javascript +// === 동기 코드 (0ms) === +productStore.dispatch({ products: [...] }); +// → Store 상태 즉시 변경 ✅ +console.log(productStore.getState()); // 최신 상태! + +cartStore.dispatch({ items: [...] }); +// → Store 상태 즉시 변경 ✅ +console.log(cartStore.getState()); // 최신 상태! + +uiStore.dispatch({ toast: "완료" }); +// → Store 상태 즉시 변경 ✅ +console.log(uiStore.getState()); // 최신 상태! + +// === Microtask (1ms) === +render() + → 모든 Store의 최신 상태로 렌더링! 🎉 +``` + +**장점**: + +- ✅ 일관성: 모든 변경사항이 반영된 화면 +- ✅ 성능: 1번만 렌더링 +- ✅ 사용자 경험: 깜빡임 없음 + +--- + +### Q5: 비동기 작업 후에는 어떻게 되나요? + +**A**: **새로운 이벤트 루프**에서 다시 렌더링됩니다. + +**정상 동작**: + +```javascript +export const loadProducts = async () => { + // 1. 로딩 시작 (t=0ms, 동기 코드) + productStore.dispatch({ loading: true }); + // → render() 예약 + + // Microtask: render() 실행 (t=1ms) + // 화면: 로딩 스피너 표시 ✅ + + // 2. API 호출 (비동기, 500ms 소요) + const products = await getProducts(); + // ← 여기서 이벤트 루프 끊김! + + // 3. 데이터 로드 (t=501ms, 새로운 이벤트 루프!) + productStore.dispatch({ products, loading: false }); + // → render() 다시 예약 (scheduled는 이미 false로 리셋됨) + + // Microtask: render() 실행 (t=502ms) + // 화면: 상품 목록 표시 ✅ +}; + +// 결과: render 2번 실행 (정상!) +// - 로딩 스피너 +// - 상품 목록 +``` + +**왜 2번 렌더링되나요?** + +- 비동기 작업(await, setTimeout 등) 후는 **다른 이벤트 루프** +- scheduled 플래그는 이미 false로 리셋됨 +- 새로운 렌더링이 필요함 (사용자에게 단계별 피드백) + +**이것은 버그가 아니라 의도된 동작입니다!** + +--- + +### Q6: 배치 처리가 안 되는 경우는? + +**A**: 다른 이벤트 루프에서 실행되는 경우입니다. + +**배치 처리 O (같은 이벤트 루프)**: + +- ✅ 동기 코드에서 연속 호출 +- ✅ 같은 이벤트 핸들러 내부 +- ✅ 같은 함수 스코프 + +**배치 처리 X (다른 이벤트 루프)**: + +- ❌ `setTimeout` / `setInterval` 콜백 +- ❌ `async/await` 이후 +- ❌ API 응답 콜백 +- ❌ 사용자의 별도 클릭/입력 이벤트 + +**예시**: + +```javascript +// ✅ 배치 처리됨 +button.addEventListener("click", () => { + store1.dispatch(); // 같은 이벤트 루프 + store2.dispatch(); // 같은 이벤트 루프 + // → render() 1번 +}); + +// ❌ 배치 처리 안 됨 +store1.dispatch(); // 첫 번째 이벤트 루프 +setTimeout(() => { + store2.dispatch(); // 두 번째 이벤트 루프 (새로운 턴!) +}, 100); +// → render() 2번 +``` + +--- + +### Q7: React의 Batching과 차이점은? + +**A**: 기본 원리는 같지만, React는 더 정교합니다. + +**공통점**: + +- 같은 이벤트 루프에서 여러 상태 변경 → 1번 렌더링 +- Microtask 활용 +- 비동기 이후는 별도 렌더링 + +**차이점**: + +| 특징 | 우리 구현 | React 18+ | +| ----------------- | ---------------- | -------------------- | +| 기본 배치 | 같은 이벤트 루프 | Automatic Batching | +| setTimeout 내부 | 배치 안 됨 | 배치 됨 | +| Promise.then 내부 | 배치 안 됨 | 배치 됨 | +| 구현 방식 | queueMicrotask | 자체 스케줄러 | +| 우선순위 | 없음 | Concurrent Mode 지원 | + +**React 18의 Automatic Batching**: + +- 어디서든 상태 변경을 자동으로 배치 +- Concurrent Mode로 더 정교한 스케줄링 +- 우선순위 기반 렌더링 + +**우리 구현**: + +- 교육용 단순화 버전 +- 핵심 원리는 동일 +- React의 기본 동작 이해에 충분 + +--- + +## 보충 개념 + +### Virtual DOM의 2단계 방어 시스템 + +**1차 방어: withBatch** + +- 같은 이벤트 루프에서 중복 호출 방지 +- queueMicrotask로 배치 처리 + +**2차 방어: Diffing** + +- 설령 render가 여러 번 호출되어도 +- Virtual DOM 비교로 변경된 부분만 업데이트 +- 불필요한 DOM 조작 방지 + +**시너지**: + +```javascript +// 1차 방어 실패 (비동기로 여러 번 호출) +render() // t=0ms +render() // t=100ms + +// 2차 방어 작동 +renderElement(newVNode, container) + → updateElement (Diffing) + → 변경 없으면 DOM 조작 안 함! +``` + +--- + +### 성능 측정 + +**배치 처리 효과**: + +```javascript +// withBatch 없이 +for (let i = 0; i < 100; i++) { + store.dispatch(); // render 100번 +} +// → DOM 조작 100번, 매우 느림 😱 + +// withBatch 사용 +for (let i = 0; i < 100; i++) { + store.dispatch(); // render 예약 1번 +} +// → DOM 조작 1번, 빠름! 🎉 +``` + +**Diffing 효과**: + +```javascript +// Virtual DOM 없이 +store.dispatch(); // 전체 DOM 재생성 + +// Virtual DOM 사용 +store.dispatch(); + → Diffing으로 변경된 부분만 업데이트 + → 10개 중 1개만 변경 → 10배 빠름 +``` + +--- + +### 디버깅 팁 + +**1. render 호출 횟수 확인**: + +```javascript +let renderCount = 0; +const render = withBatch(() => { + console.log(`render 호출 #${++renderCount}`); + renderElement(...); +}); +``` + +**2. Store 변경 추적**: + +```javascript +const originalDispatch = store.dispatch; +store.dispatch = (action) => { + console.log("Store 변경:", action); + originalDispatch(action); +}; +``` + +**3. scheduled 상태 확인**: + +```javascript +// withBatch에 디버깅 추가 +if (scheduled) { + console.log("render 무시됨 (이미 예약됨)"); + return; +} +console.log("render 예약됨"); +``` + +--- + +## 정리 + +### Virtual DOM 핵심 3가지 + +1. **변환**: JSX → VNode (빌드 타임) +2. **비교**: Diffing으로 변경 감지 (런타임) +3. **최적화**: Batching으로 중복 제거 (런타임) + +### 이벤트 루프 핵심 3가지 + +1. **동기 코드**: Store 변경은 즉시 완료 +2. **Microtask**: render는 나중에 실행 +3. **배치 범위**: 같은 이벤트 루프만 해당 + +### 실무 적용 + +- React, Vue 등 모던 프레임워크의 기본 원리 +- 성능 최적화의 핵심 패턴 +- 상태 관리 라이브러리 설계 기반 + +**프레임워크를 이해하는 가장 좋은 방법은 직접 만들어보는 것입니다!** 🎉 + +--- + +## 3. 브라우저 API와 DocumentFragment + +### Q1: JavaScript와 브라우저 API는 다른 건가요? + +**A:** 네! **JavaScript 언어**와 **브라우저가 제공하는 API**는 별개입니다. + +**구조도:** + +``` +JavaScript 언어 (ECMAScript) + ├─ 기본 문법 (if, for, function, class 등) + ├─ 내장 객체 (Array, Object, String, Number 등) + └─ 내장 API (Math, JSON, Promise 등) + +브라우저 환경 (Web API) + ├─ DOM API + │ ├─ document.createElement() ← Virtual DOM에서 사용 + │ ├─ document.createDocumentFragment() ← 배열 처리에 사용 + │ ├─ document.getElementById() + │ ├─ element.appendChild() + │ └─ element.addEventListener() + ├─ BOM (Browser Object Model) + │ ├─ window + │ ├─ location + │ ├─ history + │ └─ navigator + └─ 기타 Web API + ├─ fetch() + ├─ localStorage + ├─ setTimeout() + └─ requestAnimationFrame() +``` + +**핵심 차이:** + +- **JavaScript 언어**: 어디서든 동작 (브라우저, Node.js, Deno 등) +- **브라우저 API**: 브라우저에서만 사용 가능 + +**확인 방법:** + +```javascript +// 브라우저 +console.log(typeof document); // "object" ✅ + +// Node.js +console.log(typeof document); // "undefined" ❌ +``` + +--- + +### Q2: DocumentFragment는 무엇이고 왜 사용하나요? + +**A:** **DocumentFragment는 일회용 임시 컨테이너**입니다. + +**정의:** + +- 여러 DOM 요소를 담을 수 있는 가벼운 컨테이너 +- 실제 DOM 트리에 속하지 않음 (메모리에만 존재) +- DOM에 추가하면 자동으로 사라지고 자식들만 이동 + +**생명주기:** + +```javascript +// 1. 생성 +const fragment = document.createDocumentFragment(); +console.log(fragment.childNodes.length); // 0 + +// 2. 요소 추가 (메모리에서만) +fragment.appendChild(div1); +fragment.appendChild(div2); +console.log(fragment.childNodes.length); // 2 + +// 3. DOM에 추가 +container.appendChild(fragment); +// → Fragment는 사라지고 자식들만 container로 이동! + +// 4. Fragment는 비어있음 +console.log(fragment.childNodes.length); // 0 +console.log(container.childNodes.length); // 2 +``` + +--- + +### Q3: 일반 div와 DocumentFragment의 차이는? + +**A:** Fragment는 **wrapper 없이** 여러 요소를 추가할 수 있습니다. + +**비교:** + +```javascript +// ❌ 일반 div 사용 +const wrapper = document.createElement("div"); +wrapper.appendChild(div1); +wrapper.appendChild(div2); +container.appendChild(wrapper); + +// 결과 +
+
+ {" "} + ← 불필요한 wrapper! +
항목 1
+
항목 2
+
+
; + +// ✅ DocumentFragment 사용 +const fragment = document.createDocumentFragment(); +fragment.appendChild(div1); +fragment.appendChild(div2); +container.appendChild(fragment); + +// 결과 +
+
항목 1
← 깔끔! +
항목 2
+
; +``` + +--- + +### Q4: DocumentFragment의 성능 이점은? + +**A:** 여러 DOM 조작을 **한 번에 처리**하여 Reflow를 최소화합니다. + +**성능 비교:** + +```javascript +// ❌ 비효율적: 1000번의 Reflow +for (let i = 0; i < 1000; i++) { + container.appendChild(document.createElement("div")); + // 매번 화면 다시 그림! 😱 +} + +// ✅ 효율적: 1번의 Reflow +const fragment = document.createDocumentFragment(); +for (let i = 0; i < 1000; i++) { + fragment.appendChild(document.createElement("div")); + // 메모리에서만 작업 +} +container.appendChild(fragment); // 한 번에 추가! 🎉 +``` + +**측정 결과 (1000개 요소 추가 시):** + +- 개별 추가: ~100ms +- Fragment 사용: ~10ms +- **10배 빠름!** + +--- + +### Q5: Virtual DOM 구현에서 어떻게 사용하나요? + +**A:** **배열 형태의 VNode**를 처리할 때 사용합니다. + +**사용 시나리오:** + +```jsx +// React Fragment 패턴 +<> +
첫 번째
+
두 번째
+
세 번째
+; + +// 배열 형태로 변환 +createElement([ + { type: "div", children: ["첫 번째"] }, + { type: "div", children: ["두 번째"] }, + { type: "div", children: ["세 번째"] }, +]); + +// → DocumentFragment 생성 +// → wrapper div 없이 3개 요소 추가 +``` + +**createElement 구현:** + +- 배열 감지 시 DocumentFragment 생성 +- 각 VNode를 재귀적으로 처리 +- Fragment에 모든 요소 추가 후 반환 +- 실제 DOM에 추가되면 Fragment는 사라지고 요소들만 남음 + +--- + +### Q6: DocumentFragment의 특징 정리 + +**주요 특징:** + +| 특징 | 설명 | +| ------------ | ----------------------------------- | +| nodeType | 11 (DOCUMENT_FRAGMENT_NODE) | +| parentNode | 항상 null (DOM 트리에 속하지 않음) | +| 추가 후 상태 | 비어있음 (자식들이 이동됨) | +| wrapper | 추가되지 않음 (자식들만 추가) | +| 성능 | 여러 요소 일괄 처리로 Reflow 최소화 | +| 재사용 | 불가 (일회용) | +| 사용 목적 | 여러 요소를 효율적으로 추가 | + +**비유:** + +- **택배 상자**: 물건을 담아서 배송, 도착하면 상자는 버리고 물건만 꺼냄 +- **임시 작업장**: 작업 완료 후 결과물만 남기고 작업장은 철거 +- **버스**: 승객을 태워서 목적지에 내려주고 버스는 돌아감 + +--- + +## 추가 학습 자료 + +- **React 공식 문서**: https://react.dev +- **Virtual DOM 이해하기**: https://react.dev/learn/preserving-and-resetting-state +- **Reconciliation**: https://react.dev/learn/reconciliation +- **JSX 변환**: https://babeljs.io/docs/babel-plugin-transform-react-jsx +- **이벤트 루프**: https://developer.mozilla.org/ko/docs/Web/JavaScript/Event_loop +- **DocumentFragment**: https://developer.mozilla.org/ko/docs/Web/API/DocumentFragment diff --git a/docs/VIRTUAL_DOM_GUIDE.md b/docs/VIRTUAL_DOM_GUIDE.md new file mode 100644 index 00000000..cca51309 --- /dev/null +++ b/docs/VIRTUAL_DOM_GUIDE.md @@ -0,0 +1,366 @@ +# Virtual DOM 구현 가이드 + +React처럼 동작하는 Virtual DOM 시스템의 핵심 개념과 구현 원리를 정리한 문서입니다. + +## 📚 목차 + +1. [Virtual DOM이란?](#virtual-dom이란) +2. [핵심 모듈](#핵심-모듈) +3. [전체 동작 흐름](#전체-동작-흐름) +4. [성능 최적화 전략](#성능-최적화-전략) + +--- + +## Virtual DOM이란? + +### 개념 + +Virtual DOM은 실제 DOM의 경량화된 JavaScript 객체 표현입니다. 상태가 변경되면 새로운 Virtual DOM을 생성하고, 이전 Virtual DOM과 비교(Diffing)하여 변경된 부분만 실제 DOM에 반영합니다. + +- **VNode (Virtual Node)**: DOM 요소를 나타내는 JavaScript 객체 +- **Diffing**: 이전 VNode와 새 VNode를 비교하는 알고리즘 +- **Reconciliation**: 변경된 부분만 실제 DOM에 반영하는 과정 + +### 왜 사용하나? + +**문제점**: 실제 DOM 조작은 비용이 높습니다. + +- 매번 DOM을 조작하면 리플로우/리페인트 발생 +- 여러 곳에서 동시에 DOM 변경 시 비효율적 + +**해결책**: Virtual DOM으로 변경사항을 모아서 한 번에 처리 + +- 변경된 부분만 찾아서 업데이트 +- 여러 변경사항을 배치 처리 + +### 주요 장점 + +1. **성능**: 최소한의 DOM 조작 +2. **선언적 UI**: 상태 기반으로 UI 자동 업데이트 +3. **추상화**: 플랫폼 독립적 (DOM, Native 등) + +--- + +## 전체 아키텍처 + +``` +┌─────────────────────────────────────────────────┐ +│ Application │ +│ (Components, State, User Interactions) │ +└─────────────────┬───────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────┐ +│ JSX → VNode 변환 │ +│ (createVNode - Babel/ESBuild) │ +└─────────────────┬───────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────┐ +│ VNode 정규화 │ +│ (normalizeVNode - 컴포넌트 실행) │ +└─────────────────┬───────────────────────────────┘ + │ + ↓ +┌─────────────────────────────────────────────────┐ +│ 렌더링 판단 │ +│ (renderElement - 최초 vs 업데이트) │ +└─────┬───────────────────────────────────┬───────┘ + │ │ + ↓ (최초) ↓ (업데이트) +┌─────────────────┐ ┌──────────────────┐ +│ createElement │ │ updateElement │ +│ (DOM 생성) │ │ (Diffing) │ +└─────────────────┘ └──────────────────┘ + │ │ + └────────────────┬──────────────────┘ + ↓ + ┌─────────────────┐ + │ 실제 DOM 반영 │ + └─────────────────┘ +``` + +--- + +## 핵심 모듈 + +### 1. createVNode - VNode 생성 + +JSX를 JavaScript 객체(VNode)로 변환하는 함수입니다. + +**동작**: + +- Babel/ESBuild가 컴파일 타임에 JSX를 이 함수 호출로 변환 +- HTML 태그명(문자열) 또는 컴포넌트(함수)를 type으로 저장 +- props와 children을 객체로 구조화 + +**예시**: + +```javascript +// JSX +
Hello
+ +// 변환 결과 +{ type: "div", props: { className: "box" }, children: ["Hello"] } +``` + +--- + +### 2. normalizeVNode - VNode 정규화 + +다양한 타입의 VNode를 일관된 형태로 변환합니다. + +**처리 내용**: + +- `null`, `undefined`, `boolean` → 빈 문자열 +- 문자열/숫자 → 그대로 (텍스트 노드) +- 배열 → 평탄화 +- **함수형 컴포넌트 → 실행하여 실제 VNode로 변환** + +**핵심**: 컴포넌트 함수를 호출하여 최종 HTML VNode로 만듭니다. + +--- + +### 3. createElement - VNode → 실제 DOM + +정규화된 VNode를 실제 DOM 요소로 생성합니다. + +**처리 내용**: + +- **배열 VNode**: `DocumentFragment` 생성 (wrapper 없이 여러 요소 추가) +- 텍스트 노드: `document.createTextNode()` 생성 +- HTML 요소: `document.createElement()` 생성 +- 속성 설정: `className`, `style`, 이벤트 리스너 등 +- 자식 요소: 재귀적으로 생성하여 추가 + +**특징**: 최초 렌더링 시에만 사용됩니다. + +**DocumentFragment 활용**: + +- React Fragment(`<>...`) 패턴 지원 +- 브라우저 DOM API의 일회용 임시 컨테이너 +- DOM에 추가되면 자동으로 사라지고 자식들만 남음 +- 여러 요소를 일괄 추가하여 Reflow 최소화 (10배 빠름) + +--- + +### 4. updateElement - Diffing 알고리즘 + +이전 VNode와 새 VNode를 비교하여 변경된 부분만 실제 DOM에 반영합니다. + +**Diffing 전략**: + +1. **노드 삭제**: 새 VNode 없음 → DOM에서 제거 +2. **노드 추가**: 이전 VNode 없음 → 새 DOM 생성 +3. **노드 교체**: 타입 변경 (div → span) → DOM 교체 +4. **텍스트 변경**: textContent만 수정 +5. **속성 업데이트**: 같은 타입이면 속성만 변경 +6. **자식 재귀**: 모든 자식에 대해 재귀적으로 diffing +7. **남은 자식 삭제**: 자식이 줄어들면 뒤에서부터 제거 + +**핵심**: 최소한의 DOM 조작으로 효율성 확보 + +--- + +### 5. renderElement - 렌더링 메인 로직 + +전체 렌더링 프로세스를 관리합니다. + +**처리 흐름**: + +1. VNode 정규화 +2. 이전 VNode 확인 (WeakMap에 저장됨) +3. 최초 렌더링 → `createElement` 사용 +4. 재렌더링 → `updateElement` 사용 (Diffing) +5. 현재 VNode 저장 (다음 비교용) +6. 이벤트 리스너 설정 + +**특징**: WeakMap으로 이전 VNode를 관리하여 메모리 누수 방지 + +--- + +## 전체 동작 흐름 + +### 1. 애플리케이션 시작 + +``` +main.js 실행 + ↓ +initRender() - Store 구독 설정 + ↓ +router.start() - 라우팅 시작 + ↓ +render() 최초 호출 +``` + +### 2. JSX 변환 (빌드 타임) + +``` +개발자 작성 (JSX) + ↓ +Vite/ESBuild 컴파일 + ↓ +createVNode() 함수 호출로 변환 + ↓ +브라우저는 순수 JavaScript만 실행 +``` + +**중요**: JSX는 브라우저로 전달되지 않습니다. 빌드 시점에 완전히 제거됩니다. + +### 3. 렌더링 프로세스 + +``` +Store/Router 변경 + ↓ +notify() - 구독자에게 알림 + ↓ +render() 호출 (withBatch로 중복 방지) + ↓ +renderElement() 실행 + ↓ +normalizeVNode() - 컴포넌트 함수 실행 + ↓ +최초 렌더링: createElement() +재렌더링: updateElement() (Diffing) + ↓ +실제 DOM 반영 +``` + +### 4. 상태 업데이트 흐름 + +``` +사용자 액션 / API 응답 + ↓ +Store.dispatch() - 상태 즉시 변경 + ↓ +Store.notify() - 구독자에게 알림 + ↓ +withBatch - 중복 호출 방지 + ↓ +queueMicrotask - 동기 코드 끝난 후 실행 + ↓ +render() - 모든 Store 최신 상태 반영 + ↓ +Virtual DOM Diffing + ↓ +변경된 부분만 DOM 업데이트 +``` + +--- + +## 성능 최적화 전략 + +### 1. 배치 처리 (withBatch) + +**목적**: 같은 이벤트 루프에서 여러 번 render 호출 시 1번만 실행 + +**구현 원리**: + +- `scheduled` 플래그로 실행 여부 추적 +- `queueMicrotask`로 동기 코드 완료 후 실행 +- 중복 호출은 즉시 무시 + +**효과**: + +- 여러 Store가 동시에 변경되어도 1번만 렌더링 +- React의 Automatic Batching과 동일한 방식 + +**적용 범위**: + +- ✅ 같은 동기 실행 컨텍스트 +- ✅ 같은 이벤트 핸들러 내부 +- ❌ 비동기 콜백 (setTimeout, API 응답 등) + +### 2. Virtual DOM Diffing + +**목적**: 변경된 부분만 DOM 업데이트 + +**최적화 포인트**: + +- 타입이 다르면 즉시 교체 (불필요한 비교 생략) +- 텍스트 노드는 textContent만 변경 +- 속성은 변경된 것만 업데이트 +- 자식이 줄어들면 뒤에서부터 제거 + +**효과**: 불필요한 DOM 조작 최소화 + +### 3. WeakMap 사용 + +**목적**: 이전 VNode 저장 및 메모리 관리 + +**장점**: + +- 컨테이너를 키로 사용 +- 컨테이너가 제거되면 VNode도 자동 GC +- 메모리 누수 방지 + +### 4. 이벤트 위임 + +**목적**: 이벤트 리스너 효율적 관리 + +**구현**: + +- 각 요소에 개별 리스너 등록하지 않음 +- 상위 요소에서 이벤트 캡처 후 위임 +- 동적 요소 추가/제거 시에도 동작 + +--- + +## 핵심 개념 정리 + +### Virtual DOM의 장점 + +1. **성능**: 변경 감지 및 최소 업데이트 +2. **추상화**: 플랫폼 독립적 (웹, 모바일 등) +3. **선언적**: 상태만 관리하면 UI 자동 업데이트 +4. **배치 처리**: 여러 변경사항을 모아서 처리 + +### 제약사항 + +1. **메모리**: VNode 객체 생성 비용 +2. **초기 렌더링**: 첫 렌더링은 직접 DOM 조작과 유사 +3. **복잡도**: 간단한 UI는 오히려 오버헤드 + +### React와의 차이점 + +**유사점**: + +- Virtual DOM 개념 +- Diffing 알고리즘 +- Batching 메커니즘 +- 함수형 컴포넌트 + +**차이점**: + +- React는 Fiber 아키텍처 (더 정교한 스케줄링) +- React는 key 기반 diffing 지원 +- React는 Concurrent Mode, Suspense 등 고급 기능 +- 우리 구현은 교육용 단순화 버전 + +--- + +## 학습 포인트 + +### 이 구현을 통해 배울 수 있는 것 + +1. **프레임워크 내부 동작**: React가 어떻게 작동하는지 이해 +2. **성능 최적화**: Batching, Diffing의 중요성 +3. **이벤트 루프**: JavaScript 비동기 처리 메커니즘 +4. **설계 패턴**: Observer, Factory, Reconciliation + +### 실무 적용 + +- Virtual DOM 개념은 React, Vue, Preact 등에서 공통적으로 사용 +- Diffing 알고리즘은 데이터 동기화, 버전 관리 등에도 활용 +- Batching 패턴은 성능 최적화의 핵심 기법 + +--- + +## 참고 자료 + +- **React 공식 문서**: https://react.dev +- **Virtual DOM 개념**: https://react.dev/learn/preserving-and-resetting-state +- **Reconciliation**: https://react.dev/learn/reconciliation +- **JSX Transform**: https://babeljs.io/docs/babel-plugin-transform-react-jsx +- **JavaScript 이벤트 루프**: https://developer.mozilla.org/ko/docs/Web/JavaScript/Event_loop +- **DocumentFragment API**: https://developer.mozilla.org/ko/docs/Web/API/DocumentFragment diff --git a/packages/app/src/pages/PageWrapper.jsx b/packages/app/src/pages/PageWrapper.jsx index 6fb6b877..ed9e314b 100644 --- a/packages/app/src/pages/PageWrapper.jsx +++ b/packages/app/src/pages/PageWrapper.jsx @@ -10,7 +10,7 @@ const close = () => { export const PageWrapper = ({ headerLeft, children }) => { const cart = cartStore.getState(); const { cartModal, toast } = uiStore.getState(); - const cartSize = cart.items.length; + const cartSize = cart?.items?.length ?? 0; const cartCount = useMemo( () => ( diff --git a/packages/react/src/core/context.ts b/packages/react/src/core/context.ts index bf41d61d..958bdded 100644 --- a/packages/react/src/core/context.ts +++ b/packages/react/src/core/context.ts @@ -13,8 +13,10 @@ export const context: Context = { node: null, instance: null, reset({ container, node }) { - // 여기를 구현하세요. // container, node, instance를 전달받은 값으로 초기화합니다. + this.container = container; + this.node = node; + this.instance = null; }, }, @@ -32,36 +34,44 @@ export const context: Context = { * 모든 훅 관련 상태를 초기화합니다. */ clear() { - // 여기를 구현하세요. // state, cursor, visited, componentStack을 모두 비웁니다. + this.state.clear(); + this.cursor.clear(); + this.visited.clear(); + this.componentStack.length = 0; }, /** * 현재 실행 중인 컴포넌트의 고유 경로를 반환합니다. */ 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; + const path = this.currentPath; + return this.cursor.get(path) ?? 0; }, /** * 현재 컴포넌트의 훅 상태 배열을 반환합니다. */ get currentHooks() { - // 여기를 구현하세요. // state Map에서 현재 경로의 훅 배열을 가져옵니다. 없으면 빈 배열을 반환합니다. - return []; + const path = this.currentPath; + if (!this.state.has(path)) { + this.state.set(path, []); + } + return this.state.get(path)!; }, }, @@ -71,4 +81,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..afbee391 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,45 @@ import { Instance } from "./types"; * 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다. */ export const setDomProps = (dom: HTMLElement, props: Record): void => { - // 여기를 구현하세요. + for (const key in props) { + if (key === "children") continue; + if (key.startsWith("on") && typeof props[key] === "function") { + dom.addEventListener(key.slice(2).toLowerCase(), props[key]); + } else if (key === "className") { + dom.className = props[key] ?? ""; + } else if (key === "style" && typeof props[key] === "object" && props[key] !== null) { + diffStyle(dom, {}, props[key]); + continue; + } else if (["checked", "disabled", "readOnly", "selected"].includes(key)) { + (dom as any)[key] = !!props[key]; + } else if (key === "value") { + (dom as any).value = props[key] ?? ""; + } else { + dom.setAttribute(key, props[key]); + } + } +}; + +type StyleMap = Record; + +const diffStyle = (dom: HTMLElement, prev?: StyleMap, next?: StyleMap) => { + if (prev === next) return; + prev = prev || {}; + next = next || {}; + // 제거 + for (const key in prev) { + if (!(key in next)) { + dom.style[key as any] = ""; + } + } + // 추가/업데이트 + applyStyle(dom, next); +}; + +const applyStyle = (dom: HTMLElement, style: StyleMap = {}) => { + for (const key in style) { + dom.style[key as any] = style[key] == null ? "" : String(style[key]); + } }; /** @@ -19,7 +57,89 @@ export const updateDomProps = ( prevProps: Record = {}, nextProps: Record = {}, ): void => { - // 여기를 구현하세요. + const prevStyle = prevProps.style && typeof prevProps.style === "object" ? (prevProps.style as StyleMap) : undefined; + const nextStyle = nextProps.style && typeof nextProps.style === "object" ? (nextProps.style as StyleMap) : undefined; + // 제거/변경 + for (const key in prevProps) { + if (key === "children") continue; + const prevValue = prevProps[key]; + const nextValue = nextProps[key]; + + if (key === "style") { + if (prevStyle && !nextStyle) { + diffStyle(dom, prevStyle, {}); + } + continue; + } + + if (key.startsWith("on") && typeof prevValue === "function") { + if (prevValue !== nextValue) { + dom.removeEventListener(key.slice(2).toLowerCase(), prevValue); + } + continue; + } + + if (!(key in nextProps)) { + if (key === "className") { + dom.className = ""; + } else if (["checked", "disabled", "readOnly", "selected"].includes(key)) { + (dom as any)[key] = false; + dom.removeAttribute(key); + } else if (key === "value") { + (dom as any).value = ""; + } else { + dom.removeAttribute(key); + } + } + } + + if (nextStyle) { + diffStyle(dom, prevStyle ?? {}, nextStyle); + } + + // 추가/업데이트 + for (const key in nextProps) { + if (key === "children" || key === "style") continue; + const value = nextProps[key]; + + if (key.startsWith("on") && typeof value === "function") { + const prevValue = prevProps[key]; + if (prevValue !== value) { + if (typeof prevValue === "function") { + dom.removeEventListener(key.slice(2).toLowerCase(), prevValue); + } + dom.addEventListener(key.slice(2).toLowerCase(), value); + } + continue; + } + + if (key === "className") { + dom.className = value ?? ""; + continue; + } + + if (["checked", "disabled", "readOnly", "selected"].includes(key)) { + const boolValue = !!value; + (dom as any)[key] = boolValue; + if (boolValue) { + dom.setAttribute(key, ""); + } else { + dom.removeAttribute(key); + } + continue; + } + + if (key === "value") { + (dom as any).value = value ?? ""; + continue; + } + + if (value == null) { + dom.removeAttribute(key); + } else { + dom.setAttribute(key, String(value)); + } + } }; /** @@ -27,7 +147,19 @@ export const updateDomProps = ( * Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다. */ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => { - // 여기를 구현하세요. + if (!instance) return []; + if (instance.kind === NodeTypes.FRAGMENT && instance.children) { + return instance.children.flatMap(getDomNodes); + } + if (instance.kind === NodeTypes.TEXT && instance.dom) { + return [instance.dom]; + } + if (instance.kind === NodeTypes.HOST && instance.dom) { + return [instance.dom]; + } + if (instance.children) { + return instance.children.flatMap(getDomNodes); + } return []; }; @@ -35,15 +167,18 @@ export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] = * 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => { - // 여기를 구현하세요. - return null; + const nodes = getDomNodes(instance); + return nodes.length > 0 ? nodes[0] : null; }; /** * 자식 인스턴스들로부터 첫 번째 실제 DOM 노드를 찾습니다. */ export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => { - // 여기를 구현하세요. + for (const child of children) { + const dom = getFirstDom(child); + if (dom) return dom; + } return null; }; @@ -56,12 +191,24 @@ export const insertInstance = ( instance: Instance | null, anchor: HTMLElement | Text | null = null, ): void => { - // 여기를 구현하세요. + const nodes = getDomNodes(instance); + for (const node of nodes) { + if (anchor) { + parentDom.insertBefore(node, anchor); + } else { + parentDom.appendChild(node); + } + } }; /** * 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다. */ export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => { - // 여기를 구현하세요. + const nodes = getDomNodes(instance); + for (const node of nodes) { + if (parentDom.contains(node)) { + parentDom.removeChild(node); + } + } }; diff --git a/packages/react/src/core/elements.ts b/packages/react/src/core/elements.ts index d04bce98..6912ac4d 100644 --- a/packages/react/src/core/elements.ts +++ b/packages/react/src/core/elements.ts @@ -3,21 +3,95 @@ import { isEmptyValue } from "../utils"; import { VNode } from "./types"; import { Fragment, TEXT_ELEMENT } from "./constants"; +const isVNode = (value: unknown): value is VNode => { + return ( + typeof value === "object" && + value !== null && + "type" in (value as Record) && + "props" in (value as Record) + ); +}; + +const flattenChildren = (children: unknown[]): unknown[] => { + const result: unknown[] = []; + for (const child of children) { + if (Array.isArray(child)) { + result.push(...flattenChildren(child)); + } else { + result.push(child); + } + } + return result; +}; + +const normalizeChildren = (children: unknown[]): VNode[] => { + return flattenChildren(children) + .map((child) => normalizeNode(child as VNode)) + .filter((child): child is VNode => child != null); +}; + /** * 주어진 노드를 VNode 형식으로 정규화합니다. * null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다. */ export const normalizeNode = (node: VNode): VNode | null => { - // 여기를 구현하세요. - return null; + if (isEmptyValue(node)) { + return null; + } + + if (typeof node === "string" || typeof node === "number") { + return createTextElement(node); + } + + if (Array.isArray(node)) { + const children = normalizeChildren(node); + return children.length + ? { + type: Fragment, + key: null, + props: { children }, + } + : null; + } + + if (!isVNode(node)) { + return null; + } + + const hasChildrenProp = node.props && Object.prototype.hasOwnProperty.call(node.props, "children"); + const childProp = hasChildrenProp ? node.props.children : undefined; + const childrenArray = childProp === undefined ? [] : Array.isArray(childProp) ? childProp : [childProp]; + const children = normalizeChildren(childrenArray); + + if (node.type === Fragment) { + return children.length + ? { + type: Fragment, + key: node.key ?? null, + props: { ...node.props, children }, + } + : null; + } + + return { + ...node, + key: node.key ?? null, + props: { + ...node.props, + children, + }, + }; }; /** * 텍스트 노드를 위한 VNode를 생성합니다. */ const createTextElement = (node: VNode): VNode => { - // 여기를 구현하세요. - return {} as VNode; + return { + type: TEXT_ELEMENT, + key: null, + props: { nodeValue: String(node), children: [] }, + }; }; /** @@ -29,7 +103,19 @@ export const createElement = ( originProps?: Record | null, ...rawChildren: any[] ) => { - // 여기를 구현하세요. + const props = originProps ?? {}; + const { key, children: propsChildren, ...rest } = props; + const childCandidates = rawChildren.length > 0 ? rawChildren : propsChildren !== undefined ? [propsChildren] : []; + const normalizedChildren = normalizeChildren(childCandidates); + + return { + type, + key: key != null ? key : null, + props: { + ...rest, + ...(normalizedChildren.length > 0 ? { children: normalizedChildren } : {}), + }, + }; }; /** @@ -43,6 +129,15 @@ export const createChildPath = ( nodeType?: string | symbol | React.ComponentType, siblings?: VNode[], ): string => { - // 여기를 구현하세요. - return ""; + const inferredIndex = + key != null || !siblings ? index : siblings.slice(0, index).filter((sibling) => sibling?.type === nodeType).length; + const childId = key != null ? `key:${key}` : `idx:${inferredIndex}`; + const typeStr = + typeof nodeType === "string" + ? nodeType + : typeof nodeType === "function" + ? nodeType.name || "Component" + : String(nodeType); + + return `${parentPath}/${typeStr}/${childId}`; }; diff --git a/packages/react/src/core/hooks.ts b/packages/react/src/core/hooks.ts index ef35d0f6..fd859560 100644 --- a/packages/react/src/core/hooks.ts +++ b/packages/react/src/core/hooks.ts @@ -1,4 +1,4 @@ -import { shallowEquals, withEnqueue } from "../utils"; +import { shallowEquals } from "../utils"; import { context } from "./context"; import { EffectHook } from "./types"; import { enqueueRender } from "./render"; @@ -8,7 +8,20 @@ import { HookTypes } from "./constants"; * 사용되지 않는 컴포넌트의 훅 상태와 이펙트 클린업 함수를 정리합니다. */ export const cleanupUnusedHooks = () => { - // 여기를 구현하세요. + for (const path of context.hooks.state.keys()) { + if (!context.hooks.visited.has(path)) { + const hooks = context.hooks.state.get(path); + if (hooks) { + for (const hook of hooks) { + if (hook && typeof hook === "object" && "kind" in hook && (hook as EffectHook).kind === HookTypes.EFFECT) { + (hook as EffectHook).cleanup?.(); + } + } + } + context.hooks.state.delete(path); + context.hooks.cursor.delete(path); + } + } }; /** @@ -20,12 +33,40 @@ export const useState = (initialValue: T | (() => T)): [T, (nextValue: T | (( // 여기를 구현하세요. // 1. 현재 컴포넌트의 훅 커서와 상태 배열을 가져옵니다. // 2. 첫 렌더링이라면 초기값으로 상태를 설정합니다. - // 3. 상태 변경 함수(setter)를 생성합니다. + // 3. 상태 변경 함수(setter)를 생성합ㅇ니다. // - 새 값이 이전 값과 같으면(Object.is) 재렌더링을 건너뜁니다. // - 값이 다르면 상태를 업데이트하고 재렌더링을 예약(enqueueRender)합니다. // 4. 훅 커서를 증가시키고 [상태, setter]를 반환합니다. - const setState = (nextValue: T | ((prev: T) => T)) => {}; - return [initialValue as T, setState]; + + const path = context.hooks.currentPath; + const cursor = context.hooks.currentCursor; + const hooks = context.hooks.currentHooks; + const currentIndex = cursor; + + const setState = (nextValue: T | ((prev: T) => T)) => { + // 새 값 계산 + const newValue = + typeof nextValue === "function" ? (nextValue as (prev: T) => T)(hooks[currentIndex] as T) : nextValue; + + // 같으면 무시! + if (Object.is(hooks[currentIndex], newValue)) { + return; + } + + // 다르면 업데이트! + hooks[currentIndex] = newValue; + + //리렌더링 예약! + enqueueRender(); + }; + + // 첫 렌더링이면 초기화! + if (hooks[currentIndex] === undefined) { + hooks[currentIndex] = typeof initialValue === "function" ? (initialValue as () => T)() : initialValue; + } + + context.hooks.cursor.set(path, currentIndex + 1); + return [hooks[currentIndex] as T, setState]; }; /** @@ -34,9 +75,33 @@ 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. 예약된 이펙트는 렌더링이 끝난 후 비동기로 실행됩니다. + + const path = context.hooks.currentPath; + const currentIndex = context.hooks.currentCursor; + const hooks = context.hooks.currentHooks; + + const prevHooks = hooks[currentIndex] as EffectHook | undefined; + + const depsChanged = !prevHooks || deps === undefined || !shallowEquals(prevHooks.deps, deps); + if (depsChanged) { + context.effects.queue.push(() => { + prevHooks?.cleanup?.(); + + const cleanup = effect(); + + if (cleanup) { + (hooks[currentIndex] as EffectHook).cleanup = cleanup; + } + }); + } + hooks[currentIndex] = { + kind: HookTypes.EFFECT, + deps: deps, + cleanup: prevHooks?.cleanup, + }; + context.hooks.cursor.set(path, currentIndex + 1); }; diff --git a/packages/react/src/core/reconciler.ts b/packages/react/src/core/reconciler.ts index 12cbdd39..adbfe380 100644 --- a/packages/react/src/core/reconciler.ts +++ b/packages/react/src/core/reconciler.ts @@ -2,8 +2,8 @@ import { context } from "./context"; import { Fragment, NodeTypes, TEXT_ELEMENT } from "./constants"; import { Instance, VNode } from "./types"; import { - getFirstDom, - getFirstDomFromChildren, + // getFirstDom, + // getFirstDomFromChildren, insertInstance, removeInstance, setDomProps, @@ -34,5 +34,174 @@ export const reconcile = ( // 4. 타입과 키가 같으면 인스턴스를 업데이트합니다. (update) // - DOM 요소: updateDomProps로 속성 업데이트 후 자식 재조정 // - 컴포넌트: 컴포넌트 함수 재실행 후 자식 재조정 - return null; + + // 1. 노드 없음 + if (isEmptyValue(node) || !node) { + removeInstance(parentDom, instance); + return null; + } + + // 2. 인스턴스 없음 + if (isEmptyValue(instance) || !instance) { + // TEXT_ELEMENT + if (node.type === TEXT_ELEMENT) { + const textNode = document.createTextNode(node.props.nodeValue || ""); + const newInstance: Instance = { + kind: NodeTypes.TEXT, // ✅ + dom: textNode, + node: node, + children: [], + key: node.key, + path: path, + }; + insertInstance(parentDom, newInstance); + return newInstance; + } + + // Fragment + if (node.type === Fragment) { + const newInstance: Instance = { + kind: NodeTypes.FRAGMENT, // ✅ + dom: null, + node: node, + children: [], + key: node.key, + path: path, + }; + + const children = node.props.children || []; + newInstance.children = children + .map((child, index) => { + const childPath = createChildPath(path, child?.key ?? null, index, child?.type, children); + return reconcile(parentDom, null, child, childPath); + }) + .filter((child): child is Instance => child !== null); + + return newInstance; + } + + // DOM 요소 + if (typeof node.type === "string") { + const dom = document.createElement(node.type); + setDomProps(dom, node.props); + + const newInstance: Instance = { + kind: NodeTypes.HOST, // ✅ ELEMENT → HOST + dom: dom, + node: node, + children: [], + key: node.key, + path: path, + }; + + const children = node.props.children || []; + newInstance.children = children + .map((child, index) => { + const childPath = createChildPath(path, child?.key ?? null, index, child?.type, children); + return reconcile(dom, null, child, childPath); + }) + .filter((child): child is Instance => child !== null); + + insertInstance(parentDom, newInstance); + return newInstance; + } + + // 컴포넌트 + if (typeof node.type === "function") { + context.hooks.componentStack.push(path); + const childVNode = node.type(node.props); + context.hooks.componentStack.pop(); + context.hooks.visited.add(path); + + const childInstance = reconcile(parentDom, null, childVNode, path); + + const newInstance: Instance = { + kind: NodeTypes.COMPONENT, // ✅ + dom: null, + node: node, + children: childInstance ? [childInstance] : [], + key: node.key, + path: path, + }; + + return newInstance; + } + + return null; + } + + // 여기서부터 instance와 node 둘 다 확정! + + // 3. 타입 다름 + if (instance.node.type !== node.type || instance.node.key !== node.key) { + removeInstance(parentDom, instance); + return reconcile(parentDom, null, node, path); + } + + // 4. 같음 - 여기서 명시적 체크! + + // TEXT_ELEMENT + if (node.type === TEXT_ELEMENT && instance.dom) { + (instance.dom as Text).nodeValue = node.props.nodeValue || ""; + instance.node = node; + return instance; + } + + // Fragment + if (node.type === Fragment) { + const oldChildren = instance.children; + const newChildren = node.props.children || []; + const maxLength = Math.max(oldChildren.length, newChildren.length); + + instance.children = []; + for (let i = 0; i < maxLength; i++) { + const child = newChildren[i]; + const childPath = createChildPath(path, child?.key ?? null, i, child?.type, newChildren); + const childInstance = reconcile(parentDom, oldChildren[i] || null, child || null, childPath); + if (childInstance) { + instance.children.push(childInstance); + } + } + + instance.node = node; + return instance; + } + + // DOM 요소 + if (typeof node.type === "string" && instance.dom) { + updateDomProps(instance.dom as HTMLElement, instance.node.props, node.props); + + const oldChildren = instance.children; + const newChildren = node.props.children || []; + const maxLength = Math.max(oldChildren.length, newChildren.length); + + instance.children = []; + for (let i = 0; i < maxLength; i++) { + const child = newChildren[i]; + const childPath = createChildPath(path, child?.key ?? null, i, child?.type, newChildren); + const childInstance = reconcile(instance.dom as HTMLElement, oldChildren[i] || null, child || null, childPath); + if (childInstance) { + instance.children.push(childInstance); + } + } + + instance.node = node; + return instance; + } + + // 컴포넌트 + if (typeof node.type === "function") { + context.hooks.componentStack.push(path); + const childVNode = node.type(node.props); + context.hooks.componentStack.pop(); + context.hooks.visited.add(path); + + const childInstance = reconcile(parentDom, instance.children[0] || null, childVNode, path); + + instance.children = childInstance ? [childInstance] : []; + instance.node = node; + return instance; + } + + return instance; }; diff --git a/packages/react/src/core/render.ts b/packages/react/src/core/render.ts index 79c4bbb8..5a6447f6 100644 --- a/packages/react/src/core/render.ts +++ b/packages/react/src/core/render.ts @@ -1,8 +1,7 @@ +import { enqueue, withEnqueue } from "../utils"; import { context } from "./context"; -import { getDomNodes, insertInstance } from "./dom"; -import { reconcile } from "./reconciler"; import { cleanupUnusedHooks } from "./hooks"; -import { withEnqueue } from "../utils"; +import { reconcile } from "./reconciler"; /** * 루트 컴포넌트의 렌더링을 수행하는 함수입니다. @@ -13,6 +12,19 @@ export const render = (): void => { // 1. 훅 컨텍스트를 초기화합니다. // 2. reconcile 함수를 호출하여 루트 노드를 재조정합니다. // 3. 사용되지 않은 훅들을 정리(cleanupUnusedHooks)합니다. + + if (!context.root.container) return; + + context.hooks.visited.clear(); + context.hooks.cursor.clear(); + + context.root.instance = reconcile(context.root.container, context.root.instance, context.root.node, ""); + + cleanupUnusedHooks(); + + const effects = context.effects.queue.slice(); + context.effects.queue = []; + effects.forEach(enqueue); }; /** diff --git a/packages/react/src/core/setup.ts b/packages/react/src/core/setup.ts index 03813995..1c7fee67 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"; /** @@ -16,4 +16,21 @@ export const setup = (rootNode: VNode | null, container: HTMLElement): void => { // 2. 이전 렌더링 내용을 정리하고 컨테이너를 비웁니다. // 3. 루트 컨텍스트와 훅 컨텍스트를 리셋합니다. // 4. 첫 렌더링을 실행합니다. + + if (!container) { + throw new Error("Container is required"); + } + if (!rootNode) { + throw new Error("Root node is required"); + } + if (context.root.instance) { + removeInstance(container, context.root.instance); + } + container.innerHTML = ""; + + context.root.reset({ container, node: rootNode }); + context.hooks.clear(); + context.effects.queue = []; + + render(); }; diff --git a/packages/react/src/core/types.ts b/packages/react/src/core/types.ts index d88c5714..d957532b 100644 --- a/packages/react/src/core/types.ts +++ b/packages/react/src/core/types.ts @@ -55,7 +55,7 @@ export interface HooksContext { } export interface EffectsContext { - queue: Array<{ path: string; cursor: number }>; + queue: (() => void)[]; } export interface Context { diff --git a/packages/react/src/easy/createElement.js b/packages/react/src/easy/createElement.js new file mode 100644 index 00000000..14d6c389 --- /dev/null +++ b/packages/react/src/easy/createElement.js @@ -0,0 +1,91 @@ +import { addEvent } from "./eventManager"; + +/** + * VNode를 실제 DOM 요소로 변환합니다. + * @param {object|string|array} vNode - Virtual DOM 노드 또는 배열 + * @returns {Node} 생성된 DOM 노드 + */ +export function createElement(vNode) { + // 배열인 경우 DocumentFragment 생성 + if (Array.isArray(vNode)) { + const fragment = document.createDocumentFragment(); + vNode.forEach((child) => { + fragment.appendChild(createElement(child)); + }); + return fragment; + } + + // 텍스트 노드인 경우 + if (typeof vNode === "string" || typeof vNode === "number") { + return document.createTextNode(String(vNode)); + } + + // VNode 객체가 아닌 경우 빈 텍스트 노드 반환 + if (!vNode || typeof vNode !== "object") { + return document.createTextNode(""); + } + + // HTML 요소 생성 + const $el = document.createElement(vNode.type); + + // 속성 설정 + updateAttributes($el, vNode.props); + + // 자식 요소들을 재귀적으로 생성하고 추가 + if (vNode.children) { + vNode.children.forEach((child) => { + $el.appendChild(createElement(child)); + }); + } + + return $el; +} + +/** + * DOM 요소의 속성을 업데이트합니다. + * @param {HTMLElement} $el - 대상 DOM 요소 + * @param {object} props - 설정할 속성들 + */ +function updateAttributes($el, props) { + if (!props) return; + + Object.entries(props).forEach(([key, value]) => { + // 이벤트 리스너 처리 (onClick, onInput 등) + if (key.startsWith("on") && typeof value === "function") { + const eventType = key.slice(2).toLowerCase(); // onClick -> click + addEvent($el, eventType, value); + return; + } + + // className 처리 + if (key === "className") { + $el.setAttribute("class", value); + return; + } + + // style 객체 처리 + if (key === "style" && typeof value === "object") { + Object.entries(value).forEach(([styleName, styleValue]) => { + $el.style[styleName] = styleValue; + }); + return; + } + + // boolean 속성은 property로 직접 설정 + if (key === "checked" || key === "disabled" || key === "selected" || key === "readOnly") { + $el[key] = !!value; + return; + } + + // value 속성도 property로 직접 설정 + if (key === "value") { + $el.value = value; + return; + } + + // 일반 속성 처리 + if (value != null && value !== false) { + $el.setAttribute(key, value); + } + }); +} diff --git a/packages/react/src/easy/createVNode.js b/packages/react/src/easy/createVNode.js new file mode 100644 index 00000000..de7247b5 --- /dev/null +++ b/packages/react/src/easy/createVNode.js @@ -0,0 +1,16 @@ +/** + * Virtual DOM 노드를 생성합니다. + * @param {string|Function} type - HTML 태그명 또는 컴포넌트 함수 + * @param {object} props - 속성 객체 + * @param {...any} children - 자식 노드들 + * @returns {object} VNode 객체 + */ +export function createVNode(type, props, ...children) { + return { + type, + props, // props를 그대로 전달 (null일 수 있음) + children: children + .flat(Infinity) // 중첩 배열을 평탄화 + .filter((child) => child != null && child !== false && child !== true), // boolean, null, undefined 제거 + }; +} diff --git a/packages/react/src/easy/eventManager.js b/packages/react/src/easy/eventManager.js new file mode 100644 index 00000000..e618d7f3 --- /dev/null +++ b/packages/react/src/easy/eventManager.js @@ -0,0 +1,148 @@ +/** + * 각 요소에 등록된 이벤트 핸들러를 저장 + * WeakMap을 사용하여 요소가 제거되면 자동으로 GC + */ +const eventHandlers = new WeakMap(); + +/** + * 루트 컨테이너에 등록된 이벤트 타입들을 추적 + */ +const rootEventTypes = new WeakMap(); + +/** + * 요소에 이벤트 핸들러를 등록합니다. (이벤트 위임 방식) + * @param {HTMLElement} element - 대상 요소 + * @param {string} eventType - 이벤트 타입 (click, input 등) + * @param {Function} handler - 이벤트 핸들러 함수 + */ +export function addEvent(element, eventType, handler) { + if (!element || !eventType || typeof handler !== "function") { + return; + } + + // 요소의 이벤트 맵 가져오기 또는 생성 + let elementEvents = eventHandlers.get(element); + if (!elementEvents) { + elementEvents = new Map(); + eventHandlers.set(element, elementEvents); + } + + // 이벤트 타입별 핸들러 배열 가져오기 또는 생성 + let handlers = elementEvents.get(eventType); + if (!handlers) { + handlers = []; + elementEvents.set(eventType, handlers); + } + + // 중복 등록 방지 (WeakMap에만 저장, 직접 addEventListener 호출 안 함) + if (!handlers.includes(handler)) { + handlers.push(handler); + } +} + +/** + * 요소에서 이벤트 핸들러를 제거합니다. + * @param {HTMLElement} element - 대상 요소 + * @param {string} eventType - 이벤트 타입 + * @param {Function} handler - 제거할 핸들러 함수 + */ +export function removeEvent(element, eventType, handler) { + if (!element || !eventType || typeof handler !== "function") { + return; + } + + const elementEvents = eventHandlers.get(element); + if (!elementEvents) return; + + const handlers = elementEvents.get(eventType); + if (!handlers) return; + + // 핸들러 배열에서 제거 (WeakMap에서만 제거, removeEventListener 호출 안 함) + const index = handlers.indexOf(handler); + if (index > -1) { + handlers.splice(index, 1); + } + + // 빈 배열이면 정리 + if (handlers.length === 0) { + elementEvents.delete(eventType); + } + + // 빈 맵이면 정리 + if (elementEvents.size === 0) { + eventHandlers.delete(element); + } +} + +/** + * 루트 요소에 이벤트 위임 리스너를 설정합니다. + * @param {HTMLElement} root - 루트 컨테이너 + */ +export function setupEventListeners(root) { + if (!root) return; + + // 이미 설정된 루트면 스킵 + if (rootEventTypes.has(root)) return; + + // 이 루트에 등록된 이벤트 타입들을 추적 + const registeredTypes = new Set(); + rootEventTypes.set(root, registeredTypes); + + // 일반적인 이벤트 타입들 + const eventTypes = [ + "click", + "dblclick", + "input", + "change", + "submit", + "keydown", + "keyup", + "keypress", + "mousedown", + "mouseup", + "mousemove", + "mouseenter", + "mouseleave", + "mouseover", + "mouseout", + "focus", + "blur", + "focusin", + "focusout", + "scroll", + ]; + + // 각 이벤트 타입에 대해 위임 리스너 등록 + eventTypes.forEach((eventType) => { + root.addEventListener(eventType, (event) => { + // 이벤트 버블링을 따라 핸들러 실행 + let currentElement = event.target; + + // target부터 root까지 거슬러 올라가며 핸들러 찾기 + while (currentElement && currentElement !== root.parentElement) { + const elementEvents = eventHandlers.get(currentElement); + + if (elementEvents) { + const handlers = elementEvents.get(eventType); + + if (handlers && handlers.length > 0) { + // 해당 요소의 모든 핸들러 실행 + handlers.forEach((handler) => { + handler(event); + }); + } + } + + // stopPropagation이 호출되었으면 버블링 중단 + if (event.cancelBubble) { + break; + } + + // 부모로 이동 + currentElement = currentElement.parentElement; + } + }); + + registeredTypes.add(eventType); + }); +} diff --git a/packages/react/src/easy/normalizeVNode.js b/packages/react/src/easy/normalizeVNode.js new file mode 100644 index 00000000..be7c9f03 --- /dev/null +++ b/packages/react/src/easy/normalizeVNode.js @@ -0,0 +1,43 @@ +/** + * VNode를 정규화합니다. + * - null, undefined, boolean은 빈 문자열로 변환 + * - 문자열과 숫자는 텍스트 노드로 변환 + * - 배열은 재귀적으로 정규화 + * @param {any} vNode - 정규화할 VNode + * @returns {object|string} 정규화된 VNode + */ +export function normalizeVNode(vNode) { + // null, undefined, boolean은 빈 문자열로 처리 + if (vNode == null || typeof vNode === "boolean" || typeof vNode === "undefined") { + return ""; + } + + // 문자열이나 숫자는 그대로 반환 (텍스트 노드) + if (typeof vNode === "string" || typeof vNode === "number") { + return String(vNode); + } + + // 배열인 경우 재귀적으로 정규화 + if (Array.isArray(vNode)) { + return vNode.map(normalizeVNode).flat(); + } + + // 함수형 컴포넌트인 경우 실행하여 결과를 정규화 + if (typeof vNode.type === "function") { + const component = vNode.type; + const props = { + ...vNode.props, + children: vNode.children.length > 0 ? vNode.children : undefined, + }; + return normalizeVNode(component(props)); + } + + // 일반 VNode의 children을 정규화 + return { + ...vNode, + children: vNode.children + .map(normalizeVNode) + .flat() + .filter((child) => child !== ""), // 빈 문자열 제거 + }; +} diff --git a/packages/react/src/easy/renderElement.js b/packages/react/src/easy/renderElement.js new file mode 100644 index 00000000..68e7eaa9 --- /dev/null +++ b/packages/react/src/easy/renderElement.js @@ -0,0 +1,37 @@ +import { setupEventListeners } from "./eventManager"; +import { createElement } from "./createElement"; +import { normalizeVNode } from "./normalizeVNode"; +import { updateElement } from "./updateElement"; + +// 이전 VNode를 저장하기 위한 WeakMap (container를 키로 사용) +const oldVNodeMap = new WeakMap(); + +/** + * Virtual DOM을 실제 DOM으로 렌더링합니다. + * @param {object} vNode - 렌더링할 VNode + * @param {HTMLElement} container - 렌더링할 컨테이너 + */ +export function renderElement(vNode, container) { + // VNode 정규화 + const normalizedVNode = normalizeVNode(vNode); + + // 이전 VNode 가져오기 + const oldVNode = oldVNodeMap.get(container); + + // 최초 렌더링 + if (!oldVNode) { + // 컨테이너 비우기 + container.innerHTML = ""; + // 새 DOM 요소 생성 및 추가 + container.appendChild(createElement(normalizedVNode)); + } else { + // 업데이트 (diffing) + updateElement(container, normalizedVNode, oldVNode, 0); + } + + // 현재 VNode 저장 + oldVNodeMap.set(container, normalizedVNode); + + // 이벤트 리스너 설정 + setupEventListeners(container); +} diff --git a/packages/react/src/easy/updateElement.js b/packages/react/src/easy/updateElement.js new file mode 100644 index 00000000..8fd501c2 --- /dev/null +++ b/packages/react/src/easy/updateElement.js @@ -0,0 +1,155 @@ +import { addEvent, removeEvent } from "./eventManager"; +import { createElement } from "./createElement.js"; + +/** + * DOM 요소의 속성을 업데이트합니다. + * @param {HTMLElement} target - 대상 DOM 요소 + * @param {object} originNewProps - 새로운 속성들 + * @param {object} originOldProps - 이전 속성들 + */ +function updateAttributes(target, originNewProps, originOldProps) { + const newProps = originNewProps || {}; + const oldProps = originOldProps || {}; + + // 이전 속성 제거 + Object.keys(oldProps).forEach((key) => { + if (key.startsWith("on")) { + const eventType = key.slice(2).toLowerCase(); + removeEvent(target, eventType, oldProps[key]); + } else if (!(key in newProps)) { + // boolean 속성은 property를 false로 설정 + if (key === "checked" || key === "disabled" || key === "selected" || key === "readOnly") { + target[key] = false; + } else if (key === "value") { + target.value = ""; + } else if (key === "className") { + target.removeAttribute("class"); + } else { + target.removeAttribute(key); + } + } + }); + + // 새로운 속성 추가 또는 업데이트 + Object.entries(newProps).forEach(([key, value]) => { + const oldValue = oldProps[key]; + + // 이벤트 리스너 처리 (항상 재등록) + if (key.startsWith("on") && typeof value === "function") { + const eventType = key.slice(2).toLowerCase(); + if (oldValue && oldValue !== value) { + removeEvent(target, eventType, oldValue); + } + addEvent(target, eventType, value); + return; + } + + // 값이 변경되지 않았으면 스킵 + if (oldValue === value) return; + + // className 처리 + if (key === "className") { + target.setAttribute("class", value); + return; + } + + // style 객체 처리 + if (key === "style" && typeof value === "object") { + Object.entries(value).forEach(([styleName, styleValue]) => { + target.style[styleName] = styleValue; + }); + return; + } + + // boolean 속성은 property로 직접 설정 + if (key === "checked" || key === "disabled" || key === "selected" || key === "readOnly") { + target[key] = !!value; + return; + } + + // value 속성도 property로 직접 설정 + if (key === "value") { + target.value = value; + return; + } + + // 일반 속성 처리 + if (value != null && value !== false) { + target.setAttribute(key, value); + } else { + target.removeAttribute(key); + } + }); +} + +/** + * Virtual DOM diffing 알고리즘을 사용하여 실제 DOM을 업데이트합니다. + * @param {HTMLElement} parentElement - 부모 DOM 요소 + * @param {object|string} newNode - 새로운 VNode + * @param {object|string} oldNode - 이전 VNode + * @param {number} index - 자식 노드의 인덱스 + */ +export function updateElement(parentElement, newNode, oldNode, index = 0) { + // 이전 노드만 있는 경우 (삭제) + if (!newNode && oldNode) { + const childNode = parentElement.childNodes[index]; + if (childNode) { + parentElement.removeChild(childNode); + } + return; + } + + // 새 노드만 있는 경우 (추가) + if (newNode && !oldNode) { + parentElement.appendChild(createElement(newNode)); + return; + } + + // 노드 타입이 변경된 경우 (교체) + const hasChanged = + typeof newNode !== typeof oldNode || + (typeof newNode === "string" && newNode !== oldNode) || + (typeof newNode === "object" && newNode.type !== oldNode.type); + + if (hasChanged) { + const childNode = parentElement.childNodes[index]; + if (childNode) { + parentElement.replaceChild(createElement(newNode), childNode); + } else { + parentElement.appendChild(createElement(newNode)); + } + return; + } + + // 텍스트 노드인 경우 + if (typeof newNode === "string") { + const childNode = parentElement.childNodes[index]; + if (childNode && newNode !== oldNode) { + childNode.textContent = newNode; + } + return; + } + + // 같은 타입의 요소인 경우 속성만 업데이트 + const target = parentElement.childNodes[index]; + if (!target) return; + + updateAttributes(target, newNode.props, oldNode.props); + + // 자식 노드들을 재귀적으로 업데이트 + const newChildren = newNode.children || []; + const oldChildren = oldNode.children || []; + const maxLength = Math.max(newChildren.length, oldChildren.length); + + for (let i = 0; i < maxLength; i++) { + updateElement(target, newChildren[i], oldChildren[i], i); + } + + // 새로운 자식이 더 적으면, 남은 기존 자식들을 삭제 + while (target.childNodes.length > newChildren.length) { + const lastChild = target.childNodes[target.childNodes.length - 1]; + if (lastChild) { + target.removeChild(lastChild); + } + } +} diff --git a/packages/react/src/hocs/memo.ts b/packages/react/src/hocs/memo.ts index 24569ce4..a61e7526 100644 --- a/packages/react/src/hocs/memo.ts +++ b/packages/react/src/hocs/memo.ts @@ -12,10 +12,23 @@ import { shallowEquals } from "../utils"; */ export function memo

(Component: FunctionComponent

, equals = shallowEquals) { const MemoizedComponent: FunctionComponent

= (props) => { - // 여기를 구현하세요. - // useRef를 사용하여 이전 props와 렌더링 결과를 저장해야 합니다. - // equals 함수로 이전 props와 현재 props를 비교하여 렌더링 여부를 결정합니다. - return Component(props); + const prevPropsRef = useRef

(null); + const prevResultRef = useRef(null); + + // 이전 props와 비교 + if (prevPropsRef.current !== null && equals(prevPropsRef.current, props)) { + // props가 같으면 이전 결과 재사용 + return prevResultRef.current!; + } + + // props가 다르면 새로 렌더링 + const result = Component(props); + + // 결과 저장 + prevPropsRef.current = props; + prevResultRef.current = result; + + return result; }; MemoizedComponent.displayName = `Memo(${Component.displayName || Component.name})`; diff --git a/packages/react/src/hooks/useAutoCallback.ts b/packages/react/src/hooks/useAutoCallback.ts index 19d48f72..b27cb20e 100644 --- a/packages/react/src/hooks/useAutoCallback.ts +++ b/packages/react/src/hooks/useAutoCallback.ts @@ -9,7 +9,10 @@ import { useRef } from "./useRef"; * @returns 참조가 안정적인 콜백 함수 */ export const useAutoCallback = (fn: T): T => { - // 여기를 구현하세요. - // useRef와 useCallback을 조합하여 구현해야 합니다. - return fn; + // 최신 함수를 ref에 저장 + const fnRef = useRef(fn); + fnRef.current = fn; + + // 참조가 변하지 않는 wrapper 함수 생성 + return useCallback(((...args: Parameters) => fnRef.current(...args)) as T, []); }; diff --git a/packages/react/src/hooks/useCallback.ts b/packages/react/src/hooks/useCallback.ts index c0043993..45f1bc64 100644 --- a/packages/react/src/hooks/useCallback.ts +++ b/packages/react/src/hooks/useCallback.ts @@ -9,8 +9,6 @@ import { useMemo } from "./useMemo"; * @param deps - 의존성 배열 * @returns 메모이제이션된 콜백 함수 */ -export const useCallback = any>(callback: T, deps: DependencyList): T => { - // 여기를 구현하세요. - // useMemo를 사용하여 구현할 수 있습니다. - return callback; +export const useCallback = unknown>(callback: T, deps: DependencyList): T => { + return useMemo(() => callback, deps); }; diff --git a/packages/react/src/hooks/useDeepMemo.ts b/packages/react/src/hooks/useDeepMemo.ts index f968d05a..4bd8ae36 100644 --- a/packages/react/src/hooks/useDeepMemo.ts +++ b/packages/react/src/hooks/useDeepMemo.ts @@ -6,7 +6,5 @@ import { useMemo } from "./useMemo"; * `deepEquals`를 사용하여 의존성을 깊게 비교하는 `useMemo` 훅입니다. */ export const useDeepMemo = (factory: () => T, deps: DependencyList): T => { - // 여기를 구현하세요. - // useMemo와 deepEquals 함수를 사용해야 합니다. - return factory(); + return useMemo(factory, deps, deepEquals); }; diff --git a/packages/react/src/hooks/useMemo.ts b/packages/react/src/hooks/useMemo.ts index c275d0e1..e96f5e80 100644 --- a/packages/react/src/hooks/useMemo.ts +++ b/packages/react/src/hooks/useMemo.ts @@ -1,6 +1,6 @@ +import { shallowEquals } from "../utils"; import { DependencyList } from "./types"; import { useRef } from "./useRef"; -import { shallowEquals } from "../utils"; /** * 계산 비용이 큰 함수의 결과를 메모이제이션합니다. @@ -12,8 +12,12 @@ import { shallowEquals } from "../utils"; * @returns 메모이제이션된 값 */ export const useMemo = (factory: () => T, deps: DependencyList, equals = shallowEquals): T => { - // 여기를 구현하세요. // useRef를 사용하여 이전 의존성 배열과 계산된 값을 저장해야 합니다. // equals 함수로 의존성을 비교하여 factory 함수를 재실행할지 결정합니다. - return factory(); + const ref = useRef<{ deps: DependencyList; value: T } | null>(null); + + if (!ref.current || !equals(ref.current.deps, deps)) { + ref.current = { deps, value: factory() }; + } + return ref.current.value; }; diff --git a/packages/react/src/hooks/useRef.ts b/packages/react/src/hooks/useRef.ts index d5521ca1..1b3afc09 100644 --- a/packages/react/src/hooks/useRef.ts +++ b/packages/react/src/hooks/useRef.ts @@ -8,7 +8,7 @@ import { useState } from "../core"; * @returns `{ current: T }` 형태의 ref 객체 */ export const useRef = (initialValue: T): { current: T } => { - // 여기를 구현하세요. // useState를 사용하여 ref 객체를 한 번만 생성하도록 해야 합니다. - return { current: initialValue }; + const [ref] = useState<{ current: T }>(() => ({ current: initialValue })); + return ref; }; diff --git a/packages/react/src/utils/enqueue.ts b/packages/react/src/utils/enqueue.ts index a4957d53..82c93e8d 100644 --- a/packages/react/src/utils/enqueue.ts +++ b/packages/react/src/utils/enqueue.ts @@ -5,7 +5,7 @@ import type { AnyFunction } from "../types"; * 브라우저의 `queueMicrotask` 또는 `Promise.resolve().then()`을 사용합니다. */ export const enqueue = (callback: () => void) => { - // 여기를 구현하세요. + queueMicrotask(callback); }; /** @@ -13,7 +13,15 @@ export const enqueue = (callback: () => void) => { * 렌더링이나 이펙트 실행과 같은 작업의 중복을 방지하는 데 사용됩니다. */ export const withEnqueue = (fn: AnyFunction) => { - // 여기를 구현하세요. + let scheduled = false; // scheduled 플래그를 사용하여 fn이 한 번만 예약되도록 구현합니다. - return () => {}; + + return () => { + if (scheduled) return; + scheduled = true; + enqueue(() => { + scheduled = false; + fn(); + }); + }; }; diff --git a/packages/react/src/utils/equals.ts b/packages/react/src/utils/equals.ts index 31ec4ba5..4a56e1d6 100644 --- a/packages/react/src/utils/equals.ts +++ b/packages/react/src/utils/equals.ts @@ -5,7 +5,33 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { // 여기를 구현하세요. // Object.is(), Array.isArray(), Object.keys() 등을 활용하여 1단계 깊이의 비교를 구현합니다. - return a === b; + if (Object.is(a, b)) return true; + + if (a == null || b == null || typeof a !== "object" || typeof b !== "object") { + 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; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if ( + !Object.hasOwn(b, key) || + !Object.is((a as Record)[key], (b as Record)[key]) // 1단계 값 비교 (참조 아님) + ) { + return false; + } + } + return true; }; /** @@ -15,5 +41,31 @@ export const shallowEquals = (a: unknown, b: unknown): boolean => { export const deepEquals = (a: unknown, b: unknown): boolean => { // 여기를 구현하세요. // 재귀적으로 deepEquals를 호출하여 중첩된 구조를 비교해야 합니다. - return a === b; + + if (Object.is(a, b)) return true; + if (a == null || b == null || typeof a !== "object" || typeof b !== "object") { + 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; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + for (const key of keysA) { + if ( + !Object.hasOwn(b, key) || + !deepEquals((a as Record)[key], (b as Record)[key]) + ) { + return false; + } + } + return true; }; diff --git a/packages/react/src/utils/validators.ts b/packages/react/src/utils/validators.ts index da81b3dd..a6277a05 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" || value === ""; };