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

[7팀 김영우] [Chapter 1-2] 프레임워크 없이 SPA 만들기 #22

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
32 changes: 25 additions & 7 deletions src/components/posts/Post.jsx

Choose a reason for hiding this comment

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

어떤 액션이 일어나는지 직관적으로 알 수 있어 좋았지만 action에 관련된 내용이 globalStore.js에 들어가면 일관성있어 설계상 더 좋을 것 같습니당!

Copy link
Author

Choose a reason for hiding this comment

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

감사합니다 지금보니 확실히 globalState로 넣어서 처리하는게 더 깔끔해보이네요 피드백 감사드려요!

Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { toTimeFormat } from "../../utils/index.js";
import { globalStore } from "../../stores/index.js";

export const Post = ({ author, time, content, likeUsers }) => {
const { loggedIn, currentUser, posts } = globalStore.getState();
const activationLike = likeUsers.includes(currentUser?.username);
const handleLike = () => {
if (!currentUser) {
alert("로그인 후 이용해주세요");
return;
}

const newPosts = posts.map((post) => {
if (post.author === author && post.time === time) {
return {
...post,
likeUsers: activationLike
? post.likeUsers.filter((user) => user !== currentUser.username)
: [...post.likeUsers, currentUser?.username],
};
}
return post;
});
globalStore.setState({ posts: newPosts });
};

export const Post = ({
author,
time,
content,
likeUsers,
activationLike = false,
}) => {
return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center mb-2">
Expand All @@ -20,6 +37,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

Choose a reason for hiding this comment

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

post.jsx 와 마찬가지로 globalStore.js에 액션을 정리하면 일관성있을 것 같아요!!

Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { globalStore } from "../../stores/index.js";

export const PostForm = () => {
const handleSubmit = () => {
const content = document.getElementById("post-content").value;
if (!content) {
alert("내용을 입력해주세요.");
return;
}
const { posts, currentUser } = globalStore.getState();
const newPost = {
id: posts.length + 1,
author: currentUser?.username,
time: Date.now(),
content,
likeUsers: [],
};

globalStore.setState({ posts: [...posts, newPost] });
};
return (
<div className="mb-4 bg-white rounded-lg shadow p-4">
<textarea
Expand All @@ -10,6 +28,7 @@ export const PostForm = () => {
className="w-full p-2 border rounded"
/>
<button
onClick={handleSubmit}
id="post-submit"
className="mt-2 bg-blue-600 text-white px-4 py-2 rounded"
>
Expand Down
48 changes: 46 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
import { addEvent } from "./eventManager";

export function createElement(vNode) {}
export function createElement(vNode) {
if (
vNode === null ||
typeof vNode === "undefined" ||
typeof vNode === "boolean"
) {
return document.createTextNode("");
}
if (typeof vNode === "string" || typeof vNode === "number") {
return document.createTextNode(`${vNode}`);
}

function updateAttributes($el, props) {}
if (typeof vNode.type === "function" && typeof vNode === "object") {
throw new Error();
}

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

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

function updateAttributes($el, props) {
Object.entries(props || {}).forEach(([key, value]) => {
if (key.startsWith("on")) {
const eventType = key.toLowerCase().substring(2);
addEvent($el, eventType, value);
} else if (key.toLowerCase() === "classname") {
// console.log($el, key, value);
$el.setAttribute("class", value);
} else {
$el.setAttribute(key, value);
}
});
}
11 changes: 10 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
export function createVNode(type, props, ...children) {
return {};
// TODO: 0을 제외한 Falsy 값은 필터링
const filteredChildren = children.flat(Infinity).filter((child) => {
return !(
typeof child === "boolean" ||
typeof child === "undefined" ||
child === null
);
});

return { type, props, children: filteredChildren };
}
70 changes: 67 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,69 @@
export function setupEventListeners(root) {}
const events = new Map();
let $root = null;

export function addEvent(element, eventType, handler) {}
function handleEvent(event) {
const element = event.target;
const elementMap = events.get(element);
if (!elementMap) return;

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

handlers.forEach((handler) => handler(event));
}

export function setupEventListeners(root) {
// TODO: 이벤트 함수를 가져와서 한 번에 root에 이벤트를 등록한다.
if ($root && $root !== root) {
events.forEach((value) => {
$root.removeEventListener(value.eventType, value.handler);
});
}

if ($root !== root) {
$root = root;
events.forEach((value, key) => {
const elementMap = events.get(key);
elementMap.forEach((handlers, eventType) => {
$root.addEventListener(eventType, handleEvent);
});
});
}
}

export function addEvent(element, eventType, handler) {
if (events.has(element)) return;

events.set(element, new Map());

if ($root) {
$root.addEventListener(eventType, handleEvent);
}

const elementMap = events.get(element);
if (!elementMap.has(eventType)) {
elementMap.set(eventType, new Set());
}
elementMap.get(eventType).add(handler);
}

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

const handlers = elementMap.get(eventType);
if (!handlers) return;

handlers.delete(handler);

if (handlers.size === 0) {
elementMap.delete(eventType);
}

if (elementMap.size === 0) {
events.delete(element);
if ($root) {
$root.removeEventListener(eventType, handleEvent);
}
}
}
33 changes: 32 additions & 1 deletion src/lib/normalizeVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
export function normalizeVNode(vNode) {
return vNode;
if (
vNode === null ||
typeof vNode === "boolean" ||
typeof vNode === "undefined"
) {
return "";
}

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

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

if (Array.isArray(vNode)) {
return vNode
.map((child) => normalizeVNode(child))
.filter((child) => child || child === 0);
}

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

const containerMap = new WeakMap();

export function renderElement(vNode, container) {
// 최초 렌더링시에는 createElement로 DOM을 생성하고
// 이후에는 updateElement로 기존 DOM을 업데이트한다.
// 렌더링이 완료되면 container에 이벤트를 등록한다.
// vNode를 정규화한 다음에 createElement로 노드를 만들고, container에 삽입하고, 이벤트를 등록합니다.
const normalizedVNode = normalizeVNode(vNode);

if (containerMap.has(container)) {
const oldNode = containerMap.get(container);
updateElement(container, normalizedVNode, oldNode);
} else {
const element = createElement(normalizedVNode);
container.appendChild(element);
}

setupEventListeners(container);
containerMap.set(container, normalizedVNode);
}
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 removeStaleEvents(target, newProps, oldProps) {
Object.keys(oldProps).forEach((key) => {
if (key.startsWith("on") && typeof oldProps[key] === "function") {
const eventType = key.toLowerCase().substring(2);
const oldHandler = oldProps[key];
const newHandler = newProps[key];

export function updateElement(parentElement, newNode, oldNode, index = 0) {}
if (!newHandler || newHandler !== oldHandler) {
removeEvent(target, eventType, oldHandler);
}
} else if (!newProps[key]) {
target?.removeAttribute(key);
}
});
}

function addNewEvents(target, newProps, oldProps) {
Object.keys(newProps).forEach((key) => {
if (key.startsWith("on")) {
const eventType = key.toLowerCase().substring(2);
const newHandler = newProps[key];
const oldHandler = oldProps[key];

if (!oldHandler || newHandler !== oldHandler) {
addEvent(target, eventType, newHandler);
}
} else if (key.toLowerCase() === "classname") {
target?.setAttribute("class", newProps[key] ?? "");
} else {
target?.setAttribute(key, newProps[key]);
}
});
}

function updateAttributes(target, newProps, oldProps) {
removeStaleEvents(target, newProps, oldProps);
addNewEvents(target, newProps, oldProps);
}

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

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

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

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

updateAttributes(
parentElement?.childNodes[index],
newNode.props || {},
oldNode.props || {},
);

const max = Math.max(newNode.children.length, oldNode.children.length);

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

return (
<div className="bg-gray-100 min-h-screen flex justify-center">
Expand All @@ -20,12 +20,12 @@ 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} />;
})}
</div>
</main>
Expand Down
Loading
Loading