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-1] 프레임워크 없이 SPA 만들기 #22

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
34b4355
feat: 커스텀 라우터 기반 페이지 이동
devJayve Dec 15, 2024
4a6040d
feat: 컴포넌트 추상화
devJayve Dec 15, 2024
67815e3
refactor: 팩토리 패턴 기반 페이지 로드
devJayve Dec 15, 2024
c5d9493
refactor: 라우트 컴포넌트화
devJayve Dec 15, 2024
c379efd
feat: 로그인 처리
devJayve Dec 15, 2024
a68d83b
feat: 커스텀 이벤트 기반 라우팅
devJayve Dec 16, 2024
9f49657
feat: 프로필 업데이트
devJayve Dec 16, 2024
1564f43
feat: 브라우저 URL 수동 변경 라우팅 처리
devJayve Dec 17, 2024
76b07b8
test: 테스트 요구 기반 엘리먼트 속성 등 수정
devJayve Dec 17, 2024
4210f37
fix: 로그인 시 정보 즉시 갱신
devJayve Dec 17, 2024
1813a8f
feat: 로그아웃 상태로 프로필 접근시 로그인 리다이렉트
devJayve Dec 17, 2024
69ead67
Merge pull request #1 from devJayve/basic-spa
devJayve Dec 17, 2024
c245e3a
feat: 로그인 상태로 로그인 페이지 접근 시 메인 리다이렉트
devJayve Dec 17, 2024
eaf8f1c
feat: hash 기반 라우트 동작 구현
devJayve Dec 17, 2024
3752654
feat: Navbar Controller로 상태 및 리스너 관리
devJayve Dec 18, 2024
e7c41e2
feat: 프로필, 로그인 페이지 Controller 구현
devJayve Dec 18, 2024
57de2d9
refactor: 라우트 가드 라우터 내에서 처리
devJayve Dec 18, 2024
2b83adb
refactor: 해시 및 히스토리 기반 라우팅 통합
devJayve Dec 18, 2024
234149e
refactor: 유저 관련 localstorage 클래스 기반 관리
devJayve Dec 18, 2024
4220bde
refactor: 싱글톤 객체 getter 함수
devJayve Dec 18, 2024
7a34c49
fix: router 파일명 오류 해결
devJayve Dec 18, 2024
262e41e
add: jsconfig.json 파일
devJayve Dec 18, 2024
8e1658e
fix: e2e 테스트를 위한 placeholoder 값 변경
devJayve Dec 18, 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
8 changes: 8 additions & 0 deletions jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
15 changes: 15 additions & 0 deletions src/bindings/ComponentBinding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import NavbarController from "../controllers/NavbarController";

class ComponentBinding {
$target;

constructor($target) {
this.$target = $target;
this._dependencies();
}
_dependencies() {
new NavbarController(this.$target);
}
}
Comment on lines +3 to +13

Choose a reason for hiding this comment

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

오호 특정 DOM 요소($target)에 대한 바인딩을 수행하는 역할을 컴포넌트와 페이지로 분리하셨군요,,!?!?
혹시 이렇게 구현하게 된 이유가 있으신가요? 요 역할을 따로 분리해주신게 넘 신기해요 👍 새로운 구조를 하나 알아갑니다,,,


export default ComponentBinding;
12 changes: 12 additions & 0 deletions src/bindings/PageBinding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import LoginController from "../controllers/LoginController";
import ProfileController from "../controllers/ProfileController";
import Binding from "../core/Binding";

class PageBinding extends Binding {
_dependencies() {
new ProfileController(this.$target);
new LoginController(this.$target);
}
}

export default PageBinding;
14 changes: 14 additions & 0 deletions src/bindings/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import ComponentBinding from "./ComponentBinding";
import PageBinding from "./PageBinding";

const createBindings = ($target) => {
const componentBinding = new ComponentBinding($target);
const pageBinding = new PageBinding($target);

return {
component: componentBinding,
page: pageBinding,
};
};

export default createBindings;
21 changes: 21 additions & 0 deletions src/components/NavBar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Component from "../core/Component";

