[Project] 중복되는 코드 리팩토링하기 (VanillaJS)

young_pallete·2021년 9월 7일
0

no#tation

목록 보기
10/11
post-thumbnail

시작하며 🌈

저는 제 코드를 볼 때 항상 다음을 우려합니다.

  • 이 코드, 과연 나중에 또 쓸 수 있을까?
  • 뭔가 중복되는 듯한 느낌이 드는데?!
  • 성능 면에서 영 꽝 아냐?!

그러다 보니, 이번에도 뭔가 제 코드를 보는데, 이벤트 쪽에 있어서 중복되는 부분이 몇 차례보이게 됐어요.
따라서 시작합니다. 버튼 코드 리팩토링!

본론 📖

제가 리팩토링하기로 결정한 코드는 다음 sidebar 부분이였어요.

import {
  _removeAllChildNodes,
  _createElemWithAttr,
  _renderPosts,
} from '@/utils/customDOMMethods';
import names from '@/utils/classNames';
import createPost from '@/apis/route/post/createPost';
import Modal from '@/components/common/Modal';
import { push } from '@/apis/router';
import { ERROR_STATUS } from '@/utils/constants';
import deletePost from '@/apis/route/post/deletePost';
import getPostList from '@/apis/route/post/getPostList';
import Button from '@/components/common/Button';
import checkState from '@/utils/checkState';

/*
  {
    username,
    documents: []
  }
*/

export default function SideBar({ $target, initialState, onClick }) {
  const {
    postsItem,
    postsBlock,
    sideBarItem,
    sideBarContainer,
    sideBarButtonBox,
    sideBarCreatePostBtn,
    postBlock,
    postToggleBtn,
    postNext,
    postLink,
    postNextNew,
    postRemoveBtn,
  } = names;
  this.state = initialState;

  const $sideBar = _createElemWithAttr('nav', [sideBarContainer]);
  const $posts = _createElemWithAttr('section', [sideBarItem, postsBlock]);
  const $sideBarButtonBox = _createElemWithAttr('div', [sideBarButtonBox]);
  new Button({
    $target: $sideBarButtonBox,
    attributes: { classNames: [sideBarCreatePostBtn], text: '페이지 생성' },
    onClick: () => {
      const $app = document.querySelector('#app');
      const modal = new Modal({
        $target: $app,
        head: '생성할 페이지의 제목을 입력해주세요!',
        isInput: true,
        onConform: async title => {
          try {
            const result = await createPost(this.state.username, {
              title,
              parent: null,
            });
            push(`/posts/${result.id}`);
          } catch (e) {
            console.error(e);
            alert(ERROR_STATUS, e);
          }
        },
      });
      modal.render();
    },
  });
  $sideBar.appendChild($sideBarButtonBox);

  this.setState = nextState => {
    if (!checkState(this.state, nextState)) {
      _removeAllChildNodes($posts);
      this.state = nextState;
      const { documents } = this.state;
      const $fragment = new DocumentFragment();
      _renderPosts($fragment, documents);
      $posts.appendChild($fragment);
      $sideBar.appendChild($posts);
    }
    this.render();
  };

  this.render = () => {
    console.log($sideBar, $target);
    if (!$target.querySelector(`.${sideBarContainer}`))
      $target.append($sideBar);
  };

  $sideBar.addEventListener('click', e => {
    if (
      !e.target.classList.contains(postsItem) &&
      !e.target.classList.contains(postLink)
    ) {
      return;
    }
    const postId = e.target.closest(`.${postsItem}`).getAttribute(['data-id']);
    onClick(postId);
  });

  $posts.addEventListener('click', e => {
    const { target } = e;
    if (!target.classList.contains(postToggleBtn, 'post__link')) return;
    const closestPostId = target.closest(`.${postBlock}`).dataset.id;
    const $nextItem = $posts.querySelector(
      `.${postNext}[data-id="${closestPostId}"]`,
    );
    $nextItem.classList.toggle('invisible');
    target.classList.toggle('toggle');
  });

  $sideBar.addEventListener('click', e => {
    const closestPostNextNew = e.target.closest(`.${postNextNew}`);
    if (!closestPostNextNew) return;
    const $app = document.querySelector('#app');
    const closestPostNext = e.target.closest(`.${postNext}`);
    const modal = new Modal({
      $target: $app,
      head: '생성할 페이지의 제목을 입력해주세요!',
      isInput: true,
      onConform: async title => {
        try {
          const result = await createPost(this.state.username, {
            title,
            parent: closestPostNext.dataset.id,
          });
          push(`/posts/${result.id}`);
        } catch (e) {
          console.error(e);
          alert(ERROR_STATUS, e);
        }
      },
    });
    modal.render();
  });

  $sideBar.addEventListener('click', e => {
    if (!e.target.classList.contains(postRemoveBtn)) return;
    const closestPostNext = e.target.closest(`.${postsItem}`);
    const modal = new Modal({
      $target: document.querySelector('#app'),
      head: '정말로 삭제하시겠어요?',
      isInput: false,
      onConform: async () => {
        try {
          const { id } = closestPostNext.dataset;
          await deletePost(this.state.username, id);
          const posts = await getPostList(this.state.username);
          this.setState({
            documents: posts,
          });
          if (id === window.location.pathname.split('/')[2]) {
            push('/');
          }
        } catch (e) {
          console.error(e);
          alert(ERROR_STATUS, e);
        }
      },
    });
    modal.render();
  });
}

