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

[13팀 김유진] [Chapter 1-2] 프레임워크 없이 SPA 만들기 #28

Open
wants to merge 3 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
4 changes: 3 additions & 1 deletion src/components/posts/Post.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ export const Post = ({
content,
likeUsers,
activationLike = false,
onLike,
}) => {
return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="bg-white rounded-lg shadow p-4 mb-4" id="post">
<div className="flex items-center mb-2">
<div>
<div className="font-bold">{author}</div>
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={onLike}
>
좋아요 {likeUsers.length}
</span>
Expand Down
23 changes: 23 additions & 0 deletions src/components/posts/PostForm.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { addEvent, removeEvent } from "../../lib/";
import { postStore } from "../../stores";
import { userStorage } from "../../storages";

export const PostForm = () => {
const handleSubmit = () => {
console.log("포스트 작성 이벤트 등록");
Copy link

Choose a reason for hiding this comment

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

콘솔로그가 귀엽게 남아있어용ㅎㅎ

const content = document.getElementById("post-content").value;
if (!content.trim()) {
alert("내용을 입력해주세요!");
return;
}

postStore.actions.addPost({
author: userStorage.get().username,
time: new Date(),
content: content,
likeUsers: [],
});

// 입력창 초기화
document.getElementById("post-content").value = "";
};

return (
<div className="mb-4 bg-white rounded-lg shadow p-4">
<textarea
Expand All @@ -12,6 +34,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
44 changes: 42 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
import { addEvent } from "./eventManager";

export function createElement(vNode) {}
function updateAttributes($el, props) {
Object.entries(props).forEach(([attr, value]) => {
if (attr.startsWith("on") && typeof value === "function") {
addEvent($el, attr.slice(2).toLowerCase(), value);
} else if (attr === "className") {
$el.setAttribute("class", value);
} else {
$el.setAttribute(attr, value);
}
});
}

function updateAttributes($el, props) {}
export function createElement(vNode) {
if (vNode === null || vNode === undefined || typeof vNode === "boolean")
return document.createTextNode("");

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

if (Array.isArray(vNode)) {
console.log("vNode Array", vNode);
const fragment = document.createDocumentFragment();
vNode.forEach((child) => {
if (child) {
fragment.appendChild(createElement(child));
}
});
return fragment;
}

const $el = document.createElement(vNode.type);

if (vNode.props) {
updateAttributes($el, vNode.props);
}

if (vNode.children) {
$el.append(...vNode.children.map(createElement));
Copy link

Choose a reason for hiding this comment

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

저는 appendChild만 알고 있었어서 append는 모지..?! 하고 찾아보니 용도는 비슷하지만

  • appendChild
    • DOMNode만 추가 가능
    • 하나의 요소만 추가 가능
  • append
    • DOMNode뿐만 아니라 text도 추가 가능
    • 여러개의 요소를 한 번에 추가 가능

요런 차이점이 있더라구요! append가 더 최신 문법이고 유용한 것 같아용 하나 배워갑니다~~✨

}

return $el;
}
27 changes: 26 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
export function createVNode(type, props, ...children) {
return {};
// props.ref로 이벤트 등록
if (props?.ref && typeof props.ref === "function") {
setTimeout(() => {
const element = document.querySelector(`[id="${props.id}"]`);
if (element) {
// 이전에 등록된 클린업 함수가 있다면 실행
if (element._cleanup) {
element._cleanup();
}

// 새로운 ref 콜백 실행 및 반환된 클린업 함수 저장
const cleanup = props.ref(element.parentElement);
if (typeof cleanup === "function") {
element._cleanup = cleanup;
}
}
}, 0);
Copy link

Choose a reason for hiding this comment

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

 setTimeout(() => {
      const element = document.querySelector(`[id="${props.id}"]`);
      if (element) {
        // 이전에 등록된 클린업 함수가 있다면 실행
        if (element._cleanup) {
          element._cleanup();
        }

        // 새로운 ref 콜백 실행 및 반환된 클린업 함수 저장
        const cleanup = props.ref(element.parentElement);
        if (typeof cleanup === "function") {
          element._cleanup = cleanup;
        }
      }
    }, 0);

저는 처음에 setTimeout이 0초로 설정되어있어서 이러면 타임아웃을 거는 이유가 뭘까 생각해보고 찾아봤는데, 0으로 설정해도 바로 실행되는게 아니라는 것을 배우고 갑니다 ㅎ

dom이 다 그려진 이후에 클린업을 체크하려고 넣으신걸로 이해했는데 제가 맞게 이해한게 맞을까요??

}

return {
type,
props,
children: children
.flat(Infinity)
.filter((value) => value === 0 || Boolean(value)),
};
}
54 changes: 51 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
export function setupEventListeners(root) {}
const eventMap = new Map();
let rootElement = null;

export function addEvent(element, eventType, handler) {}
function handleEvent(event) {
let target = event.target;
while (target && target !== rootElement) {
const elementHandlers = eventMap.get(event.type)?.get(target);
if (elementHandlers) {
elementHandlers.forEach((handler) => handler(event));
}
target = target.parentElement;
}
}

export function removeEvent(element, eventType, handler) {}
export function setupEventListeners(root) {
rootElement = root;
eventMap.forEach((handlers, eventType) => {
rootElement.removeEventListener(eventType, handleEvent);
rootElement.addEventListener(eventType, handleEvent);
});
}

export function addEvent(element, eventType, handler) {
if (!eventMap.has(eventType)) {
eventMap.set(eventType, new WeakMap());
}
const elementMap = eventMap.get(eventType);
if (!elementMap.has(element)) {
elementMap.set(element, new Set());
}
elementMap.get(element).add(handler);
}

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

const handlers = elementMap.get(element);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
elementMap.delete(element);
}
}

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

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

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

return {
...vNode,
children: vNode.children.map(normalizeVNode).filter(Boolean),
};
}
14 changes: 14 additions & 0 deletions src/lib/renderElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,22 @@ import { createElement } from "./createElement";
import { normalizeVNode } from "./normalizeVNode";
import { updateElement } from "./updateElement";

