jojokiki

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

IT

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

jojokiki 2025. 4. 28. 01:01

https://jojokiki.tistory.com/6

 

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

상태관리프론트에서 규모가 커지면 커질수록 복잡해지는 것이 바로 상태 관리입니다.바닐라 js로 개발해야 한다면 상태 관리를 어떻게 해야 할까? 또 상태 관리를 잘할 수 있는 방법은 무엇일까

jojosdev.com

저번엔 viewBuilder를 구현 후 특정 파일을 로드하는 것까지 구현해 보았습니다.

이번엔 form등록, 리스트와 상태변화에 따른 렌더링을 구현해 보도록 하겠습니다.

먼저

클릭 이벤트가 발생하는 코드를 먼저 작성해두고 구현해가는 방식으로 진행해보겠습니다. 

import { ViewBuilder } from './jam/src/viewBuilder.js';
import { Todo } from './jam/TodoModel.js';

new ViewBuilder().load('todo.html', '#body', (rView) => {
   const todoModel = new Todo();
   rView.init((rView) => {
      rView.sub('.todo-button').event.onClick(() => {
         const input = rView.sub('.todo-input')
         todoModel.addTodo(input.prop.value);
         input.prop.value = '';
         rView.render(todoModel);
      })
   }).on(todoModel, (rView, model) => {
      
   })
})

 

sub는 rView에 있는 특정 클래스를 가진 태그를 가져오는 역할을 합니다. rView.sub('.todo-button')은 todo.html에 존재하는 todo-button 클래스를 가진 요소를 지칭합니다. 

이벤트함수 간 자동완성을 지원할 수 있도록 .event를 이용해 묶어주었습니다. 속성은 prop으로 묶어주면 될 것 같아요.

 

onClick의 람다 본문 안에서는 클릭 발생 후 코드를 작성해 두었습니다. 

 

1. rView에서 todo-input 클래스를 가진 요소를 가져온 후

2. todoModel에 해당 인풋의 값(투두 내용이 되겠죠?)을 저장하고

3. input의 값을 clear해주고

4. rView를 렌더링 해줍니다. 

     render에 todoModel을 넘겨주었기 때문에 rView에 등록해둔 on의 람다가 실행되겠죠?


input은 컴포넌트로 만들면 더 좋을 것 같지만 잠시 넘어가겠습니다. 

ViewBuilder 구현 

sub

sub(key, block = () => { }) {
    const subEl = this.#element.querySelector(key)
    if (subEl === null) throw new Error(`해당 셀렉터를 가진 요소가 존재하지 않습니다. key: ${key}`)
    else {
      if (this.#subViewMap.has(key)) {
        const view = this.#subViewMap.get(key)
        block(view)
        return view
      } else {
        const newView = new ViewBuilder()
        newView.element = subEl
        this.#subViewMap.set(key, newView)
        block(newView)
        return newView
      }
    }
  }

새로만든 view가 하위 element를 참조할 수 있도록 만드는 것이 핵심입니다. 

block에 newView를 넘기면 람다 본문에서 key에 해당하는 요소를 조작할 수 있습니다. 