어떤가요? 뭔가 중복되었다는 느낌이 들지 않나요?!
저의 경우 버튼이 클릭 되었을 때 모달을 동작시키는 일련의 절차들 역시 포맷화시킬 수 있지 않을까라는 생각이 들었어요.

첫번째, 공통적인 부분을 추려내자.

따라서 공통적인 부분을 일단 추려냈습니다.
그 결과, 모달의 이벤트를 실행하는 부분에서 다음과 같이 결론을 지었어요.

  1. 이벤트는 여러 곳에서 쓸 수 있으며, 또한 함수로 만드는 것보다 addEventListener의 형태로 남는 게 더욱 시멘틱하니 건드리지 말자.
  2. 그렇다면 일단 우리가 일반적으로 모달을 실행시킬 건데, 필요한 매개변수는 모달의 제목과 input이 달려있는지, 그리고 try할 때 구문이다.
  3. 따라서 이 세 개를 매개변수로 갖는 함수로 만들어서, 앞으로 반복되는 코드에 재사용하자!

따라서 저는 이러한 모달을 만드는 코드에 대해 utils라는 디렉토리에 넣고, 앞으로 컴포넌트 렌더링에 있어 유용한 함수들을 모아넣겠다!는 의미에서 renderComponentMethods.js라는 모듈을 만들어냈습니다.

renderComponentMethods.js

import Modal from '@/components/common/Modal';
import names from '@/utils/classNames';
import { ERROR_STATUS } from '@/utils/constants';

export const renderModalByEvent = ({ head, isInput, tryFunc }) => {
  const { container } = names;
  const $app = document.querySelector('#app');
  const modal = new Modal({
    $target: document.querySelector('#app'),
    head,
    isInput,
    onConform: async content => {
      try {
        await tryFunc(isInput && content);
      } catch (e) {
        console.error(e);
        alert(ERROR_STATUS, e);
      } finally {
        if ($app.querySelector(`.${container}`)) {
          $app.removeChild(modal.$container);
        }
      }
    },
  });
  modal.render();
};

뭔가 꽤나 많이 떼어냈어요!
벌써부터 한 코드 당 저만큼 생략할 생각을 하면 설레지 않나요?!

그렇다면, 다시 sidebar로 넘어가서 이를 적용해봅시다.

sidebar.js

여기서도 저는 또 함수를 만들어줬어요.
아무래도 버튼을 클릭할 때 나타나는 tryFunc마저 해당 컴포넌트에서는 거의 비슷했기 때문이죠.
따라서 이를 적용하는 openCreatePageModal이라는 함수를 만들어줬답니다.

sidebar.js/openCreatePageModal

  const openCreatePageModal = async $elem => {
    renderModalByEvent({
      head: INPUT_TITLE_MESSAGE,
      isInput: true,
      tryFunc: async title => {
        const result = await createPost(this.state.username, {
          title,
          parent: $elem?.dataset.id ?? null,
        });
        push(`/posts/${result.id}`);
      },
    });
  };

그런데 사실 또 불만이었던 게 있어요.
check를 한 다음 -> 모달을 만드는 과정에 있어서, 이 과정 역시 반복되는 느낌이 강했죠.
따라서 check -> openCreatePageModal을 실행하는 함수 역시 또 만들어 줍니다.

sidebar.js/renderModal

  const renderModal = async ({ eventTarget, isValid, closestSelectorName }) => {
    if (!isValid) return;
    const $elem = eventTarget.closest(`.${closestSelectorName}`);
    await openCreatePageModal($elem);
  };

어때요, 간단하죠?

결론적으로 얼마나 줄여지는 지 볼까요?

  new Button({
    $target: $sideBarButtonBox,
    attributes: { classNames: [sideBarCreatePostBtn], text: '페이지 생성' },
    onClick: () => {
      const $app = document.querySelector('#app');
      const modal = new Modal({
        $target: $app,
        head: '생성할 페이지의 제목을 입력해주세요!',
        isInput: true,
        onConform: async title => {
          try {
            const result = await createPost(this.state.username, {
              title,
              parent: null,
            });
            push(`/posts/${result.id}`);
          } catch (e) {
            console.error(e);
            alert(ERROR_STATUS, e);
          }
        },
      });
      modal.render();
    },
  });

