Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bd575a3
과제 제출을 위한 빈 커밋 날리기
ds92ko Nov 16, 2025
cfca807
feat: shallowEquals & deepEquals 함수 구현
ds92ko Nov 16, 2025
08162be
feat: isEmptyValue 함수 구현
ds92ko Nov 16, 2025
14fc255
feat: enqueue & withEnqueue 함수 구현
ds92ko Nov 16, 2025
484808d
refactor: shallowEquals & deepEquals 함수 리팩토링
ds92ko Nov 16, 2025
9cdf844
feat: createElement & normalizeNode & createChildPath 함수 구현
ds92ko Nov 16, 2025
da61c1b
feat: Mini-React 전역 컨텍스트 구현
ds92ko Nov 16, 2025
1b4fcb4
feat: DOM 인터페이스 구축
ds92ko Nov 16, 2025
fb2b379
feat: cleanupUnusedHooks, useState, useEffect 함수 구현
ds92ko Nov 16, 2025
f5662ea
fix: nomalizeNode에 Fragment 처리 추가
ds92ko Nov 17, 2025
b3c4f59
feat: createChildPath path 생성 규칙 수정
ds92ko Nov 17, 2025
5cc7eb3
feat: setup 함수 구현
ds92ko Nov 18, 2025
bbab456
feat: render 함수 구현
ds92ko Nov 18, 2025
29016e1
feat: reconcile 함수 구현 및 가상 DOM 재조정 로직 추가
ds92ko Nov 18, 2025
c456bc3
fix: useEffect cleanup 실행 로직 개선
ds92ko Nov 18, 2025
ec09836
fix: DOM 속성 업데이트 및 인스턴스 삽입 로직 개선
ds92ko Nov 18, 2025
2c46514
feat: useRef 훅 구현
ds92ko Nov 18, 2025
0989dbf
feat: useMemo 훅 구현
ds92ko Nov 18, 2025
6beb800
feat: useCallback 훅 구현
ds92ko Nov 18, 2025
7a034d3
feat: useDeepMemo 훅 구현
ds92ko Nov 18, 2025
b63d57f
feat: memo HOC 구현
ds92ko Nov 18, 2025
9bdcc09
feat: deepMemo HOC 구현 완료
ds92ko Nov 18, 2025
fb4bdb2
feat: useAutoCallback 훅 구현
ds92ko Nov 18, 2025
464c308
fix: props undefined/null 예외 처리 추가
ds92ko Nov 18, 2025
bcbb430
feat: GitHub Pages 자동 배포 워크플로우 추가
ds92ko Nov 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/gh-pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Deploy to GitHub Pages

on:
push:
branches:
- main
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build Project
run: pnpm run build:prod
env:
NODE_ENV: production
- name: Upload Artifact
uses: actions/upload-pages-artifact@v4
with:
path: ./packages/app/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
permissions:
contents: read
pages: write
id-token: write
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"test:e2e:report": "npx playwright show-report",
"test:generate": "playwright codegen localhost:5173",
"prepare": "husky",
"gh-pages": "pnpm -F @hanghae-plus/shopping build && gh-pages -d ./packages/app/dist"
"build:prod": "pnpm -F @hanghae-plus/shopping build",
"gh-pages": "pnpm run build:prod && gh-pages -d ./packages/app/dist"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
Expand Down
31 changes: 16 additions & 15 deletions packages/react/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export const context: Context = {
node: null,
instance: null,
reset({ container, node }) {
// 여기를 구현하세요.
// container, node, instance를 전달받은 값으로 초기화합니다.
this.container = container;
this.node = node;
this.instance = null;
},
},

Expand All @@ -32,36 +33,36 @@ 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 "";
const stack = this.componentStack;

if (!stack.length) throw new Error("Hooks can only be called inside a component.");
return stack[stack.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;
return this.state.get(path) ?? [];
},
},