class Navbar extends Component {
template() {
return `
<nav class="navbar bg-white shadow-md p-2 sticky top-14">
<ul class="flex justify-around">
<li><a href="/" class="nav-link ${this.controller.isMainPage() ? "text-blue-600 font-bold" : "text-gray-600"}">홈</a></li>
${
this.controller.auth
? `<li><a href="/profile" class="nav-link ${!this.controller.isMainPage() ? "text-blue-600 font-bold" : "text-gray-600"}">프로필</a></li>
<li><a href="/login" id="logout" class="nav-link text-gray-600">로그아웃</a></li>`
: `<li><a href="/login" id="login" class="nav-link text-gray-600">로그인</a></li>`
}
</ul>
</nav>
`;
}
}

export default Navbar;
30 changes: 30 additions & 0 deletions src/controllers/LoginController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import UserService from "../service/UserService.js";
import Controller from "../core/Controller";
import ProfilePage from "../pages/Profile";
import Router from "../router/Router";
import ProfileController from "./ProfileController";

class LoginController extends Controller {

Choose a reason for hiding this comment

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

각각의 역할별로 컨트롤러를 분리하시고 core의 컨트롤러를 가지고 확장하는 구조를 가지고 있군요 ... 헙🧐 도대체 얼마나 많은 고민과 노력을 하셨을지 ... 👍 도운님의 코드의 의도를 잘 파악해 보겠습니다 각각의 관심사별로 잘 분리해 사용하고 계신 것 같습니다 bb 최고최고

attachListeners() {
// 로그인 폼 제출 시 로그인 처리 및 프로필 페이지 초기화
this.addListener("submit", "#login-form", (e) => {
e.preventDefault();
const username = this.$target.querySelector(".username").value.trim();

if (!username) return;

UserService.login(username);

// 새 프로필 컨트롤러 및 페이지 생성, 라우트 등록
ProfileController.instance.onRefresh();
const profileInstnace = new ProfilePage(
this.$target,
ProfileController.instance,
);
Router.instance.addRoute("/profile", () => profileInstnace);
Router.instance.navigate("/");
});
}
}

export default LoginController;
42 changes: 42 additions & 0 deletions src/controllers/NavbarController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import UserService from "../service/UserService";
import Router from "../router/Router";
import Controller from "../core/Controller";

class NavbarController extends Controller {
attachListeners() {
// 네비게이션 링크 클릭 시 라우팅 처리
this.addListener("click", ".nav-link", (e) => {
const target = e.target.closest("a");
if (!(target instanceof HTMLAnchorElement)) return;

e.preventDefault();

// 로그아웃 버튼 처리
if (target.id === "logout") {
this.handleLogout();
}

// 대상 네비게이션 탭 URL로 이동
const targetURL = e.target.getAttribute("href");
Router.instance.navigate(targetURL);
});
}

handleLogout() {
UserService.logout();
}

// 인증 정보 반환
get auth() {
return UserService.getAuth();
}

// 현재 페이지가 메인 페이지인지 판단
isMainPage() {
const currentPath = window.location.pathname;
const currentHash = window.location.hash;
return currentPath === "/" || currentHash === "#/";
}
}

export default NavbarController;
38 changes: 38 additions & 0 deletions src/controllers/ProfileController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import UserService from "../service/UserService";
import Controller from "../core/Controller";

class ProfileController extends Controller {
_onInit() {
// 사용자 정보 로드 및 상태 초기화
const user = UserService.getUser();
this.state = {
username: user?.username || "",
email: user?.email || "",
bio: user?.bio || "",
};
super._onInit();
}

onRefresh() {
this._onInit();
}

attachListeners() {
// 프로필 폼 제출 시 사용자 정보 업데이트
this.addListener("submit", "#profile-form", (e) => {
e.preventDefault();

const form = e.target.closest("form");
const username = form.querySelector("#username").value;
const email = form.querySelector("#email").value;
const bio = form.querySelector("#bio").value;

UserService.saveUser(username, email, bio);

this.setState({ username, email, bio });
alert("프로필이 업데이트 되었습니다");
});
}
}

export default ProfileController;
13 changes: 13 additions & 0 deletions src/core/Binding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
class Binding {
$target;

constructor($target) {
this.$target = $target;
this._dependencies();
}

// 의존성 주입
_dependencies() {}
}

