Skip to content
54 changes: 54 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# (25.11.18 수정)
# pnpm 세팅으로 기존 CI workflow와 충돌 이슈가 있었습니다.
# 따라서 "Install pnpm" Step에 pnpm version을 명시하는 방향으로 수정했습니다.

name: Deploy to GitHub Pages

on:
push: # push trigger
branches:
- 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 # 여기 새로 추가되었습니다 (2025.11.18)

- 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
149 changes: 147 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,150 @@
import { addEvent } from "./eventManager";

export function createElement(vNode) {}
export function createElement(vNode) {
// null, undefined, true, false는 빈 텍스트 노드로 변환
if (
vNode === null ||
vNode === undefined ||
vNode === true ||
vNode === false
) {
return document.createTextNode("");
}

function updateAttributes($el, props) {}
// 문자열과 숫자는 텍스트 노드로 변환
if (typeof vNode === "string" || typeof vNode === "number") {
return document.createTextNode(String(vNode));
}

// 배열은 DocumentFragment로 변환
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
vNode.forEach((child) => {
const element = createElement(child);
if (element) {
fragment.appendChild(element);
}
});
return fragment;
}

// VNode 객체인지 확인
if (typeof vNode === "object" && vNode !== null && "type" in vNode) {
const { type, props, children } = vNode;

// type이 함수면 에러 발생
if (typeof type === "function") {
throw new Error("Component must be normalized before creating element");
}

// DOM 요소 생성
const element = document.createElement(type);

// 속성 설정
if (props) {
updateAttributes(element, props);
}

// 자식 요소 처리
if (children && Array.isArray(children)) {
children.forEach((child) => {
if (child !== null && child !== undefined) {
const childElement = createElement(child);
if (childElement) {
element.appendChild(childElement);
}
}
});
}

return element;
}

// 그 외의 경우는 null 반환
return null;
}

function updateAttributes($el, props) {
if (!props) return;

// 읽기 전용 속성 목록 (설정할 수 없는 속성들)
const readOnlyProps = [
"children",
"innerHTML",
"textContent",
"innerText",
"outerHTML",
];

Object.keys(props).forEach((key) => {
const value = props[key];

// 읽기 전용 속성은 건너뛰기
if (readOnlyProps.includes(key)) {
return;
}

// 이벤트 핸들러 처리 (onClick, onMouseOver 등)
if (key.startsWith("on") && typeof value === "function") {
const eventType = key.slice(2).toLowerCase(); // onClick -> click
addEvent($el, eventType, value);
return;
}

// className을 class로 변환
if (key === "className") {
$el.setAttribute("class", value || "");
return;
}

// 불리언 속성 처리
if (typeof value === "boolean") {
// checked와 selected는 DOM 속성 없이 property만 사용
const isPropertyOnly = key === "checked" || key === "selected";

if (value) {
if (isPropertyOnly) {
// property만 설정, DOM 속성은 설정하지 않음
if (key in $el) {
$el[key] = true;
}
} else {
// 일반 boolean 속성은 DOM 속성도 설정
$el.setAttribute(key, "");
if (key in $el) {
$el[key] = true;
}
}
} else {
// false일 때는 항상 제거
$el.removeAttribute(key);
if (key in $el) {
$el[key] = false;
}
}
return;
}

// data-* 속성은 dataset으로 처리
if (key.startsWith("data-")) {
const dataKey = key
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
$el.dataset[dataKey] = value;
return;
}

// 일반 속성 설정
if (value !== null && value !== undefined) {
$el.setAttribute(key, value);
// DOM 속성도 설정 (id, href 등) - 읽기 전용이 아닌 경우에만
if (key in $el && !readOnlyProps.includes(key)) {
try {
$el[key] = value;
} catch {
// 읽기 전용 속성인 경우 무시
}
}
}
});
}
15 changes: 14 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
export function createVNode(type, props, ...children) {
return {};
const flattened = children.flat(Infinity);
const filtered = flattened.filter(
(child) =>
child !== null &&
child !== undefined &&
child !== false &&
child !== true,
);

return {
type: type,
props: props,
children: filtered,
};
}
166 changes: 163 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,165 @@
export function setupEventListeners(root) {}
// 이벤트 핸들러를 저장하는 맵
// 구조: Map<element, Map<eventType, Set<handler>>>
const eventHandlers = new WeakMap();

export function addEvent(element, eventType, handler) {}
// 각 root에 대해 등록된 이벤트 타입을 추적
// 구조: Map<root, Set<eventType>>
const rootEventTypes = new WeakMap();

export function removeEvent(element, eventType, handler) {}
// root와 이벤트 타입을 연결하는 맵 (setupEventListeners에서 사용)
// 구조: Map<root, Map<eventType, delegatedHandler>>
const rootDelegatedHandlers = new WeakMap();

export function setupEventListeners(root) {
if (!root) return;

// root에 등록된 이벤트 타입들을 가져옴
const eventTypes = rootEventTypes.get(root);
if (!eventTypes || eventTypes.size === 0) return;

// root의 위임 핸들러 맵 가져오기 또는 생성
let delegatedHandlers = rootDelegatedHandlers.get(root);
if (!delegatedHandlers) {
delegatedHandlers = new Map();
rootDelegatedHandlers.set(root, delegatedHandlers);
}

// 각 이벤트 타입에 대해 위임 핸들러 등록
eventTypes.forEach((eventType) => {
// 이미 등록된 핸들러가 있다면 중복 등록 방지
if (delegatedHandlers.has(eventType)) return;

const delegatedHandler = (e) => {
// 이벤트가 발생한 타겟부터 root까지 올라가면서 핸들러 찾기
let target = e.target;
const path = [];

// 이벤트 경로 수집 (target부터 root까지)
while (target && target !== root.parentNode) {
path.push(target);
if (target === root) break;
target = target.parentNode;
}

// 경로를 따라가면서 등록된 핸들러 찾아서 실행
for (const element of path) {
const handlersMap = eventHandlers.get(element);
if (handlersMap) {
const handlers = handlersMap.get(eventType);
if (handlers) {
handlers.forEach((handler) => {
handler(e);
});
}
}
}
};

// bubble phase에서 실행 (기본값, capture phase가 아님)
root.addEventListener(eventType, delegatedHandler);
delegatedHandlers.set(eventType, delegatedHandler);
});
}

export function addEvent(element, eventType, handler) {
if (!element || !eventType || !handler) return;

// element의 핸들러 맵 가져오기 또는 생성
let handlersMap = eventHandlers.get(element);
if (!handlersMap) {
handlersMap = new Map();
eventHandlers.set(element, handlersMap);
}

// 해당 이벤트 타입의 핸들러 Set 가져오기 또는 생성
let handlers = handlersMap.get(eventType);
if (!handlers) {
handlers = new Set();
handlersMap.set(eventType, handlers);
}

// 핸들러 추가
handlers.add(handler);

// root 요소 찾기 (container)
// element부터 시작해서 document.body의 직접 자식인 요소를 root로 사용
let root = element;
while (root.parentNode) {
const parent = root.parentNode;
// document.body의 직접 자식인 경우
if (parent === document.body || parent === document) {
// root는 document.body의 직접 자식인 요소 (현재 root)
break;
}
// document.body나 document가 아니면 계속 올라감
root = parent;
}

// root에 이벤트 타입 등록
let eventTypes = rootEventTypes.get(root);
if (!eventTypes) {
eventTypes = new Set();
rootEventTypes.set(root, eventTypes);
}
eventTypes.add(eventType);
}

export function removeEvent(element, eventType, handler) {
if (!element || !eventType || !handler) return;

const handlersMap = eventHandlers.get(element);
if (!handlersMap) return;

const handlers = handlersMap.get(eventType);
if (!handlers) return;

// 핸들러 제거
handlers.delete(handler);

// 해당 이벤트 타입의 핸들러가 없으면 맵에서 제거
if (handlers.size === 0) {
handlersMap.delete(eventType);
}

// element에 등록된 핸들러가 없으면 WeakMap에서 제거
if (handlersMap.size === 0) {
eventHandlers.delete(element);
}
}

// container의 모든 자식 요소에 대해 container를 root로 설정
export function migrateRootToContainer(container) {
if (!container) return;

// container의 모든 자식 요소를 순회
const walker = document.createTreeWalker(
container,
NodeFilter.SHOW_ELEMENT,
null,
);

const eventTypesSet = new Set();
let node;
while ((node = walker.nextNode())) {
// 각 요소에 등록된 이벤트 핸들러 확인
const handlersMap = eventHandlers.get(node);
if (handlersMap) {
// 각 이벤트 타입에 대해 container를 root로 설정
handlersMap.forEach((handlers, eventType) => {
if (handlers && handlers.size > 0) {
eventTypesSet.add(eventType);
}
});
}
}

// container를 root로 설정
let eventTypes = rootEventTypes.get(container);
if (!eventTypes) {
eventTypes = new Set();
rootEventTypes.set(container, eventTypes);
}
eventTypesSet.forEach((eventType) => {
eventTypes.add(eventType);
});
}
Loading