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

[3팀 허원영] [Chapter 1-2] 프레임워크 없이 SPA 만들기 #48

Open
wants to merge 2 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
2 changes: 2 additions & 0 deletions src/components/posts/Post.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const Post = ({
content,
likeUsers,
activationLike = false,
handleLike,
}) => {
return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
Expand All @@ -21,6 +22,7 @@ export const Post = ({
<div className="mt-2 flex justify-between text-gray-500">
<span
className={`like-button cursor-pointer${activationLike ? " text-blue-500" : ""}`}
onClick={handleLike}
>
좋아요 {likeUsers.length}
</span>
Expand Down
9 changes: 8 additions & 1 deletion src/components/posts/PostForm.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";

import { globalStore } from "../../stores";
export const PostForm = () => {
const { addPost } = globalStore.actions;

Choose a reason for hiding this comment

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

createStore에 actions도 따로 정의가 되어 있었군요! 🤩

const handleSubmit = (e) => {
e.preventDefault();
const content = document.getElementById("post-content").value;
addPost(content);
};
return (
<div className="mb-4 bg-white rounded-lg shadow p-4">
<textarea
Expand All @@ -12,6 +18,7 @@ export const PostForm = () => {
<button
id="post-submit"
className="mt-2 bg-blue-600 text-white px-4 py-2 rounded"
onClick={handleSubmit}
>
게시
</button>
Expand Down
52 changes: 50 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
import { addEvent } from "./eventManager";

export function createElement(vNode) {}
/**
* @typedef {Object} VNode
* @property {string|function} type - 요소의 타입
* @property {Object | null} props - 요소의 속성
* @property {Array<*>} children - 요소의 자식 노드
*/

function updateAttributes($el, props) {}
export function createElement(vNode) {
if (
Comment on lines +3 to +11

Choose a reason for hiding this comment

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

원영님 코드를 보면 코드 자체도 읽기 편한데 주석도 굉장히 잘 작성되어 있네요...!👍

Copy link
Author

Choose a reason for hiding this comment

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

감사합니다 ㅎㅎ

vNode === null ||
typeof vNode === "undefined" ||
typeof vNode === "boolean"
) {
return document.createTextNode("");
}

if (typeof vNode === "string" || typeof vNode === "number") {
return document.createTextNode(vNode);
}

// vNode가 배열이면 DocumentFragment를 생성하고 각 자식에 대해 createElement를 재귀 호출하여 추가합니다.
if (vNode instanceof Array) {
const fragment = document.createDocumentFragment();
vNode.forEach((child) => {
fragment.appendChild(createElement(child));
});
return fragment;
}

const $el = document.createElement(vNode.type);
updateAttributes($el, vNode.props);
vNode.children.forEach((child) => {
$el.appendChild(createElement(child));
});

return $el;
}
function updateAttributes($el, props) {
if (props === null) return;

Object.entries(props).forEach(([key, value]) => {
if (key === "className") {
$el.setAttribute("class", value);
} else if (key.startsWith("on") && typeof value === "function") {
const eventType = key.toLowerCase().substring(2);
addEvent($el, eventType, value);
} else {
$el.setAttribute(key, value);
}
});
}
25 changes: 24 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,26 @@
export function createVNode(type, props, ...children) {
return {};
const flatChildren = children
.flatMap((child) => (child instanceof Array ? child.flat() : child))
.filter(
(child) =>
child !== false &&
child !== null &&
child !== undefined &&
child !== "" &&
child !== true,
);

return {
type,
props,
children: flatChildren,
};
}

// <div id="test" className="old">
// Hello
// </div>

// const test = createVNode("div", { id: "test", className: "old" }, "Hello");

// console.log(test);
56 changes: 53 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
export function setupEventListeners(root) {}
/**
* idea
* 이벤트가 걸려있는 요소를 저장하여 위임된 이벤트를 처리한다.
*/

export function addEvent(element, eventType, handler) {}
/**
* eventHandlers Map
* [eventType: WeakMap
* element: handler
* ]
*/

export function removeEvent(element, eventType, handler) {}
export const eventMap = new Map();
function handleEvent(e) {
const eventType = e.type;
const elementEventMap = eventMap.get(eventType);

if (!elementEventMap || !elementEventMap.has(e.target)) {
return;
}
const handler = elementEventMap.get(e.target);
handler(e);
}

export function setupEventListeners(root) {
if (!eventMap.size) {
return;
}

eventMap.forEach((elementEventMap, eventType) => {
root.addEventListener(eventType, handleEvent);
});
}

export function addEvent(element, eventType, handler) {
if (!eventMap.has(eventType)) {
eventMap.set(eventType, new WeakMap());
}

const elementEventMap = eventMap.get(eventType);
elementEventMap.set(element, handler);
}

export function removeEvent(element, eventType) {
const elementEventMap = eventMap.get(eventType);
if (!elementEventMap || !elementEventMap.has(element)) {
return;
}

elementEventMap.delete(element);

if (elementEventMap.size === 0) {
eventMap.delete(eventType);
}
}
24 changes: 23 additions & 1 deletion src/lib/normalizeVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
export function normalizeVNode(vNode) {
return vNode;
if (
vNode === null ||
typeof vNode === "undefined" ||
typeof vNode === "boolean"
) {
return "";
}

if (typeof vNode === "string" || typeof vNode === "number") {
return vNode.toString();
}

if (typeof vNode.type === "function") {
const temp = vNode.type({ children: vNode.children, ...vNode.props });
return normalizeVNode(temp);
}

const children = vNode.children.map((child) => normalizeVNode(child));

return {
...vNode,
children,
};
}
17 changes: 13 additions & 4 deletions src/lib/renderElement.js

Choose a reason for hiding this comment

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

이번에 코치님께 이런 피드백을 받았어요~!!
원영님께도 도움이 될 것 같아서 공유드립니다 🙇‍♀️ 👍

스크린샷 2024-12-28 오후 2 17 43

Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@ import { setupEventListeners } from "./eventManager";
import { createElement } from "./createElement";
import { normalizeVNode } from "./normalizeVNode";
import { updateElement } from "./updateElement";

export function renderElement(vNode, container) {
// 최초 렌더링시에는 createElement로 DOM을 생성하고
// 이후에는 updateElement로 기존 DOM을 업데이트한다.
// 렌더링이 완료되면 container에 이벤트를 등록한다.
if (!container._oldVNode) {
container._oldVNode = null;
}

const normalizedVNode = normalizeVNode(vNode);
if (!container._oldVNode) {
container.appendChild(createElement(normalizedVNode));
} else {
updateElement(container, normalizedVNode, container._oldVNode);
}

setupEventListeners(container);
container._oldVNode = normalizedVNode;
Comment on lines +6 to +18

Choose a reason for hiding this comment

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

저는 변수를 따로 만들어둔 다음에 oldVNode를 저장해뒀는데, container에 _oldVNode를 프로터피 바인딩을 사용하는 방식이 더 깔끔해보이는 것 같아요!

}
80 changes: 78 additions & 2 deletions src/lib/updateElement.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,82 @@
import { addEvent, removeEvent } from "./eventManager";
import { createElement } from "./createElement.js";

function updateAttributes(target, originNewProps, originOldProps) {}
function updateAttributes(target, originNewProps, originOldProps) {
const newProps = originNewProps || {};
const oldProps = originOldProps || {};

export function updateElement(parentElement, newNode, oldNode, index = 0) {}
for (const key in newProps) {
if (oldProps[key] !== newProps[key]) {
if (key === "className") {
target.setAttribute("class", newProps[key]);
} else if (key.startsWith("on") && typeof newProps[key] === "function") {
const eventName = key.toLowerCase().substring(2);
// removeEvent(target, eventName);

Choose a reason for hiding this comment

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

저는 주석은 항상 지워두는 편인데, 예전에 다니던 회사에서는 남겨두는 경우도 많이 본 것 같아요..!
원영님은 주석에 대해서 어떻게 생각하시는지 궁금합니다ㅎㅎ

Copy link
Author

Choose a reason for hiding this comment

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

사실 저런 주석은 지우는게 맞는거 같아요 ㅋㅋ 코치님도 사용하지 않는 주석을 지우라고 피드백 주셨더라구요...

addEvent(target, eventName, newProps[key]);
} else {
target.setAttribute(key, newProps[key]);
}
}
}

// a만 있고 b에는 없는 속성 제거
for (const key in oldProps) {
if (!(key in newProps)) {
if (key === "className") {
target.removeAttribute("class");
} else if (key.startsWith("on") && typeof oldProps[key] === "function") {
const eventName = key.toLowerCase().substring(2);
removeEvent(target, eventName);
} else {
target.removeAttribute(key);
}
}
}
}

export function updateElement(parentElement, newNode, oldNode, index = 0) {
if (!newNode) {
parentElement.removeChild(parentElement.children[index]);
return;
}
if (!oldNode) {
parentElement.appendChild(createElement(newNode));
return;
}

if (typeof newNode === "string" || typeof newNode === "number") {
if (newNode !== oldNode) {
parentElement.childNodes[index].nodeValue = newNode; // 변경된 코드
}
return;
}

if (newNode.type !== oldNode.type) {
parentElement.replaceChild(
createElement(newNode),
parentElement.children[index],
);
return;
}

updateAttributes(parentElement.children[index], newNode.props, oldNode.props);

const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];

for (let i = 0; i < newChildren.length; i++) {
updateElement(
parentElement.children[index],
newChildren[i],
oldChildren[i],
i,
);
}
if (oldChildren.length > newChildren.length) {
for (let i = newChildren.length; i < oldChildren.length; i++) {
parentElement.children[index].removeChild(
parentElement.children[index].children[newChildren.length],
);
}
}
}
21 changes: 18 additions & 3 deletions src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ import { globalStore } from "../stores";
* - 로그인하지 않은 사용자가 게시물에 좋아요를 누를 경우, "로그인 후 이용해주세요"를 alert로 띄운다.
*/
export const HomePage = () => {
const { posts } = globalStore.getState();
const { posts, loggedIn } = globalStore.getState();
const { likePost } = globalStore.actions;
const handleLike = (postId) => {
if (!loggedIn) {
alert("로그인 후 이용해주세요");
return;
}
likePost(postId);
};

return (
<div className="bg-gray-100 min-h-screen flex justify-center">
Expand All @@ -20,12 +28,19 @@ export const HomePage = () => {
<Navigation />

<main className="p-4">
<PostForm />
{loggedIn && <PostForm />}
<div id="posts-container" className="space-y-4">
{[...posts]
.sort((a, b) => b.time - a.time)
.map((props) => {
return <Post {...props} activationLike={false} />;
return (
<Post
{...props}
activationLike={props.likeUsers.length > 0}
loggedIn={loggedIn}
handleLike={() => handleLike(props.id)}
/>
);
})}
</div>
</main>
Expand Down
21 changes: 21 additions & 0 deletions src/stores/globalStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,26 @@ export const globalStore = createStore(
userStorage.reset();
return { ...state, currentUser: null, loggedIn: false };
},
likePost(state, postId) {

Choose a reason for hiding this comment

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

이번주 수고하셨습니다. 좋아요 기능을 여기서 관리하는게 깔끔하군요

const post = state.posts.find((post) => post.id === postId);
if (post.likeUsers.includes(state.currentUser.username)) {
post.likeUsers = post.likeUsers.filter(
(username) => username !== state.currentUser.username,
);
} else {
post.likeUsers.push(state.currentUser.username);
}
return { ...state, posts: [...state.posts] };
},
addPost(state, content) {
const newPost = {
id: state.posts.length + 1,
author: state.currentUser.username,
time: Date.now(),
content,
likeUsers: [],
};
return { ...state, posts: [newPost, ...state.posts] };
},
},
);
Loading