export default Binding;
35 changes: 35 additions & 0 deletions src/core/Component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
class Component {
$target;
controller;

constructor($target, controller) {
this.$target = $target;
this.controller = controller;

// 컨트롤러가 있으면 상태 변경 시 자동 리렌더링
if (this.controller) {
this.controller.setOnStateChange(() => this.render());
}

this.init();
Comment on lines +9 to +14

Choose a reason for hiding this comment

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

저는 도운님의 코드를 보고 이것을 위해 Core로 Components를 개발해두신 게 아닐까?! 생각했습니다

우선 이 구조를 보고 컴포넌트의 렌더링 로직들이 캡슐화되어있어서 관리하기도 쉽고, 애초에 이 컴포넌트를 사용하고자 하는 도메인에 의존한 컴포넌트에서 상속받아서 쉽게 사용할 수 있다는 것, 그리고 이 코드의 장점인 컨트롤러를 통한 상태 관리 구조는 컴포넌트의 상태 변화에 따른 자동 리렌더링을 지원하게 한다는 것👏🏻👏🏻👏🏻 이 가장 두드러지는 장점인것 같아요!

저 혹시 잘 이해한거 맞나요 ..?ㅎㅎ 하하 코드 리뷰가 아니고 도운님의 멋진 코드를 더 고민해보는 리뷰가 되었네요
처음에는 어떤 목적으로 해당 디자인 패턴을 도입해서 이 구조를 짜게 되셨을 까 고민을 했는데 각 목적성에 맞게 잘 분리해주시고, 이 Core 폴더에 있는 바인딩, 컴포넌트, 컨트롤러의 핵심 로직들이 이 서비스의 확장성에 가장 높은 기여를 할 것 같아요 👍 정말 몇 수 앞을 내다 보신 것입니까,,, 👀 저도 이렇게 도운님처럼 확장성을 잘 고려하는 개발자가 되고 싶군요,, core 로직까지 보고나서야 점차 이해가 잘 되었습니다

이 구조를 처음부터 끝까지 설계하시다니,,, 존경스럽습니다 많이 배우고 갑니다! 고생하셨습니다 도운님

Copy link
Author

Choose a reason for hiding this comment

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

웹앱 설계에 익숙하지 않은 패턴이기도 하고 어쩌면 복잡한 구조일 수 있는데 완벽히 이해해주셔서 개발한 사람으로써 매우 감사할 따름입니다..👍 소현님에게 제 코드가 조금이라도 도움이 되었다면 이번 과제는 성공적이네요 !! 소현님 코드 리뷰 보면서 저도 다시 한번 복기할 수 있어 좋은 기회였습니다 😁

}

// 초기화 로직용 훅
init() {}

// 렌더링할 템플릿 반환
template() {
return ``;
}

// DOM 업데이트
render() {
this.$target.innerHTML = this.template();
this.mount();
}

// 마운트 훅
mount() {}
}

export default Component;
61 changes: 61 additions & 0 deletions src/core/Controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
class Controller {
state;
static _instance = null;

static get instance() {
if (!this._instance) {
throw new Error(`Instance of ${this.name} has not been created yet.`);
}
return this._instance;
}

constructor($target) {
// 싱글톤 패턴
if (this.constructor._instance) {
return this.constructor._instance;
}
this.constructor._instance = this;

this.$target = $target;
this.onStateChange = null;
this.state = {};
this._onInit();
}

// 초기화 시 attachListeners 호출
_onInit() {
this.attachListeners();
}

// 이벤트 리스너 등록 헬퍼 메서드
addListener(eventType, selector, callback) {
const boundCallback = (event) => {
if (!event.target.closest(selector)) return false;
callback(event);
};
// 기존 리스너 제거 후 새 리스너 추가
this.$target.removeEventListener(eventType, boundCallback);
this.$target.addEventListener(eventType, boundCallback);
}

// 리스너를 등록하는 훅, 하위 클래스에서 구현
attachListeners() {}

// 상태 변경 시 onStateChange 콜백 실행
setState(newState) {
this.state = { ...this.state, ...newState };
this.onStateChange();
}

// 상태 변경 시 호출할 콜백 설정
setOnStateChange(callback) {
this.onStateChange = callback;
}

// 컨트롤러 해제 시 인스턴스 제거
dispose() {
this.constructor.instance = null;
}
}

export default Controller;
Loading
Loading