Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Deploy to GitHub Pages

on:
push:
branches:
- main

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: "./packages/app/dist"

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"build": "pnpm run -r build",
"lint:fix": "eslint --fix",
"prettier:write": "prettier --write ./packages/*/src",
"preview": "vite preview",
"preview": "pnpm -F @hanghae-plus/shopping preview",
"test": "pnpm -F @hanghae-plus/react test",
"test:basic": "pnpm -F @hanghae-plus/react test:basic",
"test:advanced": "pnpm -F @hanghae-plus/react test:advanced",
Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/__tests__/basic.mini-react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { createElement, Fragment, setup, useEffect, useState } from "../core";
import { TEXT_ELEMENT } from "../core/constants";
import { context } from "../core/context";

const flushMicrotasks = async () => await Promise.resolve();

Expand Down Expand Up @@ -692,6 +693,7 @@ describe("Chapter 2-2 기본과제: MiniReact", () => {
initializerCalls += 1;
return 1;
});
console.log({ value });
setValue = update;
return <div>{value}</div>;
}
Expand All @@ -705,6 +707,8 @@ describe("Chapter 2-2 기본과제: MiniReact", () => {
setValue!(5);
await flushMicrotasks();

console.log(context.hooks.state);

div = container.firstElementChild as HTMLElement;
expect(initializerCalls).toBe(1);
expect(div?.textContent).toBe("5");
Expand Down
25 changes: 15 additions & 10 deletions packages/react/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
},

Expand All @@ -32,36 +34,39 @@ export const context: Context = {
* 모든 훅 관련 상태를 초기화합니다.
*/
clear() {
// 여기를 구현하세요.
// state, cursor, visited, componentStack을 모두 비웁니다.
this.state.clear();
this.cursor.clear();
this.visited.clear();
this.componentStack = [];
},

/**
* 현재 실행 중인 컴포넌트의 고유 경로를 반환합니다.
*/
get currentPath() {
// 여기를 구현하세요.
// componentStack의 마지막 요소를 반환해야 합니다.
// 스택이 비어있으면 '훅은 컴포넌트 내부에서만 호출되어야 한다'는 에러를 발생시켜야 합니다.
return "";
if (this.componentStack.length === 0) {
throw new Error("훅은 컴포넌트 내부에서만 호출되어야 한다");
}
// componentStack의 마지막 요소를 반환해야 합니다.
return this.componentStack[this.componentStack.length - 1];
},

/**
* 현재 컴포넌트에서 다음에 실행될 훅의 인덱스(커서)를 반환합니다.
*/
get currentCursor() {
// 여기를 구현하세요.
// cursor Map에서 현재 경로의 커서를 가져옵니다. 없으면 0을 반환합니다.
return 0;
return this.cursor.get(this.currentPath) ?? 0;
},

/**
* 현재 컴포넌트의 훅 상태 배열을 반환합니다.
*/
get currentHooks() {
// 여기를 구현하세요.
// state Map에서 현재 경로의 훅 배열을 가져옵니다. 없으면 빈 배열을 반환합니다.
return [];
return this.state.get(this.currentPath) ?? [];
},
},

Expand All @@ -71,4 +76,4 @@ export const context: Context = {
effects: {
queue: [],
},
};
};
116 changes: 105 additions & 11 deletions packages/react/src/core/dom.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,52 @@
/* 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 || props === null) return;
Object.keys(props).forEach((key) => {
if (key === "children") return;
if (dom.nodeType === Node.TEXT_NODE) return;
if (!dom) return;

const value = props[key];

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

// 스타일 객체 처리
if (key === "style" && typeof value === "object") {
Object.assign((dom as HTMLElement).style, value);
return;
}

// className 속성 처리
if (key === "className") {
(dom as HTMLElement).setAttribute("class", value as string);
return;
}

// 일반 HTML 속성은 setAttribute로 설정
// 단, boolean 값은 속성의 존재 여부로 처리
if (typeof value === "boolean") {
if (value) {
(dom as HTMLElement).setAttribute(key, "");
} else {
(dom as HTMLElement).removeAttribute(key);
}
return;
}

(dom as HTMLElement)?.setAttribute(key, String(value));
});
};

/**
Expand All @@ -19,32 +58,68 @@ export const updateDomProps = (
prevProps: Record<string, any> = {},
nextProps: Record<string, any> = {},
): void => {
// 여기를 구현하세요.
// 이벤트 핸들러 처리: 변경되거나 제거된 이벤트 핸들러 제거
Object.keys(prevProps).forEach((key) => {
if (key === "children") return;

const prevValue = prevProps[key];
const nextValue = nextProps[key];

// 이벤트 핸들러인 경우
if (key.startsWith("on") && typeof prevValue === "function") {
const eventName = key.slice(2).toLowerCase();

// 이벤트 핸들러가 제거되었거나 변경된 경우
if (nextValue === undefined || nextValue !== prevValue) {
dom.removeEventListener(eventName, prevValue as EventListener);
}
}
});

// nextProps에 없는 일반 속성 제거
Object.keys(prevProps).forEach((key) => {
if (key === "children") return;
if (key.startsWith("on")) return; // 이벤트 핸들러는 위에서 처리됨

if (nextProps[key] === undefined) {
// className은 "class" 속성으로 제거
if (key === "className") {
dom.removeAttribute("class");
} else if (key === "style" && typeof prevProps[key] === "object") {
// 스타일 객체의 경우 모든 속성 제거
Object.keys(prevProps[key] || {}).forEach((styleKey) => {
(dom as HTMLElement).style.removeProperty(styleKey);
});
} else {
dom.removeAttribute(key);
}
}
});

// 새 속성 설정 (변경된 속성 포함)
setDomProps(dom, nextProps);
};

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

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

/**
* 자식 인스턴스들로부터 첫 번째 실제 DOM 노드를 찾습니다.
*/
export const getFirstDomFromChildren = (children: (Instance | null)[]): HTMLElement | Text | null => {
// 여기를 구현하세요.
return null;
return children.find((child) => child?.dom) as HTMLElement | Text | null;
};