on

  on(viewModel, block) { // 바로 실행
    if (this.#renderHookMap.has(viewModel)) throw new Error("이미 등록된 viewModel입니다.")
    this.#renderHookMap.set(viewModel, block)
    block(this, viewModel)
    return this
  }

on에는 반드시 viewModel을 등록해야 합니다. viewModel이 전용 인터페이스를 구현하도록 하면 좋겠지만 넘어가도록 하겠습니다. 

render

  render(viewModel) {
    if (!this.#renderHookMap.has(viewModel)) throw new Error("rendermap에 해당 viewModel이 존재하지 않습니다.")
    this.#renderHookMap.get(viewModel)(this, viewModel)
  }

render는 renderHookMap에 저장된 viewModel과 매칭되는 block을 실행시킵니다. 

Event

export class Event {
  constructor(view) {
    this.view = view
  }
  onClick(block) {
    this.view.element.addEventListener("click", (e) => block(e))
  }
}

Event를 따로 관리하기 위한 클래스를 만들고 viewBuilder와 연결해 줍니다. 

Prop

export class Property {
  constructor(view) {
    this.view = view
  }
  displayNone() {
    this.view.element.style.display = "none"
  }
  displayBlock() {
    this.view.element.style.display = "block"
  }
  displayFlex() {
    this.view.element.style.display = "flex"
  }
  set html(text) {
    this.view.element.innerHTML = text
  }
  clear() {
    this.view.element.innerHTML = ""
  }
  set value(v) {
    if (this.view.element instanceof HTMLInputElement) {
      this.view.element.value = v
    } else {
      throw new Error("해당 요소가 인풋이 아닙니다.")
    }
  }
  get value() {
    return this.view.element.value
  }
}

Event와 비슷하게 view.prop으로 접근할 수 있도록 만들었습니다. 

 

Todo앱의 List 그리기 

new ViewBuilder().load('todo.html', '#body', (rView) => {
   const todoModel = new Todo();
   rView.init((rView) => {
      rView.sub('.todo-button').event.onClick(() => {
         const input = rView.sub('.todo-input')
         todoModel.addTodo(input.prop.value);
         input.prop.value = '';
         rView.render(todoModel);
      })
   }).on(todoModel, (rView, model) => {
      rView.sub('.todo-list', (listView) => {
         listView.setList('uncompletedTodoItem.html', model.getUncompletedTodos(), (itemView, todo) => {
            itemView.sub('.uncompleted-text').prop.html = todo.text;
            itemView.sub('.uncompleted-toggle').event.onClick(() => {
               todo.toggle();
               rView.render(model);
            })
            itemView.sub('.uncompleted-delete').event.onClick(() => {
               model.removeTodo(todo);
               rView.render(model);
            })
         })
      })
      rView.sub('.completed-todo-list', (listView) => {
         listView.setList('completedTodoItem.html', model.getCompletedTodos(), (itemView, todo) => {
            itemView.sub('.completed-text').prop.html = todo.text;
            itemView.sub('.completed-toggle').event.onClick(() => {
               todo.toggle();
               rView.render(model);
            })
            itemView.sub('.completed-delete').event.onClick(() => {
               model.removeTodo(todo);
               rView.render(model);
            })
         })
      })
   })
})

setList를 이용해 todoi-list 클래스를 가진 태그에 uncompletedTodoItem.html를 삽입합니다.

리스트는 모델을 기반으로 그려야하기 때문에 model이 가지고 있는 todo를 불러온 후 한개씩 순회하며 렌더링합니다. 

리스트가 되는 view는 itemView로, model의 아이템은 todo로 정한 후 블록 내부에서 사용해줍니다. 

 

1. todo의 text는 itemView, 즉 리스트 중 uncompleted-text 클래스를 가지고 있는 태그에 삽입한다.

2. uncompleted-toggle을 클릭하면 model을 변화시키고 렌더링한다. 

3. uncompleted-delete를 클릭하면 model을 갱신하고 렌더링한다. 

 

비슷한 맥락으로 completed-todo-list를 만들어 줍니다. (스타일링을 다르게 하기 위해 별개로 분리했지만 사실 하나로 합쳐도 상관없습니다.)

setList

 setList(htmlPath, list, block) {
    document.startViewTransition(() => {
      this.prop.clear()
      if (!(list instanceof Array)) throw new Error("list는 배열이어야 합니다.")
      this.#load(htmlPath).then((htmlEl) => {
        const viewList = this.#appendList(htmlEl, list.length)
        viewList.forEach((view, idx) => {
          block(view, list[idx], idx)
        })
      })
    })
  }

리스트를 그릴땐 트랜지션을 주기 위해 startViewTrasnition을 걸어주었습니다. 

setList가 호출되면 기존의 element는 모두 지우고 새로 그려야 하므로 먼저 clear 해줍니다. 

appendList

#appendList(htmlEl, length) {
    const viewList = []
    const fragment = document.createDocumentFragment()
    for (let i = 0; i < length; i++) {
      const view = new ViewBuilder()
      view.element = htmlEl.cloneNode(true)
      viewList.push(view)
      fragment.appendChild(view.#element)
    }
    this.#element.appendChild(fragment)
    return viewList
  }

length만큼 view를 만들고 리스트를 반환합니다. setList에서는 해당 리스트를 사용합니다. 

 

이제 뷰모델을 만들어 보겠습니다. 

ViewModel

TodoModel

export class Todo {
   constructor() {
      this.todos = new Set();       
   }
   addTodo(text) {
      const todo = new TodoItem(text);
      this.todos.add(todo);
   }
   toggleTodo(todo) {
      todo.toggle();
   }
   removeTodo(todo) {
      this.todos.delete(todo);
   }
   getUncompletedTodos() {
      return Array.from(this.todos).filter(todo => !todo.completed);
   }
   getCompletedTodos() {
      return Array.from(this.todos).filter(todo => todo.completed);
   }
}

TodoItemModel

class TodoItem {
   constructor(text) {
      this.text = text;
      this.completed = false;
   }
   toggle() {
      this.completed = !this.completed;
   }
}

 

 

실행

app.js를 실행시키면

 

잘 동작하는 것을 볼 수 있습니다..! 이후로는 

 

1. 컴포넌트

2. 라우팅 처리

3. 에러 메시지 처리 

 

등등... 이제 여러 기능을 추가해보아도 좋을 것 같습니다. 

 

정말 간단하게 js로 동작하는 뷰모델 기반 렌더링 시스템을 만들어 보았습니다.