[Project] 버튼 이벤트 코드 리팩토링하기 2 (VanillaJS)

young_pallete·2021년 9월 7일
0

no#tation

목록 보기
11/11
post-thumbnail
post-custom-banner

시작하며 🌈

알고 보니 프로젝트 요구사항에 하위 documentform 밑에 렌더링되게 해놨던 걸 까먹고 있었습니다 😂

따라서 이를 어떻게 하면 좀 더 재사용성 높게 잘 쓸 수 있을까하다가, 또 리팩토링을 했어요!

결과적으로 어떻게 변했는지를 살펴봅시다!

본론 📖

기존 subDocument를 렌더링하는 로직은 다음과 같았어요.

customDOMMethods/_renderPosts.js 변경 전

export const _renderPosts = ($parentNode, nowDocuments) => {
  const {
    outlinedIcon,
    addIcon,
    sz150,
    postNextNewText,
    postNextNew,
    postNextNewIcon,
  } = names;

  if (!nowDocuments.length) {
    const $postNextNew = _createElemWithAttr('div', [postNextNew]);

    const $postNewIcon = _createElemWithAttr(
      'span',
      [outlinedIcon, sz150, postNextNewIcon],
      addIcon,
    );

    const $postNextNewText = _createElemWithAttr(
      'span',
      [postNextNewText],
      '빈 페이지',
    );

    _appendChilds($postNextNew, $postNewIcon, $postNextNewText);
    _appendChilds($parentNode, $postNextNew);
    return;
  }
  nowDocuments.map(doc => {
    const { id, title, documents: nextDocs } = doc;

    const post = new Post({
      $target: $parentNode,
      initialState: {
        id,
        title,
      },
    });

    _renderPosts(post.$postNext, nextDocs);
  });
};

나름 잘 짰다고 생각했는데요. 문제가 있었어요.
하위 documents에서는 Post라는 컴포넌트에서의 button-box가 없었으면 좋겠다는 생각을 하게 됐어요!
그렇다면 sideBar에서의 기능이 좀 더 잘 보이기도 하고, 굳이 편집을 하면서 보지도 않은 하위 document를 삭제할 이유가 없었기 때문이죠.

뭐, 있다고 하더라도, 나중에 className과 조건문만 바꾸면 되니, 이런 게 함수를 통한 리팩토링의 장점 아닐까요!

따라서 이를 일단 제 욕심(...)에 따라 없앨 수 있도록 설계를 했답니다.
먼저 isSideBar라는 매개변수를 받아봅시다.

customDOMMethods/_renderPosts.js 변경 후

export const _renderPosts = ($parentNode, nowDocuments, isSidebar = true) => {
  const {
    outlinedIcon,
    addIcon,
    sz150,
    postNextNewText,
    postNextNew,
    postNextNewIcon,
  } = names;

  if (!nowDocuments.length && isSidebar) {
    const $postNextNew = _createElemWithAttr('div', [postNextNew]);

    const $postNewIcon = _createElemWithAttr(
      'span',
      [outlinedIcon, sz150, postNextNewIcon],
      addIcon,
    );

    const $postNextNewText = _createElemWithAttr(
      'span',
      [postNextNewText],
      '빈 페이지',
    );

    _appendChilds($postNextNew, $postNewIcon, $postNextNewText);
    _appendChilds($parentNode, $postNextNew);
    return;
  }
  nowDocuments.map(doc => {
    const { id, title, documents: nextDocs } = doc;

    const post = new Post({
      $target: $parentNode,
      initialState: {
        id,
        title,
      },
      isSidebar,
    });

    _renderPosts(post.$postNext, nextDocs, isSidebar);
  });
};

Post.js 변경 전

import names from '@/utils/classNames';
import { _appendChilds, _createElemWithAttr } from '@/utils/customDOMMethods';

/*
  id, title
*/
const {
  postsItem,
  postToggleBtn,
  postCreateBtn,
  postRemoveBtn,
  postLink,
  postBlock,
  postNext,
  outlinedIcon,
  sharpIcon,
  sz150,
  sz175,
  addIcon,
  arrowRightIcon,
  removePostIcon,
} = names;

export default function Post({ $target, initialState }) {
  this.state = initialState;
  const { id, title } = this.state;

  this.$post = _createElemWithAttr('div', [postsItem, postBlock]);
  this.$postToggleButton = _createElemWithAttr(
    'button',
    [postToggleBtn, outlinedIcon, sz175, 'toggle'],
    arrowRightIcon,
  );
  this.$post.dataset['id'] = id;

  this.$postLink = _createElemWithAttr('a', [postLink], title);
  this.$postLink.dataset['id'] = id;

  this.$postCreateButton = _createElemWithAttr(
    'button',
    [postCreateBtn, outlinedIcon, sz150],
    addIcon,
  );
  this.$postRemoveButton = _createElemWithAttr(
    'button',
    [postRemoveBtn, sharpIcon, sz150],
    removePostIcon,
  );
  this.$postNext = _createElemWithAttr('section', [postNext]);
  this.$postNext.dataset['id'] = id;

  _appendChilds(
    this.$post, // append 대상
    this.$postToggleButton,
    this.$postLink,
    this.$postCreateButton,
    this.$postRemoveButton,
  );

  this.render = () => {
    $target.appendChild(this.$post);
    $target.appendChild(this.$postNext);
  };

  this.render();
}

Post.js 변경 후

import names from '@/utils/classNames';
import {
  _appendChilds,
  _createElemWithAttr,
  _renderChild,
} from '@/utils/customDOMMethods';

