Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[2팀 노정우] [Chapter 1-2] 프레임워크 없이 SPA 만들기 Part 2 #6

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
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
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
## 과제 체크포인트

### 기본과제

#### 가상돔을 기반으로 렌더링하기

- [x] createVNode 함수를 이용하여 vNode를 만든다.
- [x] normalizeVNode 함수를 이용하여 vNode를 정규화한다.
- [x] createElement 함수를 이용하여 vNode를 실제 DOM으로 만든다.
- [x] updateElement 함수를 이용하여 vNode를 실제 DOM에 업데이트.
- [x] 결과적으로, JSX를 실제 DOM으로 변환할 수 있도록 만들었다.

#### 이벤트 위임

- [x] 노드를 생성할 때 이벤트를 직접 등록하는게 아니라 이벤트 위임 방식으로 등록해야 한다
- [x] 동적으로 추가된 요소에도 이벤트가 정상적으로 작동해야 한다
- [x] 이벤트 핸들러가 제거되면 더 이상 호출되지 않아야 한다

### 심화 과제

#### 1) Diff 알고리즘 구현

- [x] 초기 렌더링이 올바르게 수행되어야 한다
- [x] diff 알고리즘을 통해 변경된 부분만 업데이트해야 한다
- [x] 새로운 요소를 추가하고 불필요한 요소를 제거해야 한다
- [x] 요소의 속성만 변경되었을 때 요소를 재사용해야 한다
- [x] 요소의 타입이 변경되었을 때 새로운 요소를 생성해야 한다

#### 2) 포스트 추가/좋아요 기능 구현

- [ ] 비사용자는 포스트 작성 폼이 보이지 않는다
- [ ] 비사용자는 포스트에 좋아요를 클릭할 경우, 경고 메세지가 발생한다.
- [ ] 사용자는 포스트 작성 폼이 보인다.
- [ ] 사용자는 포스트를 추가할 수 있다.
- [ ] 사용자는 포스트에 좋아요를 클릭할 경우, 좋아요가 토글된다.

## 과제 셀프회고

<!-- 과제에 대한 회고를 작성해주세요 -->

### 기술적 성장

<!-- 예시
- 새로 학습한 개념
- 기존 지식의 재발견/심화
- 구현 과정에서의 기술적 도전과 해결
-->

### 코드 품질

<!-- 예시
- 특히 만족스러운 구현
- 리팩토링이 필요한 부분
- 코드 설계 관련 고민과 결정
-->

### 학습 효과 분석

<!-- 예시
- 가장 큰 배움이 있었던 부분
- 추가 학습이 필요한 영역
- 실무 적용 가능성
-->

### 과제 피드백

<!-- 예시
- 과제에서 모호하거나 애매했던 부분
- 과제에서 좋았던 부분
-->

## 리뷰 받고 싶은 내용

<!--
피드백 받고 싶은 내용을 구체적으로 남겨주세요
모호한 요청은 피드백을 남기기 어렵습니다.

참고링크: https://chatgpt.com/share/675b6129-515c-8001-ba72-39d0fa4c7b62

모호한 요청의 예시)
- 코드 스타일에 대한 피드백 부탁드립니다.
- 코드 구조에 대한 피드백 부탁드립니다.
- 개념적인 오류에 대한 피드백 부탁드립니다.
- 추가 구현이 필요한 부분에 대한 피드백 부탁드립니다.

구체적인 요청의 예시)
- 현재 함수와 변수명을 보면 직관성이 떨어지는 것 같습니다. 함수와 변수를 더 명확하게 이름 지을 수 있는 방법에 대해 조언해주실 수 있나요?
- 현재 파일 단위로 코드가 분리되어 있지만, 모듈화나 계층화가 부족한 것 같습니다. 어떤 기준으로 클래스를 분리하거나 모듈화를 진행하면 유지보수에 도움이 될까요?
- MVC 패턴을 따르려고 했는데, 제가 구현한 구조가 MVC 원칙에 맞게 잘 구성되었는지 검토해주시고, 보완할 부분을 제안해주실 수 있을까요?
- 컴포넌트 간의 의존성이 높아져서 테스트하기 어려운 상황입니다. 의존성을 낮추고 테스트 가능성을 높이는 구조 개선 방안이 있을까요?
-->
33 changes: 26 additions & 7 deletions src/components/posts/Post.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,32 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { globalStore } from "../../stores";

