주녁, DevNote
article thumbnail

개요

모던 자바스크립트의 구조를 학습하고 컴포넌트 기반의 코드를 작성한다.

목표

순수 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개에서 상태확인 요청을 계속 보내는 것도 상당한 자원 낭비이다.

이를 해결하기 위해 나온 것이 옵저버 패턴이다.

 

쉽게 말해 할리우드 원칙(=합격하면 알려줄게!)과 동일하게

상태가 변하면 구독한 사람에게 알려준다는 것이 핵심이다.

proshy님 블로그에서 참조한 그림입니다

 

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)

옵저버 패턴으로 바닐라JS 상태관리하기 (velog.io)

profile

주녁, DevNote

@junwork

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!