diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..58ca80d2 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,51 @@ +name: Deploy to GitHub Pages + +on: + push: # push trigger + branches: + - main + - easy + +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@v2 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - 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/e2e/e2e.spec.js b/e2e/e2e.spec.js index 23b32ba2..49e11dd3 100644 --- a/e2e/e2e.spec.js +++ b/e2e/e2e.spec.js @@ -11,7 +11,10 @@ class E2EHelpers { // 페이지 로딩 대기 async waitForPageLoad() { - await this.page.waitForSelector('[data-testid="products-grid"], #products-grid', { timeout: 10000 }); + await this.page.waitForSelector( + '[data-testid="products-grid"], #products-grid', + { timeout: 10000 }, + ); await this.page.waitForFunction(() => { const text = document.body.textContent; return text.includes("총") && text.includes("개"); @@ -53,7 +56,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { }); test.describe("1. 애플리케이션 초기화 및 기본 기능", () => { - test("페이지 접속 시 로딩 상태가 표시되고 상품 목록이 정상적으로 로드된다", async ({ page }) => { + test("페이지 접속 시 로딩 상태가 표시되고 상품 목록이 정상적으로 로드된다", async ({ + page, + }) => { const helpers = new E2EHelpers(page); // 로딩 상태 확인 @@ -83,7 +88,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(firstProductCard.locator("img")).toBeVisible(); // 상품명 확인 - await expect(firstProductCard).toContainText(/pvc 투명 젤리 쇼핑백|고양이 난간 안전망/i); + await expect(firstProductCard).toContainText( + /pvc 투명 젤리 쇼핑백|고양이 난간 안전망/i, + ); // 가격 정보 확인 (숫자 + 원) await expect(firstProductCard).toContainText(/\d{1,3}(,\d{3})*원/); @@ -94,7 +101,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { }); test.describe("2. 검색 및 필터링 기능", () => { - test("검색어 입력 후 Enter 키로 검색하고 URL이 업데이트된다", async ({ page }) => { + test("검색어 입력 후 Enter 키로 검색하고 URL이 업데이트된다", async ({ + page, + }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); @@ -116,7 +125,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await page.press("#search-input", "Enter"); // URL 업데이트 확인 - await expect(page).toHaveURL(/search=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C/); + await expect(page).toHaveURL( + /search=%EC%95%84%EC%9D%B4%ED%8C%A8%EB%93%9C/, + ); // 검색 결과 확인 await expect(page.locator("text=21개")).toBeVisible(); @@ -127,27 +138,37 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page.locator("text=21개")).toBeVisible(); }); - test("카테고리 선택 후 브레드크럼과 URL이 업데이트된다", async ({ page }) => { + test("카테고리 선택 후 브레드크럼과 URL이 업데이트된다", async ({ + page, + }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); // 1차 카테고리 선택 await page.click("text=생활/건강"); - await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); + await expect(page).toHaveURL( + /category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/, + ); await expect(page.locator("text=300개")).toBeVisible(); // 브레드크럼 확인 - await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + await expect(page.locator("text=카테고리:").locator("..")).toContainText( + "생활/건강", + ); // 2차 카테고리 선택 await page.click("text=자동차용품"); - await expect(page).toHaveURL(/category2=%EC%9E%90%EB%8F%99%EC%B0%A8%EC%9A%A9%ED%92%88/); + await expect(page).toHaveURL( + /category2=%EC%9E%90%EB%8F%99%EC%B0%A8%EC%9A%A9%ED%92%88/, + ); await expect(page.locator("text=11개")).toBeVisible(); // 브레드크럼에 2차 카테고리도 표시되는지 확인 - await expect(page.locator("text=카테고리:").locator("..")).toContainText("자동차용품"); + await expect(page.locator("text=카테고리:").locator("..")).toContainText( + "자동차용품", + ); await expect(page.locator("text=11개")).toBeVisible(); await page.reload(); @@ -155,28 +176,38 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page.locator("text=11개")).toBeVisible(); }); - test("브레드크럼 클릭으로 상위 카테고리로 이동할 수 있다", async ({ page }) => { + test("브레드크럼 클릭으로 상위 카테고리로 이동할 수 있다", async ({ + page, + }) => { const helpers = new E2EHelpers(page); // 2차 카테고리 상태에서 시작 - await helpers.push("/?current=1&category1=생활%2F건강&category2=자동차용품&search=차량용"); + await helpers.push( + "/?current=1&category1=생활%2F건강&category2=자동차용품&search=차량용", + ); await helpers.waitForPageLoad(); await expect(page.locator("text=9개")).toBeVisible(); // 1차 카테고리 브레드크럼 클릭 await page.click("text=생활/건강"); - await expect(page).toHaveURL(/category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/); + await expect(page).toHaveURL( + /category1=%EC%83%9D%ED%99%9C%2F%EA%B1%B4%EA%B0%95/, + ); await expect(page).not.toHaveURL(/category2/); await expect(page.locator("text=12개")).toBeVisible(); // 전체 브레드크럼 클릭 await page.click("text=전체"); - await expect(page.locator("text=카테고리:전체생활/건강디지털/가전")).toBeVisible(); + await expect( + page.locator("text=카테고리:전체생활/건강디지털/가전"), + ).toBeVisible(); await page.reload(); await helpers.waitForPageLoad(); - await expect(page.locator("text=카테고리:전체생활/건강디지털/가전")).toBeVisible(); + await expect( + page.locator("text=카테고리:전체생활/건강디지털/가전"), + ).toBeVisible(); await page.fill("#search-input", ""); await page.press("#search-input", "Enter"); @@ -284,10 +315,14 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { const helpers = new E2EHelpers(page); // 복잡한 쿼리 파라미터로 직접 접근 - await helpers.push("/?search=젤리&category1=생활%2F건강&sort=price_desc&limit=10"); + await helpers.push( + "/?search=젤리&category1=생활%2F건강&sort=price_desc&limit=10", + ); await helpers.waitForPageLoad(); - await expect(page.locator("text=카테고리:").locator("..")).toContainText("생활/건강"); + await expect(page.locator("text=카테고리:").locator("..")).toContainText( + "생활/건강", + ); await expect(page.locator("#search-input")).toHaveValue("젤리"); await expect(page.locator("#sort-select")).toHaveValue("price_desc"); await expect(page.locator("#limit-select")).toHaveValue("10"); @@ -295,7 +330,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page.locator("text=3개")).toBeVisible(); }); - test("장바구니 내용이 localStorage에 저장되고 복원된다", async ({ page }) => { + test("장바구니 내용이 localStorage에 저장되고 복원된다", async ({ + page, + }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); @@ -306,7 +343,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page.locator("#cart-icon-btn span")).toBeVisible(); // localStorage에 저장되었는지 확인 - const cartData = await page.evaluate(() => localStorage.getItem("shopping_cart")); + const cartData = await page.evaluate(() => + localStorage.getItem("shopping_cart"), + ); expect(cartData).toBeTruthy(); // 페이지 새로고침 @@ -360,7 +399,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { // h1 태그에 상품명 확인 await expect( - page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + page.locator( + 'h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")', + ), ).toBeVisible(); // 수량 조절 후 장바구니 담기 @@ -368,7 +409,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page.locator("#quantity-input")).toHaveValue("2"); await page.click("#add-to-cart-btn"); - await expect(page.locator("text=장바구니에 추가되었습니다")).toBeVisible(); + await expect( + page.locator("text=장바구니에 추가되었습니다"), + ).toBeVisible(); // 관련 상품 섹션 확인 await expect(page.locator("text=관련 상품")).toBeVisible(); @@ -384,7 +427,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page).toHaveURL(/\/product\/\d+/); await expect(page.url()).not.toBe(currentUrl); await expect( - page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + page.locator( + 'h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")', + ), ).toBeVisible(); await expect(await page.evaluate(() => window.loadFlag)).toBe(true); @@ -392,7 +437,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await page.reload(); await expect( - page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + page.locator( + 'h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")', + ), ).toBeVisible(); await expect(await page.evaluate(() => window.loadFlag)).toBe(undefined); @@ -400,7 +447,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { }); test.describe("5. 장바구니 완전한 워크플로우", () => { - test("여러 상품 추가, 수량 조절, 선택 삭제 전체 시나리오", async ({ page }) => { + test("여러 상품 추가, 수량 조절, 선택 삭제 전체 시나리오", async ({ + page, + }) => { const helpers = new E2EHelpers(page); await helpers.waitForPageLoad(); @@ -417,7 +466,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await helpers.openCartModal(); // 두 상품이 모두 있는지 확인 - await expect(page.locator(".cart-modal")).toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).toContainText( + "PVC 투명 젤리 쇼핑백", + ); await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); // 첫 번째 상품 수량 증가 @@ -437,7 +488,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await page.click("#cart-modal-remove-selected-btn"); // 첫 번째 상품만 삭제되고 두 번째 상품은 남아있는지 확인 - await expect(page.locator(".cart-modal")).not.toContainText("PVC 투명 젤리 쇼핑백"); + await expect(page.locator(".cart-modal")).not.toContainText( + "PVC 투명 젤리 쇼핑백", + ); await expect(page.locator(".cart-modal")).toContainText("샷시 풍지판"); // 장바구니 아이콘 개수 업데이트 확인 (1개) @@ -533,7 +586,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page.locator(".cart-modal-overlay")).toBeVisible(); // 배경 클릭으로 닫기 (모달 내용이 아닌 오버레이 영역 클릭) - await page.locator(".cart-modal-overlay").click({ position: { x: 10, y: 10 } }); + await page + .locator(".cart-modal-overlay") + .click({ position: { x: 10, y: 10 } }); await expect(page.locator(".cart-modal-overlay")).not.toBeVisible(); }); @@ -545,22 +600,30 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await helpers.addProductToCart("PVC 투명 젤리 쇼핑백"); // 토스트 메시지 표시 확인 - await expect(page.locator("text=장바구니에 추가되었습니다")).toBeVisible(); + await expect( + page.locator("text=장바구니에 추가되었습니다"), + ).toBeVisible(); // 닫기 버튼이 있다면 클릭해서 수동으로 닫기 테스트 const closeButton = page.locator(".toast-close-btn"); if (await closeButton.isVisible()) { await closeButton.click(); - await expect(page.locator("text=장바구니에 추가되었습니다")).not.toBeVisible(); + await expect( + page.locator("text=장바구니에 추가되었습니다"), + ).not.toBeVisible(); } else { // 자동으로 사라지는지 확인 - await expect(page.locator("text=장바구니에 추가되었습니다")).not.toBeVisible({ timeout: 4000 }); + await expect( + page.locator("text=장바구니에 추가되었습니다"), + ).not.toBeVisible({ timeout: 4000 }); } }); }); test.describe("8. SPA 네비게이션", () => { - test("브라우저 뒤로가기/앞으로가기가 올바르게 작동한다", async ({ page }) => { + test("브라우저 뒤로가기/앞으로가기가 올바르게 작동한다", async ({ + page, + }) => { const helpers = new E2EHelpers(page); await page.evaluate(() => { window.loadFlag = true; @@ -575,7 +638,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page).toHaveURL("/product/85067212996"); await expect( - page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + page.locator( + 'h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")', + ), ).toBeVisible(); await expect(page.locator("text=관련 상품")).toBeVisible(); const relatedProducts = page.locator(".related-product-card"); @@ -583,21 +648,27 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { await expect(page).toHaveURL("/product/86940857379"); await expect( - page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + page.locator( + 'h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")', + ), ).toBeVisible(); // 브라우저 뒤로가기 await page.goBack(); await expect(page).toHaveURL("/product/85067212996"); await expect( - page.locator('h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")'), + page.locator( + 'h1:text("PVC 투명 젤리 쇼핑백 1호 와인 답례품 구디백 비닐 손잡이 미니 간식 선물포장")', + ), ).toBeVisible(); // 브라우저 앞으로가기 await page.goForward(); await expect(page).toHaveURL("/product/86940857379"); await expect( - page.locator('h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")'), + page.locator( + 'h1:text("샷시 풍지판 창문 바람막이 베란다 문 틈막이 창틀 벌레 차단 샤시 방충망 틈새막이")', + ), ).toBeVisible(); await page.goBack(); @@ -617,7 +688,9 @@ test.describe("E2E: 쇼핑몰 전체 사용자 시나리오", () => { }); // 404 페이지 테스트 - test("존재하지 않는 페이지 접근 시 404 페이지가 표시된다", async ({ page }) => { + test("존재하지 않는 페이지 접근 시 404 페이지가 표시된다", async ({ + page, + }) => { // 존재하지 않는 경로로 이동 await page.goto("/non-existent-page"); diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js index b1f186b6..d2b72964 100644 --- a/public/mockServiceWorker.js +++ b/public/mockServiceWorker.js @@ -100,7 +100,10 @@ addEventListener("fetch", function (event) { // Opening the DevTools triggers the "only-if-cached" request // that cannot be handled by the worker. Bypass such requests. - if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") { + if ( + event.request.cache === "only-if-cached" && + event.request.mode !== "same-origin" + ) { return; } @@ -216,7 +219,9 @@ async function getResponse(event, client, requestId) { const acceptHeader = headers.get("accept"); if (acceptHeader) { const values = acceptHeader.split(",").map((value) => value.trim()); - const filteredValues = values.filter((value) => value !== "msw/passthrough"); + const filteredValues = values.filter( + (value) => value !== "msw/passthrough", + ); if (filteredValues.length > 0) { headers.set("accept", filteredValues.join(", ")); @@ -286,7 +291,10 @@ function sendToClient(client, message, transferrables = []) { resolve(event.data); }; - client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]); + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]); }); } diff --git a/src/api/productApi.js b/src/api/productApi.js index bbdea046..2ad741d6 100644 --- a/src/api/productApi.js +++ b/src/api/productApi.js @@ -1,6 +1,12 @@ // 상품 목록 조회 export async function getProducts(params = {}) { - const { limit = 20, search = "", category1 = "", category2 = "", sort = "price_asc" } = params; + const { + limit = 20, + search = "", + category1 = "", + category2 = "", + sort = "price_asc", + } = params; const page = params.current ?? params.page ?? 1; const searchParams = new URLSearchParams({ diff --git a/src/lib/Router.js b/src/lib/Router.js index 7f4b8cc1..727d1ad4 100644 --- a/src/lib/Router.js +++ b/src/lib/Router.js @@ -22,7 +22,9 @@ export class Router { document.addEventListener("click", (e) => { if (e.target.closest("[data-link]")) { e.preventDefault(); - const url = e.target.getAttribute("href") || e.target.closest("[data-link]").getAttribute("href"); + const url = + e.target.getAttribute("href") || + e.target.closest("[data-link]").getAttribute("href"); if (url) { this.push(url); } @@ -111,7 +113,9 @@ export class Router { push(url) { try { // baseUrl이 없으면 자동으로 붙여줌 - let fullUrl = url.startsWith(this.#baseUrl) ? url : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); + let fullUrl = url.startsWith(this.#baseUrl) + ? url + : this.#baseUrl + (url.startsWith("/") ? url : "/" + url); const prevFullUrl = `${window.location.pathname}${window.location.search}`; @@ -170,7 +174,11 @@ export class Router { // 빈 값들 제거 Object.keys(updatedQuery).forEach((key) => { - if (updatedQuery[key] === null || updatedQuery[key] === undefined || updatedQuery[key] === "") { + if ( + updatedQuery[key] === null || + updatedQuery[key] === undefined || + updatedQuery[key] === "" + ) { delete updatedQuery[key]; } }); diff --git a/src/lib/attributeUtils.js b/src/lib/attributeUtils.js new file mode 100644 index 00000000..946bb825 --- /dev/null +++ b/src/lib/attributeUtils.js @@ -0,0 +1,88 @@ +const PROPERTY_ONLY_ATTRIBUTES = new Set(["checked", "selected"]); + +export function setAttribute(element, key, value) { + if (key === "className") { + element.className = value; + element.setAttribute("class", value); + return; + } + + if (key.startsWith("data-")) { + element.setAttribute(key, value); + return; + } + + if (typeof value === "boolean") { + if (PROPERTY_ONLY_ATTRIBUTES.has(key)) { + element[key] = value; + } else { + element[key] = value; + if (value) { + element.setAttribute(key, ""); + } else { + element.removeAttribute(key); + } + } + return; + } + + element.setAttribute(key, value); + if (key in element) { + element[key] = value; + } +} + +export function removeAttribute(element, key) { + if (key === "className") { + element.className = ""; + element.removeAttribute("class"); + return; + } + + if (key in element) { + element[key] = ""; + } + element.removeAttribute(key); +} + +export function updateAttributes(element, newProps, oldProps) { + const allProps = new Set([ + ...(newProps ? Object.keys(newProps) : []), + ...(oldProps ? Object.keys(oldProps) : []), + ]); + + allProps.forEach((key) => { + if (key.startsWith("on")) return; + + const hasNewValue = newProps && key in newProps; + const hasOldValue = oldProps && key in oldProps; + const newValue = hasNewValue ? newProps[key] : undefined; + const oldValue = hasOldValue ? oldProps[key] : undefined; + + if (!hasNewValue && !hasOldValue) return; + if (hasNewValue && hasOldValue && newValue === oldValue) return; + + if (!hasNewValue || newValue === null || newValue === undefined) { + if (hasOldValue) { + removeAttribute(element, key); + } + return; + } + + setAttribute(element, key, newValue); + }); +} + +export function extractEventHandlers(props) { + const eventHandlers = []; + if (!props) return eventHandlers; + + Object.keys(props).forEach((key) => { + if (key.startsWith("on") && typeof props[key] === "function") { + const eventType = key.slice(2).toLowerCase(); + eventHandlers.push({ eventType, handler: props[key] }); + } + }); + + return eventHandlers; +} diff --git a/src/lib/createElement.js b/src/lib/createElement.js index 5d39ae7d..dff1fb88 100644 --- a/src/lib/createElement.js +++ b/src/lib/createElement.js @@ -1,5 +1,75 @@ -import { addEvent } from "./eventManager"; +import { updateAttributes, extractEventHandlers } from "./attributeUtils.js"; -export function createElement(vNode) {} +export const elementEventHandlers = new WeakMap(); -function updateAttributes($el, props) {} +export function getElementEventHandlers(element) { + return elementEventHandlers.get(element) || []; +} + +function createTextNode(vNode) { + if ( + vNode === null || + vNode === undefined || + vNode === false || + vNode === true + ) { + return document.createTextNode(""); + } + + if (typeof vNode === "string" || typeof vNode === "number") { + return document.createTextNode(String(vNode)); + } + + return null; +} + +function createFragmentFromArray(vNodeArray) { + const fragment = document.createDocumentFragment(); + vNodeArray.forEach((child) => { + const element = createElement(child); + if (element) { + fragment.appendChild(element); + } + }); + return fragment; +} + +function createElementFromVNode(vNode) { + if (typeof vNode.type === "function") { + throw new Error("컴포넌트는 정규화 후 createElement로 생성해야 합니다."); + } + + const element = document.createElement(vNode.type); + + const eventHandlers = extractEventHandlers(vNode.props); + if (eventHandlers.length > 0) { + elementEventHandlers.set(element, eventHandlers); + } + + updateAttributes(element, vNode.props, null); + + const children = vNode.children || []; + children.forEach((child) => { + const childElement = createElement(child); + if (childElement) { + element.appendChild(childElement); + } + }); + + return element; +} + +export function createElement(vNode) { + const textNode = createTextNode(vNode); + if (textNode) return textNode; + + if (Array.isArray(vNode)) { + return createFragmentFromArray(vNode); + } + + if (vNode && typeof vNode === "object" && vNode.type) { + return createElementFromVNode(vNode); + } + + return document.createTextNode(""); +} diff --git a/src/lib/createVNode.js b/src/lib/createVNode.js index 9991337f..37ee8cf9 100644 --- a/src/lib/createVNode.js +++ b/src/lib/createVNode.js @@ -1,3 +1,16 @@ export function createVNode(type, props, ...children) { - return {}; + return { + type: type, + props: props, + children: filterTruthy(children.flat(Infinity)), + }; +} + +function filterTruthy(children) { + return children.filter((child) => { + if (child === false) return false; + if (child === null) return false; + if (child === undefined) return false; + return true; + }); } diff --git a/src/lib/eventManager.js b/src/lib/eventManager.js index 24e4240f..9570726e 100644 --- a/src/lib/eventManager.js +++ b/src/lib/eventManager.js @@ -1,5 +1,71 @@ -export function setupEventListeners(root) {} +const eventHandlers = new WeakMap(); -export function addEvent(element, eventType, handler) {} +function getElementHandlers(element) { + if (!eventHandlers.has(element)) { + eventHandlers.set(element, new Map()); + } + return eventHandlers.get(element); +} -export function removeEvent(element, eventType, handler) {} +export function addEvent(element, eventType, handler) { + const handlers = getElementHandlers(element); + if (!handlers.has(eventType)) { + handlers.set(eventType, new Set()); + } + handlers.get(eventType).add(handler); +} + +export function removeEvent(element, eventType, handler) { + const handlers = getElementHandlers(element); + if (handlers.has(eventType)) { + handlers.get(eventType).delete(handler); + } +} + +const rootEventListeners = new WeakMap(); + +export function setupEventListeners(root) { + let registeredHandlers = rootEventListeners.get(root); + if (!registeredHandlers) { + registeredHandlers = new Map(); + rootEventListeners.set(root, registeredHandlers); + } + + const createDelegatedHandler = (eventType) => { + return (event) => { + if (event.eventPhase === Event.BUBBLING_PHASE && event.cancelBubble) { + return; + } + + let target = event.target; + + while (target && target !== root && target !== document) { + const handlers = eventHandlers.get(target); + if (handlers && handlers.has(eventType)) { + const handlerSet = handlers.get(eventType); + handlerSet.forEach((handler) => { + handler(event); + }); + break; + } + target = target.parentElement; + } + }; + }; + + const collectEventTypes = (element) => { + const handlers = eventHandlers.get(element); + if (handlers) { + handlers.forEach((_, eventType) => { + if (!registeredHandlers.has(eventType)) { + const delegatedHandler = createDelegatedHandler(eventType); + registeredHandlers.set(eventType, delegatedHandler); + root.addEventListener(eventType, delegatedHandler, false); + } + }); + } + Array.from(element.children).forEach(collectEventTypes); + }; + + collectEventTypes(root); +} diff --git a/src/lib/normalizeVNode.js b/src/lib/normalizeVNode.js index 7dc6f175..ac1a13a8 100644 --- a/src/lib/normalizeVNode.js +++ b/src/lib/normalizeVNode.js @@ -1,3 +1,37 @@ +const isFalsy = (value) => + value === "" || + value === null || + value === undefined || + value === false || + value === true; + export function normalizeVNode(vNode) { - return vNode; + if (vNode === null || vNode === undefined || typeof vNode === "boolean") { + return ""; + } + + if (typeof vNode === "string" || typeof vNode === "number") { + return vNode.toString(); + } + + if (vNode.type) { + if (typeof vNode.type === "function") { + const props = vNode.props || {}; + const result = vNode.type({ children: vNode.children, ...props }); + return normalizeVNode(result); + } + + const children = vNode.children || []; + const normalizedChildren = children + .map(normalizeVNode) + .filter((child) => !isFalsy(child)); + + return { + type: vNode.type, + props: vNode.props, + children: normalizedChildren, + }; + } + + return ""; } diff --git a/src/lib/renderElement.js b/src/lib/renderElement.js index 04295728..a229ed9d 100644 --- a/src/lib/renderElement.js +++ b/src/lib/renderElement.js @@ -1,10 +1,64 @@ -import { setupEventListeners } from "./eventManager"; -import { createElement } from "./createElement"; +import { setupEventListeners, addEvent, removeEvent } from "./eventManager"; +import { createElement, getElementEventHandlers } from "./createElement"; import { normalizeVNode } from "./normalizeVNode"; import { updateElement } from "./updateElement"; +const previousVNodes = new WeakMap(); + +function appendElement(container, element) { + if (!element) return; + + if (element.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + while (element.firstChild) { + container.appendChild(element.firstChild); + } + } else { + container.appendChild(element); + } +} + +function collectAllElements(container) { + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT, + null, + false, + ); + + const allNodes = []; + let node; + while ((node = walker.nextNode())) { + allNodes.push(node); + } + + return allNodes; +} + +function registerEventHandlers(container) { + const allNodes = collectAllElements(container); + + allNodes.forEach((node) => { + const eventHandlers = getElementEventHandlers(node); + eventHandlers.forEach(({ eventType, handler }) => { + removeEvent(node, eventType, handler); + addEvent(node, eventType, handler); + }); + }); +} + export function renderElement(vNode, container) { - // 최초 렌더링시에는 createElement로 DOM을 생성하고 - // 이후에는 updateElement로 기존 DOM을 업데이트한다. - // 렌더링이 완료되면 container에 이벤트를 등록한다. + const normalizedVNode = normalizeVNode(vNode); + const previousVNode = previousVNodes.get(container); + + if (previousVNode) { + updateElement(container, normalizedVNode, previousVNode, 0); + } else { + container.innerHTML = ""; + const element = createElement(normalizedVNode); + appendElement(container, element); + } + + registerEventHandlers(container); + setupEventListeners(container); + previousVNodes.set(container, normalizedVNode); } diff --git a/src/lib/updateElement.js b/src/lib/updateElement.js index ac321861..ba7bef1c 100644 --- a/src/lib/updateElement.js +++ b/src/lib/updateElement.js @@ -1,6 +1,118 @@ -import { addEvent, removeEvent } from "./eventManager"; -import { createElement } from "./createElement.js"; +import { removeEvent } from "./eventManager"; +import { + createElement, + getElementEventHandlers, + elementEventHandlers, +} from "./createElement.js"; +import { updateAttributes, extractEventHandlers } from "./attributeUtils.js"; -function updateAttributes(target, originNewProps, originOldProps) {} +function insertElement(parentElement, element, index) { + if (element.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + while (element.firstChild) { + if (index < parentElement.childNodes.length) { + parentElement.insertBefore( + element.firstChild, + parentElement.childNodes[index], + ); + } else { + parentElement.appendChild(element.firstChild); + } + index++; + } + } else { + if (index < parentElement.childNodes.length) { + parentElement.insertBefore(element, parentElement.childNodes[index]); + } else { + parentElement.appendChild(element); + } + } +} -export function updateElement(parentElement, newNode, oldNode, index = 0) {} +function addNewNode(parentElement, newNode, index) { + const element = createElement(newNode); + if (element) { + insertElement(parentElement, element, index); + } +} + +function removeOldNode(parentElement, index) { + if (index < parentElement.childNodes.length) { + parentElement.removeChild(parentElement.childNodes[index]); + } +} + +function replaceNode(parentElement, newNode, index) { + const newElement = createElement(newNode); + if (newElement && index < parentElement.childNodes.length) { + parentElement.replaceChild(newElement, parentElement.childNodes[index]); + } +} + +function updateTextNode(currentElement, newNode) { + if (typeof newNode === "string" || typeof newNode === "number") { + currentElement.textContent = String(newNode); + } +} + +function updateEventHandlers(element, newProps) { + const oldEventHandlers = getElementEventHandlers(element); + oldEventHandlers.forEach(({ eventType, handler }) => { + removeEvent(element, eventType, handler); + }); + + const newEventHandlers = extractEventHandlers(newProps); + if (newEventHandlers.length > 0) { + elementEventHandlers.set(element, newEventHandlers); + } else { + elementEventHandlers.delete(element); + } +} + +function updateElementNode(currentElement, newNode, oldNode) { + updateEventHandlers(currentElement, newNode.props); + updateAttributes(currentElement, 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(currentElement, newChildren[i], oldChildren[i], i); + } + + while (currentElement.childNodes.length > newChildren.length) { + currentElement.removeChild(currentElement.lastChild); + } +} + +export function updateElement(parentElement, newNode, oldNode, index = 0) { + if (!newNode && !oldNode) return; + + if (!oldNode && newNode) { + addNewNode(parentElement, newNode, index); + return; + } + + if (oldNode && !newNode) { + removeOldNode(parentElement, index); + return; + } + + if (newNode.type !== oldNode.type) { + replaceNode(parentElement, newNode, index); + return; + } + + if (index >= parentElement.childNodes.length) return; + + const currentElement = parentElement.childNodes[index]; + + if (currentElement.nodeType === Node.TEXT_NODE) { + updateTextNode(currentElement, newNode); + return; + } + + if (currentElement.nodeType === Node.ELEMENT_NODE) { + updateElementNode(currentElement, newNode, oldNode); + } +} diff --git a/src/mocks/handlers.js b/src/mocks/handlers.js index 6e3035e6..ef9e9e87 100644 --- a/src/mocks/handlers.js +++ b/src/mocks/handlers.js @@ -1,7 +1,8 @@ import { http, HttpResponse } from "msw"; import items from "./items.json"; -const delay = async () => await new Promise((resolve) => setTimeout(resolve, 200)); +const delay = async () => + await new Promise((resolve) => setTimeout(resolve, 200)); // 카테고리 추출 함수 function getUniqueCategories() { @@ -26,7 +27,9 @@ function filterProducts(products, query) { if (query.search) { const searchTerm = query.search.toLowerCase(); filtered = filtered.filter( - (item) => item.title.toLowerCase().includes(searchTerm) || item.brand.toLowerCase().includes(searchTerm), + (item) => + item.title.toLowerCase().includes(searchTerm) || + item.brand.toLowerCase().includes(searchTerm), ); } @@ -66,7 +69,10 @@ export const handlers = [ // 상품 목록 API http.get("/api/products", async ({ request }) => { const url = new URL(request.url); - const page = parseInt(url.searchParams.get("page") ?? url.searchParams.get("current")) || 1; + const page = + parseInt( + url.searchParams.get("page") ?? url.searchParams.get("current"), + ) || 1; const limit = parseInt(url.searchParams.get("limit")) || 20; const search = url.searchParams.get("search") || ""; const category1 = url.searchParams.get("category1") || ""; @@ -126,7 +132,11 @@ export const handlers = [ rating: Math.floor(Math.random() * 2) + 4, // 4~5점 랜덤 reviewCount: Math.floor(Math.random() * 1000) + 50, // 50~1050개 랜덤 stock: Math.floor(Math.random() * 100) + 10, // 10~110개 랜덤 - images: [product.image, product.image.replace(".jpg", "_2.jpg"), product.image.replace(".jpg", "_3.jpg")], + images: [ + product.image, + product.image.replace(".jpg", "_2.jpg"), + product.image.replace(".jpg", "_3.jpg"), + ], }; return HttpResponse.json(detailProduct); diff --git a/src/router/withLifecycle.js b/src/router/withLifecycle.js index 9a4d1fd5..4ca67cf2 100644 --- a/src/router/withLifecycle.js +++ b/src/router/withLifecycle.js @@ -92,7 +92,12 @@ export const withLifecycle = ({ onMount, onUnmount, watches } = {}, page) => { const newDeps = getDeps(); if (depsChanged(newDeps, lifecycle.deps[index])) { - console.log(`📊 의존성 변경 감지 (${page.name}):`, lifecycle.deps[index], "→", newDeps); + console.log( + `📊 의존성 변경 감지 (${page.name}):`, + lifecycle.deps[index], + "→", + newDeps, + ); callback(); } diff --git a/src/services/productService.js b/src/services/productService.js index 8a12e8bd..f5bb4d1c 100644 --- a/src/services/productService.js +++ b/src/services/productService.js @@ -175,7 +175,9 @@ export const loadRelatedProducts = async (category2, excludeProductId) => { const response = await getProducts(params); // 현재 상품 제외 - const relatedProducts = response.products.filter((product) => product.productId !== excludeProductId); + const relatedProducts = response.products.filter( + (product) => product.productId !== excludeProductId, + ); productStore.dispatch({ type: PRODUCT_ACTIONS.SET_RELATED_PRODUCTS, diff --git a/src/stores/cartStore.js b/src/stores/cartStore.js index fe61f167..1733c96e 100644 --- a/src/stores/cartStore.js +++ b/src/stores/cartStore.js @@ -55,7 +55,9 @@ const cartReducer = (_, action) => { return { ...state, items: state.items.map((item) => - item.id === product.productId ? { ...item, quantity: item.quantity + quantity } : item, + item.id === product.productId + ? { ...item, quantity: item.quantity + quantity } + : item, ), }; } else { @@ -85,7 +87,11 @@ const cartReducer = (_, action) => { const { productId, quantity } = action.payload; return { ...state, - items: state.items.map((item) => (item.id === productId ? { ...item, quantity: Math.max(1, quantity) } : item)), + items: state.items.map((item) => + item.id === productId + ? { ...item, quantity: Math.max(1, quantity) } + : item, + ), }; } @@ -102,7 +108,8 @@ const cartReducer = (_, action) => { ); // 전체 선택 상태 업데이트 - const allSelected = updatedItems.length > 0 && updatedItems.every((item) => item.selected); + const allSelected = + updatedItems.length > 0 && updatedItems.every((item) => item.selected); return { ...state, diff --git a/vite.config.js b/vite.config.js index 011fb323..f814d747 100644 --- a/vite.config.js +++ b/vite.config.js @@ -24,7 +24,6 @@ export default mergeConfig( rollupOptions: { input: { main: resolve(__dirname, "index.html"), - 404: resolve(__dirname, "404.html"), }, }, },