const OldNodeMap = new WeakMap();

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

const oldNode = OldNodeMap.get(container);
const newNode = normalizeVNode(vNode);

if (oldNode) {
updateElement(container, newNode, oldNode);
} else {
container.appendChild(createElement(newNode));
}

OldNodeMap.set(container, newNode);
setupEventListeners(container);
}
90 changes: 88 additions & 2 deletions src/lib/updateElement.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,92 @@
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 attr in oldProps) {
if (!(attr in newProps)) {
if (attr.startsWith("on") && typeof newProps[attr] !== "function") {
removeEvent(target, attr.slice(2).toLowerCase(), oldProps[attr]);
} else {
target.removeAttribute(attr);
}
}
}

for (const attr in newProps) {
if (oldProps[attr] !== newProps[attr]) {
if (attr === "className") {
target.className = newProps[attr];
} else if (
attr.startsWith("on") &&
typeof newProps[attr] === "function"
) {
const eventName = attr.slice(2).toLowerCase();
if (typeof oldProps[attr] === "function") {
removeEvent(target, eventName, oldProps[attr]);
}
addEvent(target, eventName, newProps[attr]);
} else if (attr === "style" && typeof newProps[attr] === "object") {
Object.entries(newProps[attr]).forEach(([key, value]) => {
target.style[key] = value;
});
} else {
target.setAttribute(attr, newProps[attr]);
}
}
}
}

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

if (!parentElement.childNodes[index]) {
parentElement.appendChild(createElement(newNode));
return;
}

if (!oldNode && newNode) {
parentElement.appendChild(createElement(newNode));
return;
}

if (typeof newNode === "string" || typeof newNode === "number") {
if (newNode !== oldNode) {
const newTextNode = document.createTextNode(String(newNode));
parentElement.replaceChild(newTextNode, parentElement.childNodes[index]);
}
return;
}

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

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

const newChildren = newNode.children || [];
const oldChildren = oldNode.children || [];
const maxLength = Math.max(newChildren.length, oldChildren.length);

for (let i = 0; i < maxLength; i++) {
updateElement(
parentElement.childNodes[index],
newChildren[i],
oldChildren[i],
i,
);
}
}
34 changes: 29 additions & 5 deletions src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
/** @jsx createVNode */
import { createVNode } from "../lib";

import { addEvent } from "../lib/";
import { Footer, Header, Navigation, Post, PostForm } from "../components";
import { globalStore } from "../stores";
import { userStorage } from "../storages";
import { postStore } from "../stores";

/**
* 심화과제
Expand All @@ -11,7 +13,18 @@ import { globalStore } from "../stores";
* - 로그인하지 않은 사용자가 게시물에 좋아요를 누를 경우, "로그인 후 이용해주세요"를 alert로 띄운다.
*/
export const HomePage = () => {
const { posts } = globalStore.getState();
console.log("HomePage 렌더링");
const { posts, loggedIn } = globalStore.getState();

const handleLike = (index) => {
const currentUser = userStorage.get();
if (!currentUser) {
alert("로그인 후 이용해주세요");
return;
}

postStore.actions.toggleLike(index, currentUser.username);
};

return (
<div className="bg-gray-100 min-h-screen flex justify-center">
Expand All @@ -20,12 +33,23 @@ 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} />;
.map((props, index) => {
const currentUser = userStorage.get()?.username;
const activationLike = currentUser
? props.likeUsers.includes(currentUser)
: false;
return (
<Post
{...props}
activationLike={activationLike}
onLike={() => handleLike(index)}
/>
);
})}
</div>
</main>
Expand Down
1 change: 1 addition & 0 deletions src/stores/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./globalStore";
export * from "./postStore";
Loading
Loading