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

[8팀 최준만] [Chapter 1-2] 프레임워크 없이 SPA 만들기 #2

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
37ac031
Create README.md
junman95 Dec 21, 2024
46c2252
feat: createVNode 함수 구현
junman95 Dec 23, 2024
ba6557b
feat: normalize 구현
junman95 Dec 23, 2024
17df315
fix: 종단에서 props 강제 합성하는 코드가 아닌 props 스프레드를 통한 전달로 재 구현
junman95 Dec 23, 2024
e398fa5
feat: createElement 구현
junman95 Dec 23, 2024
1c6fc3a
feat: eventManager 구현
junman95 Dec 24, 2024
51a666e
feat: renderElement 구현
junman95 Dec 24, 2024
0ff32e7
docs:-----------------기본 및 1-1 과제 통과-----------------------
junman95 Dec 24, 2024
245951e
feat: diff로직 구현
junman95 Dec 25, 2024
05b5ecc
feat: className-> class 변환함수 외부로 분리
junman95 Dec 25, 2024
e209f2b
feat: 속성 업데이트 구현
junman95 Dec 25, 2024
96c087f
fix: 이벤트 리스너 remove시 참조 주소 상이하여 제거 안되던 이슈 해결
junman95 Dec 25, 2024
2e255fc
fix: attr 할당 함수 재 작성을 통한 이슈 해결(원인 파악 필요)
junman95 Dec 25, 2024
e26bbb5
fix: 생성 로직 순서 개선 및 초기화 코드 제거를 통해 1-1 이슈 해결(원인 파악 필요)
junman95 Dec 25, 2024
0a3d138
feat: 코드 개선
junman95 Dec 25, 2024
11e904a
fix: 텍스트 노드 업데이트 시 parentElement의 textContent에 바로 삽입하게 되면 순서 보장이안되고 덮…
junman95 Dec 25, 2024
c7c7541
feat: 포스트 기능 구현
junman95 Dec 25, 2024
e5ad353
fix: 이벤트 할당 후 리턴해주지 않아 on~ 형태의 속성 할당되는 이슈 해결
junman95 Dec 25, 2024
1765bc8
fix: 미로그인 시 alert 후 함수 종료 토록 수정
junman95 Dec 25, 2024
3db4eae
docs: readme update
junman95 Dec 26, 2024
70f9469
fix: 속성으로 처리하여 로그아웃 후에도 좋아요의 색상이 여전히 파란색인 이슈 username 포함 여부 확인 로직으로 변경
junman95 Dec 27, 2024
d38022a
refactor: 코드 개선
junman95 Dec 27, 2024
20ff127
feat: postform 내용물 없을때도 정상 동작하도록 개선
junman95 Dec 27, 2024
a1339b7
feat: 이벤트 버블링 구현
junman95 Dec 27, 2024
f5f40fc
refactor: 코드 정리
junman95 Dec 27, 2024
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
480 changes: 480 additions & 0 deletions README.md

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions src/__tests__/chapter1-2/custom.dom.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/** @jsx createVNode */
/** @jsxFrag Fragment */
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import userEvent from "@testing-library/user-event";
import { globalStore } from "../../stores/globalStore.js";

let user;

beforeAll(async () => {
// DOM 초기화
window.alert = vi.fn();
document.body.innerHTML = '<div id="root"></div>';
await import("../../main.jsx");
});

afterAll(() => {
// 각 테스트 전에 root 엘리먼트 초기화
document.getElementById("root").innerHTML = "";
localStorage.removeItem("user");
});

beforeEach(() => {
user = userEvent.setup();
});

const goTo = (path) => {
window.history.pushState({}, "", path);
window.dispatchEvent(new Event("popstate"));
};

