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 만들기 #44

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
671 changes: 671 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "front-4th-chapter1-1",
"name": "front-4th-chapter1-2",
"private": true,
"version": "0.0.0",
"type": "module",
Expand Down Expand Up @@ -34,8 +34,8 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/user-event": "^14.5.2",
"@vitejs/plugin-react": "^4.3.4",
"@vitest/ui": "^2.1.8",
"@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
Expand All @@ -44,6 +44,7 @@
"jsdom": "^25.0.1",
"lint-staged": "^15.2.11",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"vite": "^6.0.3",
"vitest": "^2.1.8"
}
Expand Down
4 changes: 2 additions & 2 deletions src/components/templates/Navigation.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { globalStore } from "../../stores";

const getNavItemClass = (path) => {
const currentPath = window.location.pathname;
console.log("currentPath= ", currentPath);
return currentPath === path ? "text-blue-600 font-bold" : "text-gray-600";
};

function Link({ onClick, children, ...props }) {
function Link({ children, ...props }) {
const handleClick = (e) => {
e.preventDefault();
onClick?.();
router.get().push(e.target.href.replace(window.location.origin, ""));
};
return (
Expand Down
56 changes: 53 additions & 3 deletions src/lib/createElement.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
import { addEvent } from "./eventManager";
function updateAttributes(element, props) {
props = props || {}; // props가 undefined일 경우 빈 객체로 설정

export function createElement(vNode) {}
for (const key in props) {
if (key.startsWith("on")) {
const eventType = key.slice(2).toLowerCase();
// 이벤트 핸들러
element.addEventListener(eventType, props[key]);
} else {
// 일반 속성 설정
if (key === "className") {
element.className = props[key]; // className 처리
} else {
element.setAttribute(key, props[key]); // 일반 속성 처리
}
}
}
}

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(String(vNode)); // 텍스트 노드 생성
}

// 배열 입력 처리: DocumentFragment 생성
if (Array.isArray(vNode)) {
const fragment = document.createDocumentFragment();
vNode.forEach((childVNode) => {
fragment.appendChild(createElement(childVNode)); // 각 요소를 DocumentFragment에 추가
});
return fragment; // DocumentFragment 반환
}

const element = document.createElement(vNode.type);

// props 설정
updateAttributes(element, vNode.props);

// 자식 노드 추가
vNode.children.forEach((child) => {
const childElement = createElement(child);
element.appendChild(childElement); // 재귀적으로 자식 요소 생성

// 자식 요소의 이벤트 핸들러 설정
if (child.props) {
updateAttributes(childElement, child.props);
}
});

return element;
}
6 changes: 5 additions & 1 deletion src/lib/createVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
export function createVNode(type, props, ...children) {
return {};
// 평탄화 및 falsy 값 필터링
children = children
.flat(Infinity)
.filter((child) => child != null && child !== false);
return { type, props, children };
}
69 changes: 66 additions & 3 deletions src/lib/eventManager.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,68 @@
export function setupEventListeners(root) {}
const events = [];
let root = null;

export function addEvent(element, eventType, handler) {}
export function setupEventListeners(_root) {
root = _root;

export function removeEvent(element, eventType, handler) {}
// 모든 이벤트 타입에 대해 이벤트 리스너를 등록합니다.
const eventTypes = ["click", "input"]; // 필요한 다른 이벤트 타입도 추가할 수 있습니다.
eventTypes.forEach((eventType) => {
root.addEventListener(eventType, handleEvent);
});
}

function handleEvent(event) {
const { target, type } = event;

events.forEach(({ element, eventType, handler }) => {
if (
eventType === type &&
(element === target || target.closest(element.tagName) === element)
) {
handler.call(element, event);
}
});
}

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

if (isIncluded) {
console.log(`Event already exists: ${eventType} on element:`, element);
return;
}

events.push({ element, eventType, handler });
console.log(`Added event: ${eventType} to element:`, element);
}

export function removeEvent(element, eventType, handler) {
console.log(
`Attempting to remove event: ${eventType} from element:`,
element,
);

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

if (!found) {
console.log(`Event not found: ${eventType} on element:`, element);
return;
}

const index = events.indexOf(found);
events.splice(index, 1);

root.removeEventListener(eventType, handler);
console.log(`Removed event: ${eventType} from element:`, element);
}

function isSameEvent(a, b) {
const sameElement = a.element === b.element;
const sameEventType = a.eventType === b.eventType;
const sameHandler = a.handler === b.handler;
return sameElement && sameEventType && sameHandler;
}
32 changes: 31 additions & 1 deletion src/lib/normalizeVNode.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,33 @@
export function normalizeVNode(vNode) {
return vNode;
// null, undefined, boolean 처리
if (
vNode == null ||
typeof vNode === "undefined" ||
typeof vNode === "boolean"
) {
return "";
}

// 문자열 또는 숫자 처리
if (typeof vNode === "string" || typeof vNode === "number") {
return String(vNode);
}

// 컴포넌트인 경우
if (typeof vNode.type === "function") {
// 컴포넌트를 호출하여 VNode 생성
const { props, children } = vNode; // props와 children 분리
const componentVNode = vNode.type({ ...props, children }); // children을 포함하여 전달
return normalizeVNode(componentVNode); // 재귀적으로 정규화
}

// 그 외의 경우, 자식 요소들을 재귀적으로 표준화
if (vNode && typeof vNode === "object") {
const children = [...vNode.children]
.map(normalizeVNode)
.filter((child) => child);
return { ...vNode, children };
}

return vNode; // 기본 반환
}
70 changes: 66 additions & 4 deletions src/lib/renderElement.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,72 @@
import { setupEventListeners } from "./eventManager";
import { setupEventListeners, removeEvent, addEvent } 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._vnode) {
container._vnode = null; // 초기화
}