이랬던 코드가!

  new Button({
    $target: $sideBarButtonBox,
    attributes: {
      classNames: [sideBarCreatePostBtn],
      text: BUTTON_COMPONENT_TEXT,
    },
    onClick: () => openCreatePageModal(),
  });

막상 하고 나니, 엄청 많이 간소화되었네요!

리팩토링 후

export default function SideBar({ $target, initialState, onClick }) {
  const {
    sideBarContainer,
    postsItem,
    postsBlock,
    sideBarItem,
    sideBarButtonBox,
    sideBarCreatePostBtn,
    postBlock,
    postToggleBtn,
    postNext,
    postLink,
    postNextNew,
    postCreateBtn,
    postRemoveBtn,
  } = names;

  const $sideBar = _createElemWithAttr('nav', [sideBarContainer]);
  const $posts = _createElemWithAttr('section', [sideBarItem, postsBlock]);
  this.state = initialState;

  const $sideBarButtonBox = _createElemWithAttr('div', [sideBarButtonBox]);
  new Button({
    $target: $sideBarButtonBox,
    attributes: {
      classNames: [sideBarCreatePostBtn],
      text: BUTTON_COMPONENT_TEXT,
    },
    onClick: () => openCreatePageModal(),
  });
  $sideBar.appendChild($sideBarButtonBox);

  this.setState = nextState => {
    if (!checkState(this.state, nextState)) {
      _removeAllChildNodes($posts);
      this.state = nextState;
      const { documents } = this.state;
      const $fragment = new DocumentFragment();
      _renderPosts($fragment, documents);
      $posts.appendChild($fragment);
      $sideBar.appendChild($posts);
    }
    this.render();
  };

  this.render = () => {
    if (!$target.querySelector(`${sideBarContainer}`)) {
      $target.appendChild($sideBar);
    }
  };

  $sideBar.addEventListener('click', e => {
    const { classList } = e.target;
    if (!classList.contains(postsItem) && !classList.contains(postLink)) return;
    const postId = e.target.closest(`.${postsItem}`).getAttribute(['data-id']);
    onClick(postId);
  });

  $posts.addEventListener('click', e => {
    const { target } = e;
    if (!target.classList.contains(postToggleBtn, 'post__link')) return;
    const closestPostId = target.closest(`.${postBlock}`).dataset.id;
    const $nextItem = $posts.querySelector(
      `.${postNext}[data-id="${closestPostId}"]`,
    );
    $nextItem.classList.toggle('invisible');
    target.classList.toggle('toggle');
  });

  $sideBar.addEventListener('click', e => {
    renderModal({
      eventTarget: e.target,
      isValid: e.target.closest(`.${postNextNew}`),
      closestSelectorName: postNext,
    });
  });

  $sideBar.addEventListener('click', e => {
    renderModal({
      eventTarget: e.target,
      isValid: e.target.classList.contains(postCreateBtn),
      closestSelectorName: postsItem,
    });
  });

  $sideBar.addEventListener('click', e => {
    if (!e.target.classList.contains(postRemoveBtn)) return;
    const closestPostsItem = e.target.closest(`.${postsItem}`);
    renderModalByEvent({
      head: MODAL_DELETE_QUESTION,
      isInput: false,
      tryFunc: async () => {
        await deletePost(this.state.username, closestPostsItem.dataset.id);
        const posts = await getPostList(this.state.username);
        this.setState({
          documents: posts,
        });
      },
    });
  });

  const renderModal = async ({ eventTarget, isValid, closestSelectorName }) => {
    if (!isValid) return;
    const $elem = eventTarget.closest(`.${closestSelectorName}`);
    await openCreatePageModal($elem);
  };

  const openCreatePageModal = async $elem => {
    renderModalByEvent({
      head: INPUT_TITLE_MESSAGE,
      isInput: true,
      tryFunc: async title => {
        const result = await createPost(this.state.username, {
          title,
          parent: $elem?.dataset.id ?? null,
        });
        push(`/posts/${result.id}`);
      },
    });
  };
}

결과적으로 이렇게 하니,

  • 리팩토링 함수를 제외한 코드가 약 20% 짧아지는 효과가 있으며,
  • 모달을 생성하는 함수 이벤트의 코드가 더욱 직관적이게 되었어요!

마치며 👏

리팩토링하는 순간에는 참 많은 고민이 드는데요.
그래도, 끝내고 나면

  • 앞으로 뭔가 갑작스레 변경사항이 생긴다거나,
  • 새롭게 만들어야 하는 상황이 생기면 재사용하기 편해서 걱정이 덜어져요!

다들, 깨끗한 코드와 함께 프로젝트 열심히 해봅시다 🖐
비록 좋은 코드는 아니지만, 이 글이 누군가에게 참고가 되었으면 좋겠어요 😆 이상!

💡 혹시 더 깔끔한 리팩토링 방안이 있다면, 아낌없는 피드백 주신다면 감사히 참고하겠습니다 :)

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글