IT

바닐라 js로 상태관리 할 수 있을까? [ #1 ]

jojokiki 2025. 4. 27. 15:17

 

상태관리

프론트에서 규모가 커지면 커질수록 복잡해지는 것이 바로 상태 관리입니다.
바닐라 js로 개발해야 한다면 상태 관리를 어떻게 해야 할까?  상태 관리를 잘할 수 있는 방법은 무엇일까? 여러 방법이 있겠지만
view와 viewModel을 정의 후 VM의 변화에 따라 렌더링 하는 방식으로 만들어 보려고 합니다. crud가 되는 간단한 투두 앱을 목표로 만들어 보겠습니다.

먼저

어떤식으로 작동하면 좋을지 작성해두고 구현하는 방식으로 진행해보려고 합니다. 

app.js

new ViewBuilder().load('todo.html', '#body', (rView) => {
   const todoModel = new Todo();
   rView.init((rView) => {
      
   }).on(todoModel, (v, m) => {
     
   })
})

todo.html을 로드 후 #body에 삽입합니다. 그리고 #body 안에 삽입한 코드를 관리할 수 있는 view를 람다의 인자로 받았습니다.
람다의 본문에서는 todoModel이라는 뷰 모델을 정의해 줍니다.
rView에는 init과 on이 있습니다.

 

1. init은 캐싱을 지원합니다. 만약 해당 파일이 여러 번 실행된다면 변하지 않는 부분을 따로 관리하기 위함입니다. 하지만 지금은 ViewBuilder 인스턴스가 매번 생성되니까 유효한 기능은 아닐 것 같습니다. 지금은 초기 시점과 렌더링 시점을 분리하는 목적으로만 사용하고 나중에 고쳐보는 걸로 넘어가겠습니다.

 

2. on은 모델과 렌더링 시 실행될 람다를 등록합니다. 해당 모델에 변화가 발생하면 람다를 실행하게 됩니다. 람다에서는 이름을 재정의할 기회를 주기 위해 인자를 제공해 주었습니다.

 

위 코드가 동작하도록 구현해 보겠습니다.

ViewBuilder 구현 

export class ViewBuilder { 
  load(htmlPath, key, block) { //file을 불러와서 렌더링
    this.#load(htmlPath).then((htmlEl) => {
      block(this.#append(htmlEl, key))
    })
  }
}

load에서는 html 경로와 key 그리고 람다를 인자로 받습니다. 실제 html를 로드하는 코드는 #load에게 맡기도록 하고 로딩이 완료된 html은 지정한 key에 삽입해 주도록 #append에 맡기겠습니다. #append는 삽입한 view를 block에 제공해 줍니다.

 

async #load(htmlPath) {
    if (this.#htmlMap.has(htmlPath)) {
      return (await this.#htmlMap.get(htmlPath))
    } else {
      const promise = htmlLoader(htmlPath)
      this.#htmlMap.set(htmlPath, promise)
      return (await promise)
    }
  }

htmlLoader는 html 파일의 경로를 받아 실제로 로딩하는 역할을 합니다. 같은 템플릿을 반복해서 요청할 때 네트워크 요청이 반복되지 않도록 htmlMap에 캐시를 잡았습니다. htmlPath가 캐시에 있으면 저장된 프로미스를 가져와 실행합니다.

export function htmlLoader(url){

  return fetch(`./templates/${url}`)
    .then(response => {
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.text();
    })
    .then(html => {
      const wrapper = document.createElement("div");
      const ElCollection = new DOMParser().parseFromString(html, 'text/html').body
      if(ElCollection !== null) {
        const children = ElCollection.children
        Array.from(children).forEach(element => {
          wrapper.appendChild(element);
        });
        const element = wrapper.children[0];
        if (element instanceof HTMLElement) {
          return element; // HTMLElement 반환
        } else {
          throw new Error("HTML 파일의 내용이 올바르지 않습니다. HTML 요소가 존재하지 않습니다.");
        }
      }
    })
    .catch(error => {
      console.error(`Error loading ./templates/${url}:`, error);
      // 에러 발생 시 null 반환
      throw new Error("HTML 파일을 불러오는 중 오류가 발생했습니다.");
    });
}

html 파일의 위치는 반드시 templates 하위에 있도록 정해놨습니다. 그리고 파일을 HTMLElment로 변환해 줍니다.

  #append(htmlEl, key) {
    const view = new ViewBuilder()
    view.#element = htmlEl.cloneNode(true)
    if (key === "") {
      if (this.#element === undefined) {
        throw new Error("element가 정의되지 않았습니다.");
      } else {
        this.#element.appendChild(view.#element);
      }
    } else {
      if (this.#element === undefined) {
        Dom.qs(key).appendChild(view.#element);
      } else {
        this.#element.querySelector(key).appendChild(view.#element);
      }
    }
    return view
  }

 