const oldVNode = container._vnode; // 이전 VNode를 저장하는 속성 추가
const normalizedVNode = normalizeVNode(vNode); // vNode 정규화

if (!oldVNode) {
// 최초 렌더링 시 createElement로 DOM을 생성하고
console.log("Creating element:", normalizedVNode);
const $el = createElement(normalizedVNode); // createElement로 노드 생성
container.appendChild($el); // container에 삽입
container._vnode = normalizedVNode; // 현재 VNode를 저장
setupEventListeners(container); // 이벤트 등록
console.log("Element created:", normalizedVNode);
console.log("Container:", container);
console.log("container.innerHTML:", container.innerHTML);
} else {
// 이전 VNode의 이벤트 핸들러를 제거
removeEventHandlers(oldVNode);

// 새로운 VNode의 이벤트 핸들러를 추가
addEventHandlers(normalizedVNode);

updateElement(container, normalizedVNode, oldVNode); // DOM 업데이트
container._vnode = normalizedVNode; // 현재 VNode를 업데이트
}
}

function removeEventHandlers(vNode) {
if (vNode.props) {
Object.keys(vNode.props).forEach((prop) => {
if (prop.startsWith("on")) {
const eventType = prop.slice(2).toLowerCase(); // onClick -> click
removeEvent(vNode, eventType, vNode.props[prop]);
console.log(`Removed event: ${eventType} from element:`, vNode);
}
});
}
if (vNode.children) {
vNode.children.forEach((child) => {
removeEventHandlers(child);
});
}
}

function addEventHandlers(vNode) {
console.log("Adding event handlers to element:", vNode);
if (vNode.props) {
console.log("Props:", vNode.props);
Object.keys(vNode.props).forEach((prop) => {
if (prop.startsWith("on")) {
const eventType = prop.slice(2).toLowerCase(); // onClick -> click
addEvent(vNode, eventType, vNode.props[prop]);
console.log(`Added event: ${eventType} to element:`, vNode);
}
});
}
if (vNode.children) {
console.log("Children:", vNode.children);
vNode.children.forEach((child) => {
console.log("Child:", child);
addEventHandlers(child);
});
}
}
81 changes: 79 additions & 2 deletions src/lib/updateElement.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,83 @@
import { addEvent, removeEvent } from "./eventManager";
import { createElement } from "./createElement.js";

function updateAttributes(target, originNewProps, originOldProps) {}
function updateAttributes(target, originOldProps, originNewProps) {
// 새로운 속성 추가 및 기존 속성 업데이트
for (const key in originNewProps) {
if (key.startsWith("on")) {
// 이벤트 리스너 처리
const eventType = key.slice(2).toLowerCase();
// 기존 핸들러가 없으면 추가
if (!originOldProps[key] || originOldProps[key] !== originNewProps[key]) {
addEvent(target, eventType, originNewProps[key]); // addEvent 사용
}
} else {
// 일반 속성 처리
target.setAttribute(key, originNewProps[key]);
}
}

export function updateElement(parentElement, newNode, oldNode, index = 0) {}
// 기존 속성 제거
for (const key in originOldProps) {
if (!(key in originNewProps)) {
if (key.startsWith("on")) {
const eventType = key.slice(2).toLowerCase();
removeEvent(target, eventType, originOldProps[key]); // 이벤트 핸들러 제거
} else {
target.removeAttribute(key); // 일반 속성 제거
}
}
}
}

export function updateElement(parentElement, newNode, oldNode, index = 0) {
// 노드가 다르면 교체
if (oldNode.type !== newNode.type) {
const newEl = createElement(newNode);
parentElement.replaceChild(newEl, parentElement.childNodes[index]);
return newEl;
}

// 속성 업데이트
updateAttributes(
parentElement.childNodes[index],
oldNode.props || {},
newNode.props || {},
);

// 자식 요소 업데이트
const oldChildren = oldNode.children || [];
const newChildren = newNode.children || [];

const minLength = Math.min(oldChildren.length, newChildren.length);

// 기존 자식 업데이트
for (let i = 0; i < minLength; i++) {
updateElement(
parentElement.childNodes[index],
newChildren[i],
oldChildren[i],
i,
);
}

// 추가된 자식 요소 처리
if (newChildren.length > oldChildren.length) {
for (let i = minLength; i < newChildren.length; i++) {
parentElement.childNodes[index].appendChild(
createElement(newChildren[i]),
);
}
}

// 제거된 자식 요소 처리
if (oldChildren.length > newChildren.length) {
for (let i = minLength; i < oldChildren.length; i++) {
parentElement.childNodes[index].removeChild(
parentElement.childNodes[index].childNodes[minLength],
);
}
}

return parentElement.childNodes[index]; // 업데이트된 요소 반환
}
1 change: 1 addition & 0 deletions src/pages/LoginPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ function login(username) {
export const LoginPage = () => {
const handleSubmit = (e) => {
e.preventDefault();
alert("진입");
const username = document.getElementById("username").value;
login(username);
};
Expand Down
Loading