From a0f21709216d135b5703816ea84326c7c724971e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 14:23:51 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20updateElement=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/updateElement.js | 247 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 245 insertions(+), 2 deletions(-) diff --git a/src/lib/updateElement.js b/src/lib/updateElement.js index ac321861..82bf00f6 100644 --- a/src/lib/updateElement.js +++ b/src/lib/updateElement.js @@ -1,6 +1,249 @@ import { addEvent, removeEvent } from "./eventManager"; import { createElement } from "./createElement.js"; -function updateAttributes(target, originNewProps, originOldProps) {} +const isEventProp = (name) => /^on[A-Z]/.test(name); +const getEventName = (prop) => prop.slice(2).toLowerCase(); -export function updateElement(parentElement, newNode, oldNode, index = 0) {} +const updateStyle = (element, newStyle = {}, oldStyle = {}) => { + Object.keys(oldStyle || {}).forEach((key) => { + if (!newStyle || newStyle[key] == null) { + element.style[key] = ""; + } + }); + + Object.entries(newStyle || {}).forEach(([key, value]) => { + element.style[key] = value ?? ""; + }); +}; + +const BOOLEAN_NO_ATTR = new Set(["checked", "selected"]); + +const setAttribute = (element, name, value) => { + if (name === "className") { + element.setAttribute("class", value ?? ""); + return; + } + + if (name === "htmlFor") { + element.setAttribute("for", value ?? ""); + return; + } + + if (name === "style" && value && typeof value === "object") { + updateStyle(element, value, {}); + return; + } + + if (typeof value === "boolean") { + element[name] = value; + if (BOOLEAN_NO_ATTR.has(name)) { + if (!value) { + element.removeAttribute(name); + } else { + element.removeAttribute(name); + } + return; + } + if (value) { + element.setAttribute(name, ""); + } else { + element.removeAttribute(name); + } + return; + } + + if (value == null) { + element.removeAttribute(name); + return; + } + + if (name in element) { + try { + element[name] = value; + return; + } catch { + // fallthrough + } + } + + element.setAttribute(name, value); +}; + +const removeAttribute = (element, name, oldValue) => { + if (name === "className") { + element.removeAttribute("class"); + return; + } + + if (name === "htmlFor") { + element.removeAttribute("for"); + return; + } + + if (name === "style" && oldValue && typeof oldValue === "object") { + Object.keys(oldValue).forEach((key) => { + element.style[key] = ""; + }); + return; + } + + if (typeof oldValue === "boolean") { + element[name] = false; + element.removeAttribute(name); + return; + } + + element.removeAttribute(name); +}; + +function updateAttributes(target, originNewProps, originOldProps) { + const newProps = originNewProps || {}; + const oldProps = originOldProps || {}; + const keys = new Set([ + ...Object.keys(oldProps), + ...Object.keys(newProps), + ]); + + keys.forEach((key) => { + if (key === "children" || key === "key") { + return; + } + + const newValue = newProps[key]; + const oldValue = oldProps[key]; + + if (isEventProp(key)) { + const eventType = getEventName(key); + + const hasOld = typeof oldValue === "function"; + const hasNew = typeof newValue === "function"; + + if (hasOld && !hasNew) { + removeEvent(target, eventType, oldValue); + return; + } + + if (!hasOld && hasNew) { + addEvent(target, eventType, newValue); + return; + } + + if (hasOld && hasNew && oldValue !== newValue) { + removeEvent(target, eventType, oldValue); + addEvent(target, eventType, newValue); + } + return; + } + + if (key === "style" && (typeof newValue === "object" || typeof oldValue === "object")) { + updateStyle(target, newValue || {}, oldValue || {}); + return; + } + + if (newValue === oldValue) { + return; + } + + if (newValue == null) { + removeAttribute(target, key, oldValue); + return; + } + + setAttribute(target, key, newValue); + }); +} + +const isTextNode = (node) => + typeof node === "string" || typeof node === "number"; + +export function updateElement(parentElement, newNode, oldNode, index = 0) { + const existingDom = parentElement.childNodes[index]; + + const removeNode = () => { + if (existingDom) { + parentElement.removeChild(existingDom); + } + }; + + const insertNode = (dom) => { + const referenceNode = parentElement.childNodes[index] || null; + parentElement.insertBefore(dom, referenceNode); + }; + + const addNode = () => { + const newDom = createElement(newNode); + if (newDom) { + insertNode(newDom); + } + }; + + // 1. remove node + if (newNode == null) { + removeNode(); + return; + } + + // 2. add node + if (oldNode == null) { + addNode(); + return; + } + + // 3. text node update + if (isTextNode(newNode) && isTextNode(oldNode)) { + if (newNode !== oldNode && existingDom) { + existingDom.textContent = String(newNode); + } + return; + } + + // 4. replace node when types differ (including text vs element) + const needsReplace = + isTextNode(newNode) !== isTextNode(oldNode) || + (!isTextNode(newNode) && + !isTextNode(oldNode) && + newNode.type !== oldNode.type); + + if (needsReplace) { + const newDom = createElement(newNode); + if (existingDom && newDom) { + parentElement.replaceChild(newDom, existingDom); + } else if (newDom) { + insertNode(newDom); + } + return; + } + + // 5. same type node update + if (!existingDom) { + addNode(); + return; + } + + updateAttributes(existingDom, newNode.props, oldNode.props); + + const toArray = (children) => { + if (Array.isArray(children)) return children; + if (children == null) return []; + return [children]; + }; + + const newChildren = toArray(newNode.children); + const oldChildren = toArray(oldNode.children); + + const sharedLength = Math.min(newChildren.length, oldChildren.length); + + for (let i = 0; i < sharedLength; i += 1) { + updateElement(existingDom, newChildren[i], oldChildren[i], i); + } + + if (newChildren.length > oldChildren.length) { + for (let i = sharedLength; i < newChildren.length; i += 1) { + updateElement(existingDom, newChildren[i], null, i); + } + } else if (oldChildren.length > newChildren.length) { + for (let i = oldChildren.length - 1; i >= newChildren.length; i -= 1) { + updateElement(existingDom, null, oldChildren[i], i); + } + } +} From 31780ce05256878963416b37d55cdb8438f20c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 14:25:16 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20createVNode=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/createVNode.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lib/createVNode.js b/src/lib/createVNode.js index 9991337f..0dbd0bd1 100644 --- a/src/lib/createVNode.js +++ b/src/lib/createVNode.js @@ -1,3 +1,19 @@ -export function createVNode(type, props, ...children) { - return {}; +export function createVNode(type, originProps = null, ...rawChildren) { + const props = originProps == null ? null : { ...originProps }; + const children = rawChildren + .flat(Infinity) + .reduce((acc, child) => { + if (child == null || typeof child === "boolean") { + return acc; + } + + acc.push(child); + return acc; + }, []); + + return { + type, + props, + children, + }; } From a248d5b65e3701d7598b238ef654de2661c10ec3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 14:26:04 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20normalizeVNode=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/normalizeVNode.js | 63 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/lib/normalizeVNode.js b/src/lib/normalizeVNode.js index 7dc6f175..996aa8ec 100644 --- a/src/lib/normalizeVNode.js +++ b/src/lib/normalizeVNode.js @@ -1,3 +1,64 @@ +function normalizeChildren(children = []) { + const result = []; + + children.forEach(child => { + const normalized = normalizeVNode(child); + + if (normalized === "" || normalized == null) { + return; + } + + if (Array.isArray(normalized)) { + result.push(...normalized); + return; + } + + result.push(normalized); + }); + + return result; +} + export function normalizeVNode(vNode) { - return vNode; + if (vNode == null || typeof vNode === "boolean") { + return ""; + } + + if (typeof vNode === "string" || typeof vNode === "number") { + return String(vNode); + } + + if (Array.isArray(vNode)) { + return normalizeChildren(vNode); + } + + if (typeof vNode !== "object") { + return ""; + } + + const { type, props = null, children = [] } = vNode; + + if (typeof type === "function") { + const componentProps = { ...(props || {}) }; + const normalizedChildren = normalizeChildren(children); + + if (normalizedChildren.length === 1) { + componentProps.children = normalizedChildren[0]; + } else if (normalizedChildren.length > 1) { + componentProps.children = normalizedChildren; + } else { + delete componentProps.children; + } + + const renderedVNode = type(componentProps); + return normalizeVNode(renderedVNode); + } + + const normalizedChildren = normalizeChildren(children); + + return { + type, + props: props == null ? null : { ...props }, + children: normalizedChildren, + }; } From abe128574b6e3e122ae2088f5e22108bb7695ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 14:26:28 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20createElement=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/createElement.js | 129 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 2 deletions(-) diff --git a/src/lib/createElement.js b/src/lib/createElement.js index 5d39ae7d..656bf95d 100644 --- a/src/lib/createElement.js +++ b/src/lib/createElement.js @@ -1,5 +1,130 @@ import { addEvent } from "./eventManager"; -export function createElement(vNode) {} +const isEventProp = (prop) => /^on[A-Z]/.test(prop); -function updateAttributes($el, props) {} +const setStyle = (element, style = {}) => { + Object.entries(style || {}).forEach(([key, value]) => { + if (value == null) return; + element.style[key] = value; + }); +}; + +const BOOLEAN_NO_ATTR = new Set(["checked", "selected"]); + +const setAttribute = (element, name, value) => { + if (name === "className") { + element.setAttribute("class", value ?? ""); + return; + } + + if (name === "htmlFor") { + element.setAttribute("for", value ?? ""); + return; + } + + if (name === "style" && value && typeof value === "object") { + setStyle(element, value); + return; + } + + if (typeof value === "boolean") { + element[name] = value; + if (BOOLEAN_NO_ATTR.has(name)) { + if (!value) { + element.removeAttribute(name); + } else { + element.removeAttribute(name); + } + return; + } + if (value) { + element.setAttribute(name, ""); + } else { + element.removeAttribute(name); + } + return; + } + + if (value == null) { + element.removeAttribute(name); + return; + } + + if (name in element) { + try { + element[name] = value; + return; + } catch { + // fallthrough to setAttribute + } + } + + element.setAttribute(name, value); +}; + +const applyProps = (element, props = {}) => { + if (!props) return; + + Object.entries(props).forEach(([key, value]) => { + if (key === "children" || key === "key") { + return; + } + + if (isEventProp(key) && typeof value === "function") { + const eventName = key.slice(2).toLowerCase(); + addEvent(element, eventName, value); + return; + } + + setAttribute(element, key, value); + }); +}; + +const createFragment = (children) => { + const fragment = document.createDocumentFragment(); + children.flat().forEach((child) => { + const node = createElement(child); + if (node) fragment.appendChild(node); + }); + return fragment; +}; + +export function createElement(node) { + if (Array.isArray(node)) { + return createFragment(node); + } + + if (node == null || typeof node === "boolean") { + return document.createTextNode(""); + } + + if (typeof node === "string" || typeof node === "number") { + return document.createTextNode(String(node)); + } + + if (typeof node !== "object") { + return document.createTextNode(""); + } + + const { type, props = null, children = [] } = node; + + if (typeof type === "function") { + throw new Error("Functional components must be normalized before rendering."); + } + + if (typeof type !== "string") { + return document.createTextNode(""); + } + + const element = document.createElement(type); + applyProps(element, props); + + (children || []).forEach((child) => { + const childNode = createElement(child); + if (childNode) { + element.appendChild(childNode); + } + }); + + return element; +} From a57725a30c36eafdfa58988bf68ddd642046a3bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 14:26:47 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20eventManager=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/eventManager.js | 127 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/src/lib/eventManager.js b/src/lib/eventManager.js index 24e4240f..e6b793cc 100644 --- a/src/lib/eventManager.js +++ b/src/lib/eventManager.js @@ -1,5 +1,126 @@ -export function setupEventListeners(root) {} +const elementEvents = new WeakMap(); +const rootContainers = new Set(); +const rootListeners = new WeakMap(); +const eventTypeCounts = new Map(); -export function addEvent(element, eventType, handler) {} +const delegateEvent = (event, root, eventType) => { + let current = event.target; -export function removeEvent(element, eventType, handler) {} + while (current && root.contains(current)) { + const eventsMap = elementEvents.get(current); + if (eventsMap) { + const handlers = eventsMap.get(eventType); + if (handlers) { + handlers.forEach((handler) => handler.call(current, event)); + } + } + + if (current === root || event.cancelBubble) { + break; + } + current = current.parentNode; + } +}; + +const attachToRoot = (root, eventType) => { + let listeners = rootListeners.get(root); + if (!listeners) { + listeners = new Map(); + rootListeners.set(root, listeners); + } + if (listeners.has(eventType)) return; + + const listener = (event) => delegateEvent(event, root, eventType); + root.addEventListener(eventType, listener); + listeners.set(eventType, listener); +}; + +const detachFromRoot = (root, eventType) => { + const listeners = rootListeners.get(root); + if (!listeners) return; + + const listener = listeners.get(eventType); + if (!listener) return; + + root.removeEventListener(eventType, listener); + listeners.delete(eventType); +}; + +const incrementEventType = (eventType) => { + const prev = eventTypeCounts.get(eventType) ?? 0; + eventTypeCounts.set(eventType, prev + 1); + if (prev === 0) { + rootContainers.forEach((root) => attachToRoot(root, eventType)); + } +}; + +const decrementEventType = (eventType) => { + const prev = eventTypeCounts.get(eventType); + if (prev == null) return; + + const next = prev - 1; + if (next <= 0) { + eventTypeCounts.delete(eventType); + rootContainers.forEach((root) => detachFromRoot(root, eventType)); + } else { + eventTypeCounts.set(eventType, next); + } +}; + +export function setupEventListeners(root) { + if (!root) return; + if (!rootListeners.has(root)) { + rootListeners.set(root, new Map()); + } + rootContainers.add(root); + + for (const eventType of eventTypeCounts.keys()) { + attachToRoot(root, eventType); + } +} + +export function addEvent(element, eventType, handler) { + if (!element || typeof handler !== "function" || !eventType) return; + + let eventsMap = elementEvents.get(element); + if (!eventsMap) { + eventsMap = new Map(); + elementEvents.set(element, eventsMap); + } + + let handlers = eventsMap.get(eventType); + if (!handlers) { + handlers = new Set(); + eventsMap.set(eventType, handlers); + } + + if (handlers.has(handler)) { + return; + } + + handlers.add(handler); + incrementEventType(eventType); +} + +export function removeEvent(element, eventType, handler) { + if (!element || !eventType || typeof handler !== "function") return; + + const eventsMap = elementEvents.get(element); + if (!eventsMap) return; + + const handlers = eventsMap.get(eventType); + if (!handlers) return; + + const removed = handlers.delete(handler); + if (!removed) return; + + if (handlers.size === 0) { + eventsMap.delete(eventType); + } + + if (eventsMap.size === 0) { + elementEvents.delete(element); + } + + decrementEventType(eventType); +} From 8864abff9e1fc99b46fd891b6ac82a67380afb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 14:27:04 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20renderElement=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/renderElement.js | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/lib/renderElement.js b/src/lib/renderElement.js index 04295728..a7887bb9 100644 --- a/src/lib/renderElement.js +++ b/src/lib/renderElement.js @@ -4,7 +4,22 @@ import { normalizeVNode } from "./normalizeVNode"; import { updateElement } from "./updateElement"; export function renderElement(vNode, container) { - // 최초 렌더링시에는 createElement로 DOM을 생성하고 - // 이후에는 updateElement로 기존 DOM을 업데이트한다. - // 렌더링이 완료되면 container에 이벤트를 등록한다. + if (!container) { + throw new Error("Container element is required for rendering."); + } + + const normalizedVNode = normalizeVNode(vNode); + + if (container.__currentVNode) { + updateElement(container, normalizedVNode, container.__currentVNode); + } else { + const dom = createElement(normalizedVNode); + container.innerHTML = ""; + if (dom) { + container.appendChild(dom); + } + } + + container.__currentVNode = normalizedVNode; + setupEventListeners(container); } From a222cacdc17dc5a27ec7596cda2915bb40d6f141 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 15:44:14 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20404.html=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 404.html | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 404.html diff --git a/404.html b/404.html new file mode 100644 index 00000000..d43ffde2 --- /dev/null +++ b/404.html @@ -0,0 +1,26 @@ + + + + + + 상품 쇼핑몰 + + + + + +
+ + + From 504cd85c8082128bef9cd81f80c3c121bc180251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EB=AF=BC?= Date: Thu, 20 Nov 2025 15:44:35 +0900 Subject: [PATCH 8/8] =?UTF-8?q?chore:=20deploy.yml=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..f70c7855 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,55 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: + - easy + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm run build + + - name: Create 404.html for SPA routing + run: | + cp ./dist/index.html ./dist/404.html + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: "./dist" + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file