Skip to content

Latest commit

 

History

History
512 lines (400 loc) · 12.4 KB

README.md

File metadata and controls

512 lines (400 loc) · 12.4 KB

SPA with VanillaJS

목차

  1. 라우터 구현
  2. 컴포넌트 시스템
  3. 전역 상태 관리

라우터

주요 기능

  1. 📱 History API 기반 네비게이션
  2. 🔒 인증을 위한 라우터 가드 지원
  3. 🎯 설정 가능한 404 처리
  4. 🔄 브라우저 뒤로가기/앞으로가기 지원
  5. 💡 의존성 없음

핵심 개념

  • 캐싱: module system으로 인스턴스를 캐싱하여 import를 여러번 해도 같은 인스턴스를 바라보도록 구성
  • 브라우저 History API: 클라이언트 사이드 네비게이션을 위해 history.pushState() 활용
  • 이벤트 처리: 브라우저 네비게이션 지원을 위한 popstate 이벤트 리스너 구현
  • 가드 패턴: 인증 및 권한 부여 로직을 위한 라우트 가드 지원

코드

[구현 코드] src/router.js
class Router {
  constructor() {
    this.routes = [];
    this.notfound = () => {};
    window.addEventListener("popstate", () => {
      const path = window.location.pathname;

      this.push(path);
    });
  }

  currentPath() {
    return window.location.pathname;
  }

  addRoute(path, handler, routerGuard = null) {
    this.routes[path] = { handler, routerGuard };
  }

  addNotFoundRoute(handler) {
    this.notfound = handler;
  }

  push(path) {
    const route = this.routes[path];
    if (route) {
      const validPath = route.routerGuard ? route.routerGuard(path) : path;
      history.pushState({}, "", validPath);

      const handler = this.routes[validPath]?.handler;
      if (handler) {
        handler();
      } else {
        this.notfound();
      }
    } else {
      this.notfound();
    }
  }
}

const router = new Router();

export default router;
[사용 코드] src/app.js
import router from "./router";
import { HOME_PAGE, LOGIN_PAGE, PROFILE_PAGE, USERNAME } from "./constants";

import HomePage from "./pages/HomePage";
import LoginPage from "./pages/LoginPage";
import NotFoundPage from "./pages/NotFoundPage";
import ProfilePage from "./pages/ProfilePage";
import userStore from "./store/userStore";

const loginPageGuard = (path) => {
  return !!userStore.getState()[USERNAME] ? HOME_PAGE : path;
};

const profilePageGuard = (path) => {
  return !userStore.getState()[USERNAME] ? LOGIN_PAGE : path;
};

export default function App($root) {
  router.addRoute(HOME_PAGE, () => {
    new HomePage($root);
  });
  router.addRoute(
    LOGIN_PAGE,
    () => {
      new LoginPage($root);
    },
    loginPageGuard
  );
  router.addRoute(
    PROFILE_PAGE,
    () => {
      new ProfilePage($root);
    },
    profilePageGuard
  );
  router.addNotFoundRoute(() => {
    new NotFoundPage($root);
  });

  router.push(router.currentPath());
}

컴포넌트

주요 기능

  • 📦 추상 클래스를 활용한 컴포넌트 구조화
  • 🔄 컴포넌트 생명주기 메서드 지원
  • 🎨 선언적 템플릿 시스템
  • 🎯 이벤트 핸들링 추상화
  • 💡 의존성 없는 순수 구현

구현 특징

컴포넌트 생명주기 컴포넌트는 다음과 같은 생명주기를 가집니다:

  • constructor: 초기 설정 및 props 전달
  • beforeMount: 마운트 전 실행될 로직
  • render: 템플릿 렌더링
  • mount: 마운트 후 실행될 로직
  • attachEventListeners: 이벤트 리스너 설정

컴포넌트 API

AbstractComponent 클래스 메서드

constructor($root, ...args)

  • 컴포넌트를 초기화하고 props를 설정합니다.

$root

  • 컴포넌트가 마운트될 DOM 요소

args

  • props로 전달될 인자들

beforeMount()

  • DOM에 마운트되기 전 실행될 로직을 정의합니다.

mount()

  • DOM에 마운트된 후 실행될 로직을 정의합니다.

template()

  • 컴포넌트의 HTML 템플릿을 반환합니다.

