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

[9팀 박지수] [Chapter 1-2] 프레임워크 없이 SPA 만들기 #17

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import eslintPluginPrettier from "eslint-plugin-prettier/recommended";
/** @type {import('eslint').Linter.Config[]} */
export default [
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
{ rules: { "prettier/prettier": ["error", { endOfLine: "auto" }] } },
pluginJs.configs.recommended,
eslintPluginPrettier,
eslintConfigPrettier,
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/chapter1-2/basic.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ describe("Chapter1-2 > 기본과제 > 가상돔 만들기 > ", () => {
const clickHandler = vi.fn();
const initialVNode = (
<div>
<button onClick={clickHandler}>Button</button>
<button onClick={clickHandler}>Buttonnnn</button>
</div>
);
renderElement(initialVNode, $container);
Expand Down
36 changes: 29 additions & 7 deletions src/components/posts/Post.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { globalStore } from "../../stores/globalStore.js";
import { toTimeFormat } from "../../utils/index.js";

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

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

globalStore.setState(({ posts, ...rest }) => {
const foundIndex = posts.findIndex((post) => post.author === author);

const updatedPost = {
...posts[foundIndex],
likeUsers: activationLike
? likeUsers.filter((likeUser) => likeUser !== currentUser.username)
: [...likeUsers, currentUser.username],
};

const clonedPosts = [...posts];
clonedPosts[foundIndex] = updatedPost;

return { ...rest, posts: clonedPosts };
});
};

return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center mb-2">
Expand All @@ -21,6 +42,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={toggleLike}
>
좋아요 {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 { globalStore } from "../../stores";

export const PostForm = () => {
const handleSubmit = () => {
const $textarea = document.querySelector("#post-content");
const value = $textarea.value;

globalStore.setState((prev) => {
const { username } = prev.currentUser;
const postIds = prev.posts.map((post) => post.id);
const id = Math.max(...postIds) + 1;

const newPost = {
id,
author: username,
time: Date.now(),
content: value,
likeUsers: [],
};

return { ...prev, posts: [...prev.posts, newPost] };
});
};

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
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 { addEvent } from "./eventManager";
import { isInvalidValue } from "./helpers";

export function createElement(vNode) {}
export function createElement(vNode) {
if (isInvalidValue(vNode)) {
return document.createTextNode("");
}

function updateAttributes($el, props) {}
if (typeof vNode === "number" || typeof vNode === "string") {
return document.createTextNode(vNode);
}

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

if (typeof vNode === "object" && typeof vNode.type === "function") {
throw new Error("컴포넌트는 `createElement()`로 처리할 수 없습니다.");
}

const { type, props, children } = vNode;
const $el = document.createElement(type);

updateAttributes($el, props);

const elements = children.map(createElement);
elements.forEach((child) => $el.appendChild(child));

return $el;
}

// Utils
function updateAttributes($el, props) {
Object.entries(props || {}).forEach(([key, value]) => {
// 스타일 속성 처리
if (key === "style") {
$el.style = value;
return;
}

// 클래스 속성 처리
if (key === "className") {
$el.setAttribute("class", value);
return;
}

// 이벤트 핸들러 처리
if (key.startsWith("on") && typeof value === "function") {
addEvent($el, key.slice(2).toLowerCase(), value);
return;
}

// 나머지 속성 처리
$el.setAttribute(key, value);
});
}
2 changes: 1 addition & 1 deletion src/lib/createObserver.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export const createObserver = () => {
const listeners = new Set();
const subscribe = (fn) => listeners.add(fn);
const notify = () => listeners.forEach((listener) => listener());
const notify = (...args) => listeners.forEach((listener) => listener(args));

return { subscribe, notify };
};
7 changes: 5 additions & 2 deletions src/lib/createStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ export const createStore = (initialState, initialActions) => {
let state = { ...initialState };

const setState = (newState) => {
state = { ...state, ...newState };
notify();
const setter = newState;
const result = typeof newState === "function" ? setter(state) : newState;

state = { ...state, ...result };
notify(state);
};

const getState = () => ({ ...state });
Expand Down
11 changes: 10 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import { isValidValue } from "./helpers";

export function createVNode(type, props, ...children) {
return {};
// NOTE: 왜 여기서 바로 normalizeVNode를 돌리면 안 될까?
// return normalizeVNode({ type, props, children });

return {
type,
props,
children: children.flat(Infinity).filter(isValidValue),
};
}
103 changes: 100 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,102 @@
export function setupEventListeners(root) {}
// NOTE: 학습메이트(최기환)님 꿀팁 듣고 다시 짠 로직
// * WeakMap 사용하고 있는데 이는 메모리 관리에 이점을 가지기 위함일까?
const listeners = new WeakMap();

export function addEvent(element, eventType, handler) {}
// NOTE: Event Type에 대한 생각
// 1. `addEvent()` 당시 event type만 따로 저장하다가
// `setupEventListeners()`가 실행될 때 사용되는 이벤트 타입들만 event listener에 등록 하는게 옳은지(현재)
//
// 2. 미리 사용될 것 같은 event type들을 미리 선언하고
// `setupEventListeners()`가 실행될 때 모두 등록 하는게 옳은지
let eventTypes = [];

export function removeEvent(element, eventType, handler) {}
export function setupEventListeners($root) {
eventTypes.forEach((eventType) => {
$root.addEventListener(eventType, handleDelegatedEvent);
});
}

export function addEvent(element, eventType, handler) {
if (!eventTypes.includes(eventType)) {
eventTypes.push(eventType);
}

const elementListeners = listeners.get(element) || [];
elementListeners.push({ eventType, handler });

listeners.set(element, elementListeners);
}

export function removeEvent(element, eventType, handler) {
if (!listeners.has(element)) return;

const elementListeners = listeners.get(element);
const updatedElementListeners = elementListeners.filter((elementListener) => {
const sameEventType = elementListener.eventType === eventType;
const sameHandler = elementListener.handler === handler;
return !(sameEventType && sameHandler);
});

if (updatedElementListeners.length === 0) {
listeners.delete(element);
return;
}

listeners.set(element, updatedElementListeners);
}

function handleDelegatedEvent(e) {
if (!listeners.has(e.target)) return;

const elementListeners = listeners.get(e.target);
const filtered = elementListeners.filter(
({ eventType }) => eventType === e.type,
);

filtered.forEach(({ handler }) => handler(e));
}

// // NOTE: 이전 작업물
// // * 이벤트가 제거되지 않는 문제로 고민하다가 학습메이트(최기환)님에게 상담
// // * 개선된 로직을 제안 받음. 하지만 아직도 왜 이벤트가 제거 되지 않는 지 모르겠음
// // * `$root`도 같고 등록된 `eventType`과 `handler`도 동일 함
// const events = [];
// let $root = null;

// export function setupEventListeners(_$root) {
// $root = _$root;

// events.forEach(({ eventType, handler }) => {
// $root.addEventListener(eventType, handler);
// });
// }

// export function addEvent(element, eventType, handler) {
// const isIncluded = events.some((event) => {
// return isSameEvent(event, { element, eventType, handler });
// });

// if (isIncluded) return;

// events.push({ element, eventType, handler });
// }

// export function removeEvent(element, eventType, handler) {
// const found = events.find((event) => {
// return isSameEvent(event, { element, eventType, handler });
// });

// if (!found) return;

// events.filter((event) => {
// return !isSameEvent(event, { element, eventType, handler });
// });
// $root.removeEventListener(found.eventType, found.handler);
// }

// function isSameEvent(a, b) {
// const isSameElement = a.element === b.element;
// const isSameEventType = a.eventType === b.eventType;
// const isSameHandler = a.handler === b.handler;
// return isSameElement && isSameEventType && isSameHandler;
// }
9 changes: 9 additions & 0 deletions src/lib/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const blacklist = [null, undefined, true, false, ""];

export function isValidValue(value) {
return !blacklist.includes(value);
}

export function isInvalidValue(value) {
return blacklist.includes(value);
}
24 changes: 24 additions & 0 deletions src/lib/normalizeVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
import { isInvalidValue, isValidValue } from "./helpers";

export function normalizeVNode(vNode) {
if (isInvalidValue(vNode)) {
return "";
}

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

if (Array.isArray(vNode)) {
return vNode.flat(Infinity).filter(isValidValue).map(normalizeVNode);
}

if (typeof vNode === "object") {
const { type, props, children } = vNode;

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

return { type, props, children: normalizeVNode(children) };
}

return vNode;
}
19 changes: 18 additions & 1 deletion src/lib/renderElement.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import { setupEventListeners } from "./eventManager";
import { createElement } from "./createElement";
import { setupEventListeners } from "./eventManager";
import { normalizeVNode } from "./normalizeVNode";
import { updateElement } from "./updateElement";

let oldNode = null;

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

container.append(node);
setupEventListeners(container);
} else {
const newNode = normalizeVNode(vNode);

updateElement(container, newNode, oldNode);
setupEventListeners(container);

oldNode = newNode;
}
}
Loading
Loading