describe("Chapter1-2 > 심화과제 > 포스트 관리", () => {
describe("사용자 > ", () => {
beforeEach(async () => {
goTo("/login");
const loginForm = document.getElementById("login-form");

await user.type(document.getElementById("username"), "testuser");

loginForm.dispatchEvent(
new SubmitEvent("submit", { bubbles: true, cancelable: true }),
);

goTo("/");
});

afterEach(async () => {
await user.click(document.getElementById("logout"));
});

it("포스트가 하나도 없을 때도,포스트를 추가할 수 있다.", async () => {
globalStore.setState({
posts: [],
});

await user.type(
document.getElementById("post-content"),
"새로운 포스트입니다.",
);
await user.click(document.getElementById("post-submit"));

expect(globalStore.getState().posts.length).toBe(1);
expect(globalStore.getState().posts[0].id).toBe(1);
expect(globalStore.getState().posts[0].content).toBe(
"새로운 포스트입니다.",
);
});
});
});
61 changes: 61 additions & 0 deletions src/__tests__/chapter1-2/custom.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* 개인적으로 만든 순수함수들에 대한 테스트 입니다.
*/

import { describe } from "vitest";
import { recursiveFlatten } from "../../utils/arrayUtils";
import { renderElement } from "../../lib";

describe("유틸 함수 체크", () => {
describe("recursiveFlatten", () => {
it("올바른 구조의 평탄화된 배열을 생성해야 한다", () => {
const vNode = recursiveFlatten([
"Hello",
["world", ["i", "am", ["junman"]], "!"],
]);
expect(vNode).toEqual(["Hello", "world", "i", "am", "junman", "!"]);
});

it("falsy한 값은 포함되지 않아야 한다.", () => {
const vNode = recursiveFlatten(
["Hello", [null, [undefined], "world", "!"]],
(value) => Boolean(value),
);

expect(vNode).toEqual(["Hello", "world", "!"]);
});
});
});
junman95 marked this conversation as resolved.
Show resolved Hide resolved

/** @jsx createVNode */
/** @jsxFrag Fragment */
import { createVNode, Fragment } from "../../lib";

describe("이벤트 매니저 버블링 테스트", () => {
let container;

beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});