import { toTimeFormat } from "../../utils/index.js";

export const Post = ({
author,
time,
content,
likeUsers,
activationLike = false,
}) => {
export const Post = ({ author, time, content, likeUsers, id }) => {
const { loggedIn, posts, currentUser } = globalStore.getState();
const activationLike =
currentUser && likeUsers.includes(currentUser.username);

Comment on lines +8 to +11

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activationLike를 Post 내부에서 선언해주셨네요 👍
관심사 분리의 목적이었을까요? 의견이 궁금합니다 !

const handleLike = () => {
if (!loggedIn) {
alert("로그인 후 이용해주세요");
} else {
const post = posts.find((post) => post.id === id);
const currentUsername = currentUser.username;
const likeUserIndex = post.likeUsers.indexOf(currentUsername);

if (likeUserIndex === -1) {
post.likeUsers.push(currentUsername);
} else {
post.likeUsers.splice(likeUserIndex, 1);
}

globalStore.setState({ posts });
}
};

return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center mb-2">
Expand All @@ -20,6 +38,7 @@ export const Post = ({
<p>{content}</p>
<div className="mt-2 flex justify-between text-gray-500">
<span
onClick={handleLike}
className={`like-button cursor-pointer${activationLike ? " text-blue-500" : ""}`}
>
좋아요 {likeUsers.length}
Expand Down
19 changes: 19 additions & 0 deletions src/components/posts/PostForm.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { globalStore } from "../../stores";

export const PostForm = () => {
const { currentUser } = globalStore.getState();
const handleAddPost = () => {
const content = document.getElementById("post-content").value;
globalStore.setState({
posts: [
{
id: Date.now(),
author: currentUser.username,
time: Date.now(),
content,
likeUsers: [],
},
...globalStore.getState().posts,
],
});
document.getElementById("post-content").value = "";
};
return (
<div className="mb-4 bg-white rounded-lg shadow p-4">
<textarea
Expand All @@ -11,6 +29,7 @@ export const PostForm = () => {
/>
<button
id="post-submit"
onClick={handleAddPost}
className="mt-2 bg-blue-600 text-white px-4 py-2 rounded"
>
게시
Expand Down
57 changes: 55 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
import { normalizeVNode } from "./normalizeVNode";
import { addEvent } from "./eventManager";

export function createElement(vNode) {}
export function createElement(vNode) {
// 컴포넌트 타입 체크 (정규화 전에 먼저 체크)
if (vNode && typeof vNode === "object" && typeof vNode.type === "function") {
throw new Error("Component should be normalized before createElement");
}

function updateAttributes($el, props) {}
// vNode 정규화
vNode = normalizeVNode(vNode);

// 배열 처리
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
vNode.forEach((child) => {
fragment.appendChild(createElement(child));
});
return fragment;
}

// 문자열이나 숫자인 경우 텍스트 노드 생성
if (typeof vNode === "string" || typeof vNode === "number") {
return document.createTextNode(vNode);
}

// HTML 요소 생성
const $el = document.createElement(vNode.type);

// 속성 업데이트
updateAttributes($el, vNode.props);

// 이벤트 속성 처리
Object.entries(vNode.props || {}).forEach(([key, value]) => {
if (key.startsWith("on")) {
addEvent($el, key.toLowerCase().slice(2), value);
}
});

// 자식 요소들을 재귀적으로 생성하고 추가
vNode.children?.forEach((child) => {
$el.appendChild(createElement(child));
});

return $el;
}

function updateAttributes($el, props = {}) {
Object.entries(props || {}).forEach(([key, value]) => {
if (key === "className") {
$el.setAttribute("class", value);
} else if (key === "htmlFor") {
$el.setAttribute("for", value);
Comment on lines +52 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

디테일이네요.. htmlFor일 경우 for로 대체해주어야 하는군요 !

궁금해서 블로그도 찾아봤네요 :) 👍
https://despiteallthat.tistory.com/179

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. 감사합니다

} else if (!key.startsWith("on")) {
$el.setAttribute(key, value);
}
});
}
8 changes: 7 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
export function createVNode(type, props, ...children) {
return {};
const flatChildren = children
.flat(Infinity)
.filter(
(child) => child !== null && child !== undefined && child !== false,
); // falsy 값 제거

return { type, props, children: flatChildren };
}
84 changes: 81 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,83 @@
export function setupEventListeners(root) {}
// 이벤트 타입별 핸들러 저장
const eventHandlers = new Map();
let rootElement = null;

export function addEvent(element, eventType, handler) {}
function delegateEvent(event) {
const eventType = event.type;

export function removeEvent(element, eventType, handler) {}
const handlers = eventHandlers.get(eventType);
if (!handlers) return;

let target = event.target;
while (target && target !== rootElement?.parentElement) {
const elementHandlers = handlers.get(target);
if (elementHandlers) {
elementHandlers.forEach((handler) => {
handler.call(target, event);
});
if (!event.bubbles) break;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오.. bubbles 프로퍼티를 사용하면 이렇게 버블링하지 않을 때 처리를 할 수 있군요!...👍

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 몰랐습니다ㅎㅎ 감사합니다

target = target.parentElement;
}
}

export function setupEventListeners(root) {
// 이전 root의 이벤트 리스너 제거
if (rootElement && rootElement !== root) {
eventHandlers.forEach((_, eventType) => {
rootElement.removeEventListener(eventType, delegateEvent);
});
}

// 새로운 root 설정
if (rootElement !== root) {
rootElement = root;
// 기존에 등록된 이벤트 타입들에 대해 root에 이벤트 리스너 등록
eventHandlers.forEach((_, eventType) => {
rootElement.addEventListener(eventType, delegateEvent);
});
}
}

export function addEvent(element, eventType, handler) {
// 이벤트 타입에 대한 Map이 없으면 생성
if (!eventHandlers.has(eventType)) {
eventHandlers.set(eventType, new WeakMap());
// root 엘리먼트에 이벤트 리스너 등록
if (rootElement) {
rootElement.addEventListener(eventType, delegateEvent);
}
}

// 엘리먼트의 핸들러 Set이 없으면 생성
const handlers = eventHandlers.get(eventType);
if (!handlers.has(element)) {
handlers.set(element, new Set());
}

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

export function removeEvent(element, eventType, handler) {
const handlers = eventHandlers.get(eventType);
if (!handlers) return;

const elementHandlers = handlers.get(element);
if (!elementHandlers) return;

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

// 엘리먼트의 모든 핸들러가 제거되면 Map에서 제거
if (elementHandlers.size === 0) {
handlers.delete(element);
}

// 해당 이벤트 타입의 모든 핸들러가 제거되었는지 확인
const handlersMap = eventHandlers.get(eventType);
if (!handlersMap || handlersMap.size === 0) {
rootElement?.removeEventListener(eventType, delegateEvent);
eventHandlers.delete(eventType);
}
}
43 changes: 43 additions & 0 deletions src/lib/normalizeVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,46 @@
export function normalizeVNode(vNode) {
// 1. null, undefined, boolean 처리
if (vNode == null || typeof vNode === "boolean") {
return "";
}

// 2. 문자열 또는 숫자 처리
if (typeof vNode === "string" || typeof vNode === "number") {
return String(vNode);
}

// 3. 배열 처리
if (Array.isArray(vNode)) {
return vNode
.map((child) => normalizeVNode(child))
.filter((child) => child !== "" && child != null && child !== false);
}

// 4. 함수형 컴포넌트 처리
if (typeof vNode.type === "function") {
return normalizeVNode(
vNode.type({
...vNode.props,
children: vNode.children,
}),
);
}

// 5. 객체(vNode)가 아닌 경우
if (typeof vNode !== "object") {
return "";
}

// 6. children 배열처리
if (Array.isArray(vNode.children)) {
const normalizedChildren = vNode.children
.map((child) => normalizeVNode(child))
.filter((child) => child !== "" && child != null && child !== false);

return {
...vNode,
children: normalizedChildren,
};
}
return vNode;
}
Loading
Loading