바닐라 js로 상태관리 할 수 있을까? [ #2 ]
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로 동작하는 뷰모델 기반 렌더링 시스템을 만들어 보았습니다.