/**
Expand All @@ -56,12 +131,31 @@ export const insertInstance = (
instance: Instance | null,
anchor: HTMLElement | Text | null = null,
): void => {
// 여기를 구현하세요.
parentDom.insertBefore(instance?.dom as Node, anchor);
};

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

// Fragment나 Component의 경우 여러 DOM 노드를 가질 수 있음
if (instance.kind === NodeTypes.FRAGMENT || instance.kind === NodeTypes.COMPONENT) {
// 자식 인스턴스들을 재귀적으로 제거
if (instance.children) {
instance.children.forEach((child) => {
if (child && child.dom && parentDom.contains(child.dom as Node)) {
// 자식의 DOM이 부모에 포함되어 있으면 제거
parentDom.removeChild(child.dom as Node);
}
});
}
return;
}

// HOST나 TEXT의 경우 instance.dom을 제거하면 자식들도 함께 제거됨
if (instance.dom && parentDom.contains(instance.dom as Node)) {
parentDom.removeChild(instance.dom as Node);
}
};
51 changes: 43 additions & 8 deletions packages/react/src/core/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ import { Fragment, TEXT_ELEMENT } from "./constants";
* null, undefined, boolean, 배열, 원시 타입 등을 처리하여 일관된 VNode 구조를 보장합니다.
*/
export const normalizeNode = (node: VNode): VNode | null => {
// 여기를 구현하세요.
if (typeof node === "string" || typeof node === "number") return createTextElement(node);
if (typeof node === "object" && node !== null) return node;
return null;
};

/**
* 텍스트 노드를 위한 VNode를 생성합니다.
*/
const createTextElement = (node: VNode): VNode => {
// 여기를 구현하세요.
return {} as VNode;
const createTextElement = (node: string | number): VNode => {
return { type: TEXT_ELEMENT, key: null, props: { children: [], nodeValue: node.toString() } } as VNode;
};

/**
Expand All @@ -29,12 +29,37 @@ export const createElement = (
originProps?: Record<string, any> | null,
...rawChildren: any[]
) => {
// 여기를 구현하세요.
const { key, ...resetProps } = originProps || {};
const children = rawChildren.flat(Infinity).map(normalizeNode).filter(isEmptyValue);
const isChildren = children.length > 0;

return {
type,
key: key || null,
props: {
...resetProps,
...(isChildren ? { children } : {}),
},
};
};

/**
* 부모 경로와 자식의 key/index를 기반으로 고유한 경로를 생성합니다.
* 타입을 문자열로 변환합니다.
*/
const getTypeString = (type: string | symbol | React.ComponentType | undefined): string => {
if (!type) return "unknown";
if (typeof type === "string") return type;
if (typeof type === "symbol") return type.toString();
if (typeof type === "function") {
return (type as any).name || (type as any).displayName || "Anonymous";
}
return String(type);
};

/**
* 부모 경로와 자식의 key/index/type을 기반으로 고유한 경로를 생성합니다.
* 이는 훅의 상태를 유지하고 Reconciliation에서 컴포넌트를 식별하는 데 사용됩니다.
* key가 없을 때는 타입 정보를 포함하여 같은 타입의 컴포넌트가 항상 같은 경로를 유지하도록 합니다.
*/
export const createChildPath = (
parentPath: string,
Expand All @@ -43,6 +68,16 @@ export const createChildPath = (
nodeType?: string | symbol | React.ComponentType,
siblings?: VNode[],
): string => {
// 여기를 구현하세요.
return "";
// key가 있으면 key 기반 경로
if (key !== null) {
return `${parentPath}.k${key}`;
}

// key가 없으면 타입 정보를 포함한 경로 생성
// 같은 타입의 컴포넌트는 같은 타입 식별자를 가지므로 경로가 유지됨
const typeStr = getTypeString(nodeType);
// 타입 이름에서 특수문자 제거 (경로에 사용 가능한 문자만 사용)
const sanitizedType = typeStr.replace(/[^a-zA-Z0-9_$]/g, "_");

return `${parentPath}.c${index}.t${sanitizedType}`;
};
Loading