#append은 항상 새로운 view를 생성합니다. 그리고 기존의 element의 특정 위치(Key에 해당하는)에 해당 view를 삽입합니다.
최초의 viewBuilder가 load 하는 시점엔 key는 있지만 element는 존재하지 않기 때문에

Dom.qs(key).appendChild(view.#element);

이 실행됩니다. 저는 #body로 지정해놓았으니 id가 body인 태그에 삽입됩니다. 

 

파일 만들기

index.html

이제 #body가 있는 Index.html을 만들어보겠습니다. 

<!DOCTYPE html>
<html lang="en">

<head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <title>Todo App</title>
   <script type="module" src="./app.js"></script>
   <style>
      body {
         font-family: Arial, sans-serif;
         max-width: 600px;
         margin: 0 auto;
         padding: 20px;
      }

      .todo-form {
         margin-bottom: 20px;
      }

      .todo-input {
         padding: 8px;
         width: 70%;
         margin-right: 10px;
      }

      .todo-button {
         padding: 8px 16px;
         background-color: #4CAF50;
         color: white;
         border: none;
         cursor: pointer;
      }

      .todo-list {
         list-style-type: none;
         padding: 0;
      }

      .todo-item {
         display: flex;
         justify-content: space-between;
         align-items: center;
         padding: 10px;
         border-bottom: 1px solid #ddd;
      }

      .todo-text {
         cursor: pointer;
      }

      .todo-text.completed {
         text-decoration: line-through;
         color: #888;
      }

      .delete-button {
         background-color: #f44336;
         color: white;
         border: none;
         padding: 5px 10px;
         cursor: pointer;
      }
   </style>
</head>

<body>
   <div id="body"></div>
</body>

</html>

간단하게 스타일링해 주고 id가 body인 div 태그를 만들어 주었습니다. 이 div 태그에 todo.html을 삽입해야 하기 때문에 todo.html 파일을 만들겠습니다.

 

todo.html

<div>
   <h1>Todo List</h1>
   <div class="form">
      <input type="text" class="todo-input" placeholder="Add a new task...">
      <button class="todo-button">
         <i class="fas fa-plus"></i>
         Add
      </button>
   </div>
   <div class="todo-list-container">
      <p><i class="fas fa-tasks"></i>uncompleted tasks</p>
      <ul class="todo-list"></ul>
   </div>
   <div class="todo-list-container">
      <p><i class="fas fa-check-circle"></i>completed tasks</p>
      <ul class="completed-todo-list"></ul>
   </div>
   <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
   <style>
      div {
         max-width: 600px;
         margin: 0 auto;
         padding: 40px 20px;
         font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      }

      h1 {
         text-align: center;
         color: #2c3e50;
         margin-bottom: 40px;
         font-weight: 300;
         letter-spacing: 1px;
      }

      .form {
         display: flex;
         gap: 10px;
         margin-bottom: 40px;
         position: relative;
      }

      .todo-input {
         flex: 1;
         padding: 15px 20px;
         border: none;
         border-radius: 8px;
         font-size: 16px;
         background-color: #f5f6fa;
         box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
         transition: all 0.3s ease;
      }

      .todo-input:focus {
         outline: none;
         box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
         background-color: #fff;
      }

      .todo-button {
         padding: 15px 25px;
         background-color: #4a69bd;
         color: white;
         border: none;
         border-radius: 8px;
         cursor: pointer;
         font-size: 16px;
         transition: all 0.3s ease;
         display: flex;
         align-items: center;
         gap: 8px;
      }

      .todo-button:hover {
         background-color: #3c56a8;
         transform: translateY(-2px);
      }

      .todo-list-container {
         background-color: #fff;
         padding: 25px;
         border-radius: 12px;
         margin-bottom: 30px;
         box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
      }

      .todo-list-container p {
         color: #7f8c8d;
         margin-bottom: 20px;
         font-weight: 500;
         font-size: 14px;
         text-transform: uppercase;
         letter-spacing: 1px;
         display: flex;
         align-items: center;
         gap: 8px;
      }

      .todo-list-container p i {
         color: #4a69bd;
      }

      .todo-list-container:last-child p i {
         color: #2ecc71;
      }

      .todo-list,
      .completed-todo-list {
         list-style: none;
         padding: 0;
         margin: 0;
      }
   </style>
</div>

todo.html은 크게

1. 투두의 내용을 입력할 수 있는 form
2. 완료되지 않은 리스트

3. 완료된 리스트

로 나누어 보았습니다. 

 

이제 실행해보면~ 

todo.html 파일!

 

개발자도구에서 dom을 확인해보면

body안에 todo.html 파일이 잘 삽입된것을 확인할 수 있습니다.

 

현재까지 만든 파일은 위와 같습니다. 

다음엔 form에 내용을 입력하면 리스트에 등록될 수 있도록 렌더링을 구현해보겠습니다~