주녁, DevNote
article thumbnail

개요

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

(이 글은 Next Step에서 진행한 js-youtube-classroom을 따라 작성되었습니다.)

목표

  • Youtube  재생목록 어플리케이션을 작성한다.
  • 복잡해지는 컴포넌트 구조를 재사용 가능하도록 한다.
  • 복합적인 상태를 효과적으로 관리할 수 있는 방법을 탐색한다.
  • API를 활용하여 무한 스크롤 기능을 구현하도록 한다.

전체 코드는 여기에서 볼 수 있습니다.


여정

들어가기에 앞서

이번 시리즈는 지난 시간에 작성한 TodoList의 마지막 코드를 기반으로 시작한다.

당시 TodoList를 마무리하면서 부족했던 점을 개선하고,

기능을 덧대어 Youtube Playlist를 만들 것이다.

 

따라서, 시작 코드가 최종 코드가 아니며,

점차 개선하는 과정을 적어보려고 한다.


프로젝트 환경설정

기존 TodoList에서는 live-server를 통해서 서버를 띄워 동작했었다.

# live-server 설치
npm install -g live-server

# 실행
live-server .

 

여기서 한발 더 나아가 일관된 코드 스타일 적용을 위해

eslint를 적용시켜보았다. 

# lint 설치(Airbnb 스타일 적용)
npm install --save-dev eslint eslint-config-airbnb-base eslint-plugin-import

# lint 초기화
node_modules/.bin/eslint --init

# lint 자동 수정 적용
node_modules/.bin/eslint --fix .

 

참고로 .eslintrc.js에는 아래와 같은 rule을 적용했다.

  // .eslintrc.js
 ... 
  rules: {
    'class-methods-use-this': 'off',
    'no-new': 'off',
    'import/extensions': 'off',
    'import/prefer-default-export': 'off',
    'no-alert': 'off',
  },

시리즈 마지막 쯤에는 Webpack을 이용해

속도를 개선하는 방법을 적용해볼 것이다.


재사용 컴포넌트 소개

기존 TodoList에서 가져온 컴포넌트들을 간략하게 소개한다.

먼저 가장 부모 컴포넌트가 되는 코드이다.

// Component.js
export default class Component {
  $target; // 컴포넌트가 마운트되거나 렌더링되는 DOM 엘리먼트를 말한다. (= 컴포넌트 UI의 컨테이너)

  $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();
    });
  }

  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; }
      callback(event);
      event.stopImmediatePropagation();
    });
  }

  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));
        });
    */
  }
}

위 코드 중 setUp() 중에 observe가 바로 상태를 추적하는 옵저버 함수이다.

해당 코드는 아래와 같이 구성되어 있다.

// observer.js
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;
};
export const observable = (obj) => {
  const observerMap = obj
    ? Object.keys(obj).reduce((map, key) => ({ ...map, [key]: new Set() }), {})
    : {};

  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;

      // 값이 변경되면 옵저버를 실행한다.
      // eslint-disable-next-line no-param-reassign
      target[key] = value;
      observerMap[key].forEach((notify) => notify());
      return true;
    },
  });
};

위 observable은 Proxy 객체를 이용한다.

따라서, get, set이 일어날 때마다 진짜 객체의 동작을 가로채서 동작한다.

이를 활용하면 상태를 관리하는 Store를 만들 수 있다.

// createStore.js
const createStore = (reducer, storeName = 'state') => {
  // 스토어를 생성한다.
  // 스토어는 상태를 관리하고, 상태를 변경하는 기능을 가진다.

  // reducer 가 실행될 때
  // 반환하는 객체 State 를 Observable 로 만든다.
  const initialState = reducer();
  const storedState = JSON.parse(localStorage.getItem(storeName));
  const state = observable(storedState || initialState);

  const dispatch = (action) => {
    // 액션을 실행한다.
    // 액션을 실행하면 reducer 가 실행되고,
    // reducer 가 반환하는 새 객체를 state 에 할당된다.
    const newState = reducer(state, action);

    Object.entries(newState).forEach(([key, value]) => {
      if (state[key]) { state[key] = value; }
    });
    // 변경된 state를 localStorage에 저장한다.
    localStorage.setItem(storeName, JSON.stringify(state));
  };

  // state 를 변경할 수 없도록 한 frozenState 를 만든다.
  const frozenState = {};
  Object.keys(state).forEach((key) => {
    Object.defineProperty(frozenState, key, {
      get: () => state[key],
    });
  });

  // frozenState 를 반환하는 getState 함수를 만든다.
  const getState = () => frozenState;

  return { getState, dispatch };
};

기존 TodoList는 저장소 이름이 고정적이여서

Store를 한가지만 만들 수 있었다.

 

따라서 개선한 점은 localStorage에 저장할 때,

저장소 이름을 설정할 수 있도록 한 것이다.

 

이는 추후에 나올 Modal Store를 만들때 활용하기 위해서이다.


마무리

자, 여기까지 시리즈의 개요와 환경설정,

그리고 재사용 컴포넌트를 소개해보았다.

 

다음 시간에는 본격적으로 요구사항을 분석하고

컴포넌트를 작성해보자.


profile

주녁, DevNote

@junwork

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