afterEach(() => {
document.body.removeChild(container);
});
it("이벤트가 버블링 되는 것 처럼 동작해야한다.", () => {
const clickHandler1 = vi.fn();
const clickHandler2 = vi.fn();

const Component = () => (
<div id="parent" onClick={clickHandler1}>
<div id="child" onClick={clickHandler2}></div>
</div>
);
renderElement(<Component />, container);

const child = container.querySelector("#child");
child.click();
expect(clickHandler1).toHaveBeenCalledTimes(1);
expect(clickHandler2).toHaveBeenCalledTimes(1);
});
});
21 changes: 12 additions & 9 deletions src/components/posts/Post.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { addEvent, 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 = ({ id, author, time, content, likeUsers }) => {
const { currentUser } = globalStore.getState();
const { like } = globalStore.actions;

const onClickLike = () => {
like(id);
};

return (
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center mb-2">
Expand All @@ -20,7 +22,8 @@ export const Post = ({
<p>{content}</p>
<div className="mt-2 flex justify-between text-gray-500">
<span
className={`like-button cursor-pointer${activationLike ? " text-blue-500" : ""}`}
className={`like-button cursor-pointer${likeUsers.includes(currentUser?.username) ? " text-blue-500" : ""}`}
Copy link

Choose a reason for hiding this comment

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

저는 제시된 activationLike를 그대로 사용했는데, 어차피 likeUsers를 전달해주니, 굳이 위에서 체크할 필요가 없었군요..
꼼꼼하게 체크하신게 느껴집니다 👍

onClick={onClickLike}
>
좋아요 {likeUsers.length}
</span>
Expand Down
22 changes: 22 additions & 0 deletions src/components/posts/PostForm.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
/** @jsx createVNode */
import { createVNode } from "../../lib";
import { globalStore } from "../../stores";

export const PostForm = () => {
const { currentUser, loggedIn, posts } = globalStore.getState();

if (!loggedIn) {
return null;
}

const onClickAddPost = () => {
globalStore.setState({
posts: [
{
id: posts ? posts.length + 1 : 1,
author: currentUser.username,
time: Date.now(),
content: document.getElementById("post-content").value,
likeUsers: [],
Comment on lines +18 to +20

Choose a reason for hiding this comment

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

document.getElementById로 ID를 하드코딩하는 것보다 event를 prop으로 받아서 event.target으로 접근하는 방법이 더 안정적일 것 같은데 어떻게 생각히사는지요?!

Copy link
Author

Choose a reason for hiding this comment

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

어떤식으로 해야할지 살짝 감이 오지 않습니다.
참고할 커밋 있을까요?
내일 같이 코드리뷰 시간에 얘기해보면 좋을 것 같아요!

},
...posts,
],
});
};
return (
<div className="mb-4 bg-white rounded-lg shadow p-4">
<textarea
Expand All @@ -12,6 +33,7 @@ export const PostForm = () => {
<button
id="post-submit"
className="mt-2 bg-blue-600 text-white px-4 py-2 rounded"
onClick={onClickAddPost}
>
게시
</button>
Expand Down
59 changes: 57 additions & 2 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,60 @@
import {
checkNullishExceptZero,
replaceIfPropIsClass,
} from "../utils/commonUtils";
import { addEvent } from "./eventManager";

export function createElement(vNode) {}
export function createElement(vNode) {
if (typeof vNode === "function" || typeof vNode?.type === "function") {
Copy link

Choose a reason for hiding this comment

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

normalizeVNode.jsvNode.type === "function"를 통해
컴포넌트 함수는 다 처리된 후 createElement로 전달될 것 같은데 혹시 한번 더 체크하시는 이유가 있으신지, 예외케이스가 있으셨는지 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

오~ 예리하십니다.
저희가 정규화 단계에서 모든 함수 호출을 하기때문에 해당 조건문에 걸릴 일이 없을 것 같네요.
예리한 피드백 감사합니다.

throw new Error("NEED NORMALIZE");
}
if (typeof vNode === "boolean" || !checkNullishExceptZero(vNode)) {
return document.createTextNode("");
}

function updateAttributes($el, props) {}
// 문자열 또는 숫자인 경우 텍스트 노드로 처리
if (typeof vNode === "string" || typeof vNode === "number") {
return document.createTextNode(String(vNode));
}

// 배열로 들어온 경우 fragment로 처리
if (Array.isArray(vNode)) {
const frag = document.createDocumentFragment();
vNode.forEach((node) => {
frag.append(makeElement(node));
});
return frag;
}
Comment on lines +21 to +27
Copy link

Choose a reason for hiding this comment

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

여기서 vNode가 배열일 경우 createElement가 아니라 makeElement로 처리해주셨는데, 배열 요소에 vNode === "string" || vNode === "boolean"요런 케이스에 해당하는 요소가 올 경우에 오류가 발생하지 않으셨는지 궁금합니다!

Copy link
Author

Choose a reason for hiding this comment

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

진짜 문제의 소지가 있겠네요!!
리뷰 감사합니다. 고쳐야될 코드같아요!!


// 그 외의 경우 element로 처리
const el = makeElement(vNode);

if (Array.isArray(vNode.children)) {
vNode.children.forEach((child) => {
el.appendChild(createElement(child));
});
}

return el;
}

function makeElement(vNode) {
const el = document.createElement(vNode.type);
updateAttributes(el, vNode.props);
return el;
}

/**
*
* @param {HTMLElement} $el
* @param {object} props
*/
function updateAttributes($el, props) {
for (const prop in props) {
if (prop?.startsWith("on")) {
addEvent($el, prop.slice(2).toLowerCase(), props[prop]);
continue;
}
$el.setAttribute(replaceIfPropIsClass(prop), props[prop]);

Choose a reason for hiding this comment

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

개인적으로는 replacePropIsClass로 별도의 함수로 뺐다면, 윗줄에서 한 번 실행하고 반환값을 받아서 prop을 인자로 넘겨주는 쪽이 가독성이 더 좋을 것 같아요!

}
}
Comment on lines +52 to +60

Choose a reason for hiding this comment

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

updateAttributes 함수를 보면 기존 코드는 모든 속성을 매번 설정하고 있어서 DOM 요소에 이미 설정된 속성이라도 변경 없이도 불필요하게 다시 설정되고 있네요 혹시 맞나용??

Suggested change
function updateAttributes($el, props) {
for (const prop in props) {
if (prop?.startsWith("on")) {
addEvent($el, prop.slice(2).toLowerCase(), props[prop]);
continue;
}
$el.setAttribute(replaceIfPropIsClass(prop), props[prop]);
}
}
function updateAttributes($el, props, prevProps = {}) {
for (const key in prevProps) {
if (!(key in props)) {
$el.removeAttribute(key);
}
}
for (const key in props) {
if (props[key] !== prevProps[key]) {
if (key.startsWith('on')) {
addEvent($el, key.slice(2).toLowerCase(), props[key]);
} else if (key === 'className') {
$el.setAttribute('class', props[key]);
} else {
$el.setAttribute(key, props[key]);
}
}
}
}

제가 개선해보면서 addEvent 함수를 호출하기 전에 이벤트 리스너 속성이 변경되었는지 확인하는 로직까지 한번 추가 해봤는데 요렇게 추가로 해보시는건 어떤가요?? 조금 더 고도화 하면 좋을 것 같습니다 :) 이 함수의 역할이 잘 보이게 간결하게 잘 작성하신 것 같아요!! 좋습니다 👍

Copy link
Author

Choose a reason for hiding this comment

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

오 코드 개선까지 감사합니다.

여기서 궁금한게,
props[key] !== prevProps[key] 코드에서 핸들러 함수가 상이한 것을 체크할 수 있나요??

9 changes: 8 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { recursiveFlatten } from "../utils/arrayUtils";
import { checkNullishExceptZero } from "../utils/commonUtils";

export function createVNode(type, props, ...children) {
return {};
const flattenedChildren = recursiveFlatten(children, (val) => {
return checkNullishExceptZero(val);
});

return { type, props, children: flattenedChildren };
}
50 changes: 47 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
export function setupEventListeners(root) {}
/**
* globalEvents
* {
* click: {
* element: handler
* }
* }
*/

export function addEvent(element, eventType, handler) {}
let globalEvents = {};

export function removeEvent(element, eventType, handler) {}
// 콜백으로 넣으면 add할때의 remove할때의 콜백이 다를 수 있음
/**
*
* @param {HTMLElement} root
*/
export function setupEventListeners(root) {
for (const eventType in globalEvents) {
root.addEventListener(eventType, handleGlobalEvents);
}
}

export function handleGlobalEvents(e) {
let target = e.target;
Copy link

Choose a reason for hiding this comment

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

eventManager에서 중복된 이벤트 이슈를 어떻게 해결해야할지 감이 안잡혔는데, 준만님 코드를 보고 많이 배웠습니다!👍👍👍

// 버블링 처리
while (target) {
if (globalEvents[e.type].has(target)) {
globalEvents[e.type].get(target)(e);
}
target = target.parentElement;
}
}

export function addEvent(element, eventType, handler) {
if (!element || typeof handler !== "function") return;

globalEvents[eventType] = globalEvents[eventType] || new WeakMap();
globalEvents[eventType].set(element, handler);
}

export function removeEvent(element, eventType, handler) {
if (globalEvents[eventType].get(element) === handler) {
globalEvents[eventType].delete(element);
}
}

export function clearEvents() {
globalEvents = {};
}
27 changes: 26 additions & 1 deletion src/lib/normalizeVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,28 @@
import { checkNullishExceptZero } from "../utils/commonUtils";

// falsy 값은 빈 문자열로 변환
// 숫자, 문자 = > 문자열로 변환
// 함수 => 함수 실행 결과로 변환

// vNode : {type , props, children}
export function normalizeVNode(vNode) {
return vNode;
if (typeof vNode === "boolean" || !checkNullishExceptZero(vNode)) {
return "";
}

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

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

return {
type: vNode.type,
props: vNode.props,
children: vNode.children.map(normalizeVNode).filter(checkNullishExceptZero),
};
}
Loading
Loading