/*
  id, title
*/

export default function Post({ $target, initialState, isSidebar }) {
  const {
    postsItem,
    postToggleBtn,
    postButtonBox,
    postCreateBtn,
    postRemoveBtn,
    postLink,
    postBlock,
    postNext,
    sz150,
    sz175,
    outlinedIcon,
    sharpIcon,
    arrowRightIcon,
    removePostIcon,
    addIcon,
  } = names;

  this.state = initialState;
  const { id, title } = this.state;

  this.$post = _createElemWithAttr('div', [postsItem, postBlock]);
  this.$post.dataset['id'] = id;

  this.$postLink = _createElemWithAttr('a', [postLink], title);
  this.$postLink.dataset['id'] = id;

  this.$postToggleButton = _createElemWithAttr(
    'button',
    [postToggleBtn, outlinedIcon, sz175, 'toggle'],
    arrowRightIcon,
  );

  if (isSidebar) {
    this.$postButtonBox = _createElemWithAttr('div', [postButtonBox]);
    this.$postCreateButton = _createElemWithAttr(
      'button',
      [postCreateBtn, outlinedIcon, sz150],
      addIcon,
    );
    this.$postRemoveButton = _createElemWithAttr(
      'button',
      [postRemoveBtn, sharpIcon, sz150],
      removePostIcon,
    );
    _appendChilds(
      this.$postButtonBox,
      this.$postCreateButton,
      this.$postRemoveButton,
    );
    _appendChilds(
      this.$post, // append 대상
      this.$postToggleButton,
      this.$postLink,
      this.$postButtonBox,
    );
  } else {
    _appendChilds(
      this.$post, // append 대상
      this.$postToggleButton,
      this.$postLink,
    );
  }

  this.$postNext = _createElemWithAttr('section', [postNext]);
  this.$postNext.dataset['id'] = id;

  this.render = () => {
    _renderChild($target, this.$post, postsItem);
    _renderChild($target, this.$postNext, postNext);
  };

  this.render();
}

일단 기존 코드에서 조건만 해주면 되니, 간단하죠?

다만 주의할 건, 클래스명까지 반복해서 사용하기 때문에 추후 querySelector에서 앞의 인스턴스 설정에 유의해야겠죠?

그런 다음, subPosts를 만들어줍시다.

SubPosts.js

import checkState from '@/utils/checkState';
import names from '@/utils/classNames';
import {
  _createElemWithAttr,
  _removeAllChildNodes,
  _renderChild,
  _renderPosts,
} from '@/utils/customDOMMethods';
import { clickPosts, togglePosts } from '@/utils/customEvent';

export default function SubPosts({
  $target,
  initialState = {
    documents: [],
  },
  onClick,
}) {
  this.state = initialState;
  const { subPostsBlock } = names;
  const $subPosts = _createElemWithAttr('section', [subPostsBlock]);
  $target.appendChild($subPosts);

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

  this.render = () => {
    _renderChild($target, $subPosts);
  };
  togglePosts($subPosts);
  clickPosts($subPosts, onClick);
}

그 다음에는, 이제 PostEditPage에 붙여주면 끝이겠죠?!

...

  const subPosts = new SubPosts({
    $target: $postEditContainer,
    initialState: {
      documents: [],
    },
    onClick,
  });

  // id가 바뀔 때 페이지의 상태가 변화합니다!
  this.setState = async nextState => {
    ...
    subPosts.setState({
      documents: this.state.documents,
    });
    this.render();
  };

  this.render = () => {
    if (!$target.querySelector('form')) {
      postForm.render();
      subPosts.render();
    } // 에디터의 경우 여기서 렌더링을 해줘야, setState할 때 다시 렌더링되지 않습니다.
    ...
}

원래였으면 새로 만들 작업을, 기존 코드를 재사용함으로써 더욱 쉽고 간편하게 만들 수 있었군요!

그렇다면, 이를 좀 더 활용해볼까요?
우리는 이벤트를 이제, 기존 코드를 활용함으로써 재설계를 해봅시다.

SideBar.js

원래는 이렇게, 이벤트 버블링을 통해 큰 컴포넌트에서 조건을 통해 작은 버튼들을 동작시켰어요.
하지만 이 역시 결국 subPosts에서 중복이 되기 때문에, 우리 이걸 반복해서 사용해볼까요?

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

utils/customEvents.js

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

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

이제는 $target과 콜백 함수만 있으면 나머지를 수행해주는군요!
그렇다면 나머지 부분들을 처리해봅시다.

리팩토링 후 SideBar.js

  // click page
  clickPosts($sideBar, onClick);

  //toggle
  togglePosts($posts);

리팩토링 후 SubPost.js

  togglePosts($subPosts);
  clickPosts($subPosts, onClick);

이렇게 이벤트 역시 리팩토링하며 재사용성을 높일 수 있었어요!
결과적으로, SideBar 역시 기존 코드 대비 약 10%의 길이가 줄었으며, 더욱 이벤트에 대해 선언적으로 느껴지는군요!

흠... 나중에 좀 더 이벤트다!라는 느낌을 주기 위해 on...으로 이름을 바꿔주든 하면 더 좋을 거 같네요 😂


마치며 👏

이제 하위도 만들었으니, 상위를 만들면 되겠네요.
이는 거꾸로 재귀를 돌리면 쉽게 나올 거 같으니, 얼른 찾아뵐 수 있도록 해야겠습니다.

그렇다면, 다들 즐거운 코딩하새오! 😆😆

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

0개의 댓글