attachEventListeners()

  • 이벤트 리스너를 설정합니다.

render()

  • 컴포넌트 렌더링 프로세스를 관리합니다.

코드

[구현 코드] src/abstract/AbstractComponent.js
export default class AbstractComponent {
  constructor($root, ...args) {
    this.$root = $root;
    this.props = args[0];

    this.render();
  }

  beforeMount() {}

  mount() {}

  template() {}

  attachEventListeners() {}

  render() {
    if (!this.$root) {
      return;
    }
    this.beforeMount();
    this.$root.innerHTML = this.template();
    this.mount();
    this.attachEventListeners();
  }
}
[사용 코드] src/pages/LoginPage.js
import router from "../router";
import { PROFILE_PAGE, USERNAME } from "../constants";

import userStore from "../store/userStore";
import AbstractComponent from "../abstract/AbstractComponent";

export default class LoginPage extends AbstractComponent {
  constructor($root) {
    super($root);
  }

  template() {
    return `
      <main class="bg-gray-100 flex items-center justify-center min-h-screen">
        <div class="bg-white p-8 rounded-lg shadow-md w-full max-w-md">
          <h1 class="text-2xl font-bold text-center text-blue-600 mb-8">항해플러스</h1>
          <form id="login-form">
            <div class="mb-4">
              <input id="username" type="text" placeholder="이메일 또는 전화번호" class="w-full p-2 border rounded">
            </div>
            <div class="mb-6">
              <input id="password" type="password" placeholder="비밀번호" class="w-full p-2 border rounded">
            </div>
            <button type="submit" class="w-full bg-blue-600 text-white p-2 rounded font-bold">로그인</button>
          </form>
          <div class="mt-4 text-center">
            <a href="#" class="text-blue-600 text-sm">비밀번호를 잊으셨나요?</a>
          </div>
          <hr class="my-6">
          <div class="text-center">
            <button class="bg-green-500 text-white px-4 py-2 rounded font-bold">새 계정 만들기</button>
          </div>
        </div>
      </main>
    `;
  }

  attachEventListeners() {
    const $usernameInput = document.getElementById("username");

    // 로그인 시
    const $loginForm = document.getElementById("login-form");
    $loginForm.addEventListener("submit", (e) => {
      e.preventDefault();

      userStore.setState({ [USERNAME]: $usernameInput.value });

      router.push(PROFILE_PAGE);
    });
  }
}

전역 상태 관리

프레임워크 없이 순수 자바스크립트로 구현한 상태 관리 시스템입니다. Redux와 유사한 중앙 집중식 상태 관리와 Observer 패턴을 활용하여 반응형 상태 관리를 구현했습니다.

주요 기능

  • 📦 Observer 패턴을 활용한 반응형 상태 관리
  • 💾 LocalStorage를 활용한 영구 저장소 연동
  • 🔄 구독 기반 상태 업데이트
  • 🛡️ 상태 접근 제어와 유효성 검사
  • 💡 의존성 없는 순수 구현

구현 특징

  • Store: 중앙 집중식 상태 관리자
  • Observer Pattern: 상태 변경 구독 및 알림
  • Persistence Layer: LocalStorage 기반 영구 저장소
  • Immutable Updates: 불변성을 지키는 상태 업데이트

상태 관리 API

Store 클래스

constructor(initialState)

  • 초기 상태로 스토어를 생성합니다.

initialState

  • 초기 상태 객체

getState()

  • 현재 상태를 반환합니다.

setState(newState)

  • 상태를 업데이트하고 구독자들에게 알립니다.

newState

  • 새로운 상태 객체

subscribe(listener)

  • 상태 변경을 구독합니다.

listener

  • 상태 변경 시 호출될 컴포넌트

clear()

  • 상태를 초기화합니다.

코드

[구현 코드] src/store/thinkStroe.js
import { USERNAME } from "../constants";
import userStore from "./userStore";

const getRandomId = () => {
  return Math.round(Math.random() * 1000);
};

