개요
모던 자바스크립트의 구조를 학습하고 컴포넌트 기반의 코드를 작성한다.
목표
순수 Javascript로 작성된 Todo List 어플리케이션을 작성한다.
(이 글은 Next Step에서 진행한 js-todo-list-step을 따라 작성되었습니다.)
전체 코드는 여기에서 보실 수 있습니다.
여정
환경 구축
초기 상태를 만들어둔 init 브랜치에서 진행한다.
# 로컬 터미널에서
git clone https://github.com/junwork123/js-todo-list.git
git checkout init
# 실행을 위한 live-server 설치
npm install -g live-server
아래의 명령어를 통해 실시간으로 웹페이지를 테스트해볼 수 있다.
live-server .
윈도우에서 실행 시 `보안 오류`가 발생하는 경우
아래 명령으로 해결할 수 있다.
# Powershell 관리자 모드에서
Set-ExecutionPolicy Unrestricted
컴포넌트 구조
먼저 황준일님의 정말 좋은 포스팅(컴포넌트, 상태관리)을 보게 되어 진심으로 감사드린다!
이 글을 바탕으로 컴포넌트 구조를 따라해보았다.
스스로 설명할 수 있는 코드가 중요하다고 생각하기 때문에 코드를 토막내어 설명해보겠다.
생성자
import { observable, observe } from "./observer.js";
export default class Component{
$target;
$props; // 부모 컴포넌트가 자식 컴포넌트에게 상태 혹은 메소드를 넘겨주기 위해서
$state;
constructor($target, $props = {}) {
this.$target = $target;
this.$props = $props;
this.setUp();
}
setUp () {
// 컴포넌트가 마운트되기 전에 호출
// 컴포넌트를 초기화하는데 사용한다.
this.$state = observable(this.initState());
observe(() => {
this.render();
this.setEvent();
this.mounted();
});
}
...
먼저 import 되는 observable과 observe는 아래에서 따로 설명하도록 하고
주로 사용되는 3가지 프로퍼티를 알아보자
- $target : 컴포넌트가 마운트되거나 렌더링되는 DOM 엘리먼트를 말한다. (= 컴포넌트 UI의 컨테이너)
- $props : 부모 컴포넌트에서 전달되는 데이터, 메소드를 담은 객체. 전달받은 자식 컴포넌트는 이를 수정해선 안된다.
- $state : 컴포넌트 내부 상태를 나타내는 객체. 이벤트에 응답하여 컴포넌트 자체에서 수정된 뒤 렌더링한다.
$target과 $props는 외부에서 값을 전달해줘야 하므로 생성자 매개변수로 지정하였다.
$state는 내부에서 생성되도록 한 이유는 스스로 초기 상태를 지정해주는 것이 의존성을 낮추기 때문이다.
자 이제 observable과 observe을 설명해야 할 때가 왔다.
Observable과 Observe
observable과 observe는 말 그대로 옵저버 패턴에서 유래한 객체이다.
상태가 변경될 때 렌더링할 객체가 100개라면,
100개에서 상태확인 요청을 계속 보내는 것도 상당한 자원 낭비이다.
이를 해결하기 위해 나온 것이 옵저버 패턴이다.
쉽게 말해 할리우드 원칙(=합격하면 알려줄게!)과 동일하게
상태가 변하면 구독한 사람에게 알려준다는 것이 핵심이다.
export const observable = obj => {
const observerMap = obj ? Object.keys(obj).reduce((map, key) => {
map[key] = new Set();
return map;
}, {}) : {};
return new Proxy(obj, {
get: (target, key) => {
// 현재 옵저버를 등록한 뒤, 해당하는 프로퍼티의 값을 반환한다.
if(currentObserver) observerMap[key].add(currentObserver);
return target[key];
},
set: (target, key, value) => {
// 값이 동일하면 업데이트하지 않는다.
if(target[key] === value) return true;
if(JSON.stringify(target[key]) === JSON.stringify(value)) return true;
// 값이 변경되면 옵저버를 실행한다.
target[key] = value;
observerMap[key].forEach(notify => notify());
return true;
}
});
}
observable(발행자)은 자체적으로 observerMap이라는 변수를 가지고 있다.
내 상태를 구독하는 구독자의 목록이다.
중요한 동작은 리턴하는 Proxy 쪽에 있다.
Proxy 객체는 원본 객체의 동작을 가로채어 원본 객체의 동작 사이에 행동을 끼워넣을 수 있게 해준다.
- get : observable에 접근할 때마다 호출 / 구독자 목록에 옵저버 추가 / 호출한 객체(target)에 그 값을 반환
- set : observable에 값을 할당할 때마다 호출 / 값이 같으면 종료 / 값이 다르면 구독자의 구독함수 실행(알림)
- 이 외에도 has(in 연산자), deleteProperty(삭제) , apply(함수 호출), new(생성자) 등이 있다.
그러면 구독자의 코드는 어떨까?
맨 아래 observe가 정의된 부분부터 보도록 하자.
let currentObserver = null;
const debounceFrame = (callback, delay = -1) => {
// 한 프레임에 한번만 렌더링되도록 한다.
let currentCallback = delay;
return () => {
// 현재 등록된 콜백이 있을 경우 취소하고 1프레임 뒤에 새로운 콜백을 등록한다.
cancelAnimationFrame(currentCallback);
currentCallback = requestAnimationFrame(callback);
}
}
export const observe = notify => {
// 현재 옵저버를 등록한다.
currentObserver = debounceFrame(notify);
notify();
currentObserver = null;
}
코드는 단순하게 3줄이고, 요약하면 한줄이다.
구독자의 구독 함수를 실행한다.
좀 더 풀어서 설명하자면 아래와 같다.
- currentObserver는 현재 실행하는 구독 함수를 담아두기 위한 변수다.
- observable에서 currentObserver가 null이면 구독함수가 실행되지 않는다.
- notify()에서 observable의 변수를 조회할 때 observable의 get 함수가 호출되면서 옵저버가 등록된다.
- debounceFrame는 한 프레임에 여러번 요청이 들어올 경우 마지막 요청만 실행할 수 있도록 한다.
원래 코드로
다시 원래 코드로 돌아오도록 하자.
setUp () {
// 컴포넌트가 마운트되기 전에 호출
// 컴포넌트를 초기화하는데 사용한다.
this.$state = observable(this.initState());
observe(() => {
this.render();
this.setEvent();
this.mounted();
});
}
observable로 이 컴포넌트의 초기상태를 발행자로 만들어서 state에 저장한다.
그리고 값이 변경되면
- 화면을 그리고(render)
- 이벤트를 설정하고(setEvent)
- 라이프 사이클 종료시 처리(mounted)를 한다.
재사용 메서드
뼈대로 작성한 코드이기 때문에 실제 내용은 없지만
보통 아래와 같은 모양을 지니게 된다.
initState () { return {}; }
mounted () {
// 컴포넌트가 마운트된 후에 동작한다.
}
template () {
// 컴포넌트의 내용을 반환
return '';
}
render () {
// 컴포넌트를 렌더링한다.
this.$target.innerHTML = this.template();
this.mounted();
}
addEvent (eventType, selector, callback) {
// 컴포넌트의 이벤트를 추가한다.
this.$target.addEventListener(eventType, event => {
if(!event.target.closest(selector)) return false;
callback(event);
});
}
setEvent () {
// 컴포넌트의 모든 이벤트를 등록한다.
// 모든 이벤트를 this.$target 에 등록하여 사용하면 된다. (이벤트 버블링)
/* ex)
const { deleteItem, toggleItem } = this.$props;
this.addEvent('click', '.deleteBtn', ({target}) => {
deleteItem(Number(target.closest('[data-seq]').dataset.seq));
});
this.addEvent('click', '.toggleBtn', ({target}) => {
toggleItem(Number(target.closest('[data-seq]').dataset.seq));
});
*/
}
실제 Component를 extend로 상속하여 사용하면 위 함수들을 재정의하여 사용하게 된다.
짜잘한 팁
- insertAdjacentHTML는 DOM을 새로 그리지 않고 요소를 추가한다.
- Nullish coalescing operator ( = ??)는 null이거나 undefine인 요소를 초기화해준다. (ES2020에서 추가됨)
this.todos = JSON.parse(localStorage.getItem('todos')) ?? [];
- 객체가 가진 메서드를 콜백함수로 전달할때, 컨텍스트가 사라지는 것을 방지하려면 bind함수를 사용한다.
this.$newTodoTitle.addEventListener('keyup', this.addTodo.bind(this));
- 익명함수를 변수처럼 사용하면 메모리를 절약할 수 있다.
toggleTodo = ({ target }) => { ... }
- .foreach는 원본 배열을 변경하지 않고 undefined을 반환한다. 반면, .map은 반환값으로 구성된 새 배열을 반환한다.
마무리
다음 시간에는 현재 작성한 Component와 Observer를 이용해
실제 TodoList를 만들기 위한 컴포넌트를 작성할 것이다.
참고자료
Vanilla Javascript로 상태관리 시스템 만들기 | 개발자 황준일 (junilhwang.github.io)
Vanilla Javascript로 웹 컴포넌트 만들기 | 개발자 황준일 (junilhwang.github.io)
'Frontend' 카테고리의 다른 글
모던 자바스크립트 TodoList - Todo Component (3) (0) | 2023.05.08 |
---|---|
모던 자바스크립트 TodoList - Todo Component (2) (0) | 2023.05.06 |
모던 자바스크립트 TodoList - Todo Component (1) (0) | 2023.05.02 |
모던 자바스크립트 TodoList - User Component (0) | 2023.04.21 |
모던 자바스크립트 TodoList - Store & Reducer (0) | 2023.04.17 |