Expand All @@ -71,4 +72,4 @@ export const context: Context = {
effects: {
queue: [],
},
};
};
211 changes: 202 additions & 9 deletions packages/react/src/core/dom.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,58 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NodeType, NodeTypes } from "./constants";
import { NodeTypes } from "./constants";
import { Instance } from "./types";

/**
* DOM 요소에 속성(props)을 설정합니다.
* 이벤트 핸들러, 스타일, className 등 다양한 속성을 처리해야 합니다.
*/
export const setDomProps = (dom: HTMLElement, props: Record<string, any>): void => {
// 여기를 구현하세요.
if (!props) return;
Object.entries(props).forEach(([key, value]) => {
if (key === "children") return;

// style 객체 처리
if (key === "style" && value && typeof value === "object") {
const styleObject = value as Record<string, string | number | null | undefined>;
Object.entries(styleObject).forEach(([styleName, styleValue]) => {
(dom.style as any)[styleName] = styleValue != null ? String(styleValue) : "";
});
return;
}

// 이벤트 핸들러 처리
if (key.startsWith("on") && typeof value === "function") {
const eventName = key.slice(2).toLowerCase();
dom.addEventListener(eventName, value);
return;
}

// className 처리
if (key === "className") {
dom.className = value ?? "";
return;
}

// data-* 속성 처리
if (key.startsWith("data-")) {
if (value != null) dom.setAttribute(key, String(value));
return;
}

// boolean 속성 처리
if (typeof value === "boolean") {
if (key in dom) (dom as any)[key] = value;
else if (value) dom.setAttribute(key, "");
return;
}

// null/undefined 무시
if (value == null) return;

// 일반 속성 처리
if (key in dom) (dom as any)[key] = value;
else dom.setAttribute(key, String(value));
});
};

/**
Expand All @@ -19,31 +64,166 @@ export const updateDomProps = (
prevProps: Record<string, any> = {},
nextProps: Record<string, any> = {},
): void => {
// 여기를 구현하세요.
// 제거되거나 변경된 props 처리
Object.entries(prevProps).forEach(([key, prevValue]) => {
if (key === "children" || key === "style") return;

const nextValue = nextProps[key];
const hasNext = Object.prototype.hasOwnProperty.call(nextProps, key);
const isUnchanged = hasNext && nextValue === prevValue;

if (isUnchanged) return;

// 이벤트 핸들러 제거
if (key.startsWith("on") && typeof prevValue === "function") {
dom.removeEventListener(key.slice(2).toLowerCase(), prevValue);
return;
}

// className 업데이트
if (key === "className") {
dom.className = hasNext ? (nextValue ?? "") : "";
return;
}

// data-* 속성 제거
if (key.startsWith("data-")) {
if (!hasNext || nextValue == null) dom.removeAttribute(key);
return;
}

// boolean 속성 제거
if (!hasNext && typeof prevValue === "boolean") {
if (key in dom) (dom as any)[key] = false;
dom.removeAttribute(key);
return;
}

// 일반 속성 제거
if (!hasNext) {
// 프로퍼티가 있으면 빈 문자열로 설정 시도 (읽기 전용 속성은 에러 발생 가능)
if (key in dom) {
try {
(dom as any)[key] = "";
} catch {
// 읽기 전용 프로퍼티(innerHTML, tagName 등)는 무시하고 attribute만 제거
}
}
// attribute 제거
dom.removeAttribute(key);
}
});

// style 객체 비교/반영
const prevStyle = prevProps.style ?? {};
const nextStyle = nextProps.style ?? {};
const allStyleKeys = new Set([...Object.keys(prevStyle), ...Object.keys(nextStyle)]);

allStyleKeys.forEach((name) => {
const prevVal = prevStyle[name];
const nextVal = nextStyle[name];
if (prevVal === nextVal) return;

(dom.style as any)[name] = nextVal != null ? String(nextVal) : "";
});

// 추가되거나 변경된 props 설정
Object.entries(nextProps).forEach(([key, value]) => {
if (key === "children" || key === "style") return;

const prevValue = prevProps[key];
const isUnchanged = value === prevValue;

// 이벤트 핸들러 추가/교체
if (key.startsWith("on") && typeof value === "function") {
const eventName = key.slice(2).toLowerCase();
if (typeof prevValue === "function" && prevValue !== value) dom.removeEventListener(eventName, prevValue);
dom.addEventListener(eventName, value);
return;
}

// 이벤트 핸들러가 null/undefined로 변경된 경우 제거
if (key.startsWith("on") && typeof prevValue === "function" && (value == null || typeof value !== "function")) {
dom.removeEventListener(key.slice(2).toLowerCase(), prevValue);
return;
}

// className 설정
if (key === "className") {
if (!isUnchanged) dom.className = value ?? "";
return;
}

// data-* 속성 설정
if (key.startsWith("data-")) {
if (value == null) dom.removeAttribute(key);
else if (!isUnchanged) dom.setAttribute(key, String(value));
return;
}

// boolean 속성 설정
if (typeof value === "boolean") {
if (key in dom) {
if (!isUnchanged) (dom as any)[key] = value;
} else {
if (value) dom.setAttribute(key, "");
else dom.removeAttribute(key);
}
return;
}

// 일반 속성 설정
if (value == null || isUnchanged) return;

if (key in dom) (dom as any)[key] = value;
else dom.setAttribute(key, String(value));
});
};

/**
* 주어진 인스턴스에서 실제 DOM 노드(들)를 재귀적으로 찾아 배열로 반환합니다.
* Fragment나 컴포넌트 인스턴스는 여러 개의 DOM 노드를 가질 수 있습니다.
*/
export const getDomNodes = (instance: Instance | null): (HTMLElement | Text)[] => {
// 여기를 구현하세요.
return [];
if (!instance) return [];

const { dom, kind, children } = instance;

// HOST/TEXT 노드는 자신이 가진 dom만 반환
if (kind === NodeTypes.HOST || kind === NodeTypes.TEXT) return dom ? [dom] : [];

// COMPONENT/FRAGMENT 노드는 자식 인스턴스에서 실제 DOM을 모아서 반환
return children.flatMap((child) => getDomNodes(child));
};

/**
* 주어진 인스턴스에서 첫 번째 실제 DOM 노드를 찾습니다.
*/
export const getFirstDom = (instance: Instance | null): HTMLElement | Text | null => {
// 여기를 구현하세요.
if (!instance) return null;

const { dom, kind, children } = instance;

// HOST/TEXT 노드는 자신의 dom이 첫 번째 실제 DOM 노드
if ((kind === NodeTypes.HOST || kind === NodeTypes.TEXT) && dom) return dom;

// COMPONENT/FRAGMENT 등은 자식 인스턴스들에서 첫 DOM 노드를 찾음
for (const child of children) {
const found = getFirstDom(child);
if (found) return found;
}

return 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;
};

Expand All @@ -56,12 +236,25 @@ export const insertInstance = (
instance: Instance | null,
anchor: HTMLElement | Text | null = null,
): void => {
// 여기를 구현하세요.
if (!instance) return;

const nodes = getDomNodes(instance);
for (const node of nodes) {
if (node.parentNode === parentDom && (anchor ? node.nextSibling === anchor : node.nextSibling === null)) continue;

if (anchor) parentDom.insertBefore(node, anchor);
else parentDom.appendChild(node);
}
};

/**
* 부모 DOM에서 인스턴스에 해당하는 모든 DOM 노드를 제거합니다.
*/
export const removeInstance = (parentDom: HTMLElement, instance: Instance | null): void => {
// 여기를 구현하세요.
if (!instance) return;

const nodes = getDomNodes(instance);
for (const node of nodes) {
if (node.parentNode === parentDom) parentDom.removeChild(node);
}
};
Loading