const initialState = () => {
  return [
    {
      id: getRandomId(),
      imgUrl: "https://via.placeholder.com/40",
      name: "홍길동",
      ago: 5,
      think: "오늘 날씨가 정말 좋네요. 다들 좋은 하루 보내세요!",
    },
    {
      id: getRandomId(),
      imgUrl: "https://via.placeholder.com/40",
      name: "김철수",
      ago: 15,
      think: "새로운 프로젝트를 시작했어요. 열심히 코딩 중입니다!",
    },
    {
      id: getRandomId(),
      imgUrl: "https://via.placeholder.com/40",
      name: "이영희",
      ago: 30,
      think: "오늘 점심 메뉴 추천 받습니다. 뭐가 좋을까요?",
    },
    {
      id: getRandomId(),
      imgUrl: "https://via.placeholder.com/40",
      name: "박민수",
      ago: 66,
      think: "주말에 등산 가실 분 계신가요? 함께 가요!",
    },
    {
      id: getRandomId(),
      imgUrl: "https://via.placeholder.com/40",
      name: "정수연",
      ago: 120,
      think: "새로 나온 영화 재미있대요. 같이 보러 갈 사람?",
    },
  ];
};

class Store {
  constructor(initialState) {
    this.state = initialState;
    this.listeners = new Set();
  }

  getState() {
    return this.state;
  }

  setState(think) {
    const newThink = {
      id: getRandomId(),
      imgUrl: "https://via.placeholder.com/40",
      name: userStore.getState()[USERNAME],
      ago: 5,
      think,
    };
    this.state = [newThink, ...this.getState()];

    this.listeners.forEach((listener) => listener.render());
  }

  clear() {
    this.setState(initialState());

    this.listeners.forEach((listener) => listener.render());
  }

  subscribe(listener) {
    this.listeners.add(listener);
  }
}

const thinkStore = new Store(initialState());

export default thinkStore;

}
[사용 코드] src/pages/HomePage.js
import Footer from "../components/Footer";
import Header from "../components/Header";
import ThinkCard from "../components/ThinkCard";

import AbstractComponent from "../abstract/AbstractComponent";
import thinkStore from "../store/thinkStore";
import userStore from "../store/userStore";
import { LOGIN_PAGE, USERNAME } from "../constants";
import router from "../router";

export default class HomePage extends AbstractComponent {
  constructor(elementId) {
    super(elementId);
  }

  beforeMount() {
    this.userStore = userStore;

    this.thinkStore = thinkStore;
    thinkStore.subscribe(this);

    this.thinkCardTemplate = this.thinkStore.getState().map((think) => {
      return `<div id=think-${think.id}></div>`;
    });
  }

  mount() {
    const $header = document.getElementById("header");
    new Header($header);

    this.thinkStore.getState().forEach((think) => {
      const $thinkCard = document.getElementById(`think-${think.id}`);
      new ThinkCard($thinkCard, think);
    });

    const $footer = document.getElementById("footer");
    new Footer($footer);
  }

  template() {
    const isLogin = !!this.userStore.getState()[USERNAME];
    const textareaPlaceholder = isLogin
      ? "무슨 생각을 하고 계신가요?"
      : "로그인을 먼저 해주세요";
    const submitBtnColor = isLogin ? "bg-blue-600" : "bg-green-600";

    return `
      <div class="bg-gray-100 min-h-screen flex justify-center">
        <div class="max-w-md w-full">
        <div id="header"></div>

        <main class="p-4">
          <div class="mb-4 bg-white rounded-lg shadow p-4">
            <form id="think-form">
              <textarea 
                id="think" class="w-full p-2 border rounded" 
                placeholder="${textareaPlaceholder}"
                ${isLogin ? null : "disabled"}></textarea>
              <button 
                type="submit"
                class="mt-2 ${submitBtnColor} text-white px-4 py-2 rounded" 
              >${isLogin ? "게시" : "로그인하러 가기"}</button>
            </form>
          </div>

          <div class="space-y-4">
            ${this.thinkCardTemplate.map((template) => template).join("")}
          </div>
        </main>

        <footer id="footer"></footer>
      </div>
    `;
  }

  attachEventListeners() {
    const $thinkTextarea = document.getElementById("think");
    const $thinkForm = document.getElementById("think-form");

    $thinkForm.addEventListener("submit", (e) => {
      e.preventDefault();

      const isLogin = !!this.userStore.getState()[USERNAME];

      if (isLogin) {
        this.thinkStore.setState($thinkTextarea.value);
      } else {
        router.push(LOGIN_PAGE);
      }
    });
  }
}