본문 바로가기
포트폴리오

(포트폴리오)CaptureMark 제작기 -2편 (vanillaJS 컴포넌트 구현)

by 흥부와놀자 2024. 2. 6.

지난 1편에서 기획, 설계, 디자인을 빠르게 진행했고, 본격적으로 vanillaJS로 개발을 시작하였다.

 

- 프로젝트 구성

크롬 익스텐션 개발은 chrome://extensions/ 에 들어가서 구성된 파일을 로드해주면 된다.

(https://support.google.com/chrome/a/answer/2714278?hl=ko 자세한 설명은 여길 참고하면 된다.)

 

파일엔 전체 manifest파일, 앱 클릭시 띄워줄 popup html, background.js, 다른 진입점 파일, 아이콘 과 같은 리소스 파일을 포함해야 한다. 나의 경우엔 각 진입점 기준으로 js를 번들링 해주고 빌드파일만 업로드할 수 있게 간단히 webpack으로 빌드 폴더를 세팅했다.

const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');

module.exports = {
  entry: {
    background: './src/background.js',
    popup: './src/popup.js',
    screenshot: './src/screenshot.js',
  },
  output: {
    filename: './src/[name].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  module: {
    rules: [
      { test: /\.s?css$/, use: ['style-loader', 'css-loader', 'sass-loader'] },
      { test: /\.png/, type: 'asset/resource' },
    ],
  },
  plugins: [
    new CopyPlugin({
      patterns: [
        { from: './public', to: 'public' },
        { from: './popup.html', to: '' },
        { from: './screenshot.html', to: '' },
        { from: './manifest.json', to: '' },
      ],
    }),
  ],
};

스타일은 간편하게 scss 전처리기를 사용했다. (기본 css보다 selector가 중첩될때 편했다.)

만들어진 빌드파일

이렇게 빌드파일을 만들어서 올리면 

요렇게 확장 프로그램이 뜨게 되는데, 빌드파일을 업데이트한 후 저기서 새로고침 버튼을 눌러주면 내 크롬 익스텐션앱에 반영이 된다.

 

기술할 팝업 레이아웃 개발할때는 webpack-dev-server로 실행 시켜서 페이지에서 팝업 html 레이아웃 틀만 맞춰준 후 진행 하였다.

 

- 컴포넌트 구조

구현 방향은 기존에 썻던 리액트와 같이 단방향으로 렌더링시키는 컴포넌트 구조로 잡았다. 컴포넌트 구조로 만드는게 유지보수하기에 용이하고 구조가 깔끔해 진다.

 

또한 단방향 바인딩이기 때문에 중심 데이터만 신경써주면 View에서는 출력만 해주면 된다.

JQuery와 같은 양방향 바인딩에 비해 데이터의 흐름이 명확해지고 예측가능해지기 때문에 사이드 이펙트를 줄일 수 있다.

 

가장 먼저 디렉토리 팝업에서 쓸 팝업 타이틀 컴포넌트를 만들었다. 

 

// PopupTitle 컴포넌트 
export function PopupTitle({ root, initialState }) {
  this.target = document.createElement('div'); // root Element
  this.target.className = 'popup-title';
  root.appendChild(this.target);
  this.state = initialState;
  this.render = () => {
    this.target.innerHTML = `<div class="title-group"><img src="${this.state.logoSrc || ''}"/><span class="title-text">${this.state.title || ''}</span></div>`;
  };
  this.render();
}

// popup.js - PopupTitle 컴포넌트 사용
const root = document.querySelector('.popup');
new PopupTitle({
  root,
  initialState: {
    title: 'Capture Mark',
    logoSrc: 'public/assets/logo/captureMark48.png',
  },
});

앞으로 사용할 기본 컴포넌트의 구조를 볼 수 있는데, 함수의 props로 root와 initialState가 들어온다. root는 외부에서 연결 시켜줄 부모 엘레먼트이고, initialState는 컴포넌트 첫 호출시 매핑되는 기본 상태값이다.  target은 해당 컴포넌트의 최상단 엘레먼트이고, root 엘레먼트의 자식으로 연결시켜준다.

state는 해당 컴포넌트가 가지는 상태이다. 첫 컴포넌트 호출 이후 this를 통해 상태를 유지 시킬 수 있다.   render함수는 컴포넌트 상태가 변해야 할때 호출하는 함수이다. 

 

처음에 PopupTitle을 객체화 하지않고 일반함수에서 this를 찍었는데, this가 계속 undefined 찍히고 있었다. (원래 명시적으로 바인딩 시키지 않는한 function내부에선 this가 window찍혀야 한다.)

 

헤매다가 알고보니 webpack이 번들링할때 자동으로 use strict를 붙여주고 있었다. (use strict에선 전역객체 this는 undefined로 매핑된다) 

 

 

다음은 디렉토리 버튼 컴포넌트이다.

버튼의 개수는 8개로 고정되어 있고, 모든 버튼은 DirectoryContent라는 컴포넌트에서 관리된다.

외부에서 디렉토리 정보를 받아와서 각 텍스트값이 매핑된다.

클릭하면 초록색으로 배경색이 바뀌고, 더블클릭하면 input 텍스트로 바뀌면서 글자를 수정할 수 있다.

수정한후 다른 곳을 클릭하여 포커스아웃 되면 수정된 값으로 기본 텍스트로 바뀌게 된다. 

 

// 디렉토리 버튼 감싸는 컨텐트 컴포넌트
export function DirectoryContent({ root, initialState = { children: [] } }) {
  this.target = document.createElement('div');
  this.target.className = `directory-content`;
  root.appendChild(this.target);
  this.state = initialState;
  this.childrenInstance = [];

  this.setState = newState => {
    this.state = newState;
    this.render();
  };
  this.clicked = undefined;

  this.target.onclick = ev => {
    const $idx = getIdxFromClassList(ev.target.classList);
    if ($idx === undefined || $idx === null || $idx < 0) {
      return;
    }
    const newChildren = this.state.children.map((c, idx) => {
      const newChild = { ...c };
      if ($idx === idx) {
        newChild.active = true;
        // clicked가 없는데 클릭했으면 clicked 넣어주고 setTImeOut실행
        if (!this.clicked) {
          const clickTimeout = setTimeout(() => {
            this.clicked = undefined;
          }, 500);
          this.clicked = clickTimeout;
        } else {
          // clicked가 있는데 클릭했으면 더블클릭한 경우이므로 edit으로 바꿔주고 clearTimeout해주기
          clearTimeout(this.clicked);
          this.clicked = undefined;
          newChild.edit = true;
        }

        return newChild;
      } else {
        newChild.active = false;
        newChild.edit = false;
        return newChild;
      }
    });
    this.setState({ children: newChildren });
  };
  this.target.addEventListener('change', event => {
    console.log('Input chang evnet', event.target.value);
    //다시 edit이전 상태로 돌아가기
    const $idx = getIdxFromClassList(event.target.classList);
    const newChildren = this.state.children.map((child, idx) => {
      const newChild = { ...child };
      if (idx === $idx && event.target.classList[0] === 'btn-input-text') {
        newChild.text = event.target.value;
        return newChild;
      } else {
        return newChild;
      }
    });
    this.setState({ children: newChildren });
  });
  this.target.addEventListener('focusout', event => {
    console.log('Input lost focus', event.target, event);
    // 다시 edit이전 상태로 돌아가기
    const $idx = getIdxFromClassList(event.target.classList);
    const newChildren = this.state.children.map((child, idx) => {
      const newChild = { ...child };
      if (idx === $idx && event.target.classList[0] === 'btn-input-text') {
        newChild.edit = false;
        console.log('newChild', idx, newChild);
        return newChild;
      } else {
        return newChild;
      }
    });
    this.setState({ children: newChildren });
  });

  this.render = () => {
    if (this.state.children && this.state.children.length > 0) {
      const isNoneActive = !this.state.children.some(c => c.active);
      this.state.children.forEach((child, idx) => {
        let cState = { ...child, id: idx };
        if (isNoneActive) {
          cState = { ...child, id: idx, active: idx === 0 };
        }
        if (this.childrenInstance.length > idx && this.childrenInstance[idx]) {
          this.childrenInstance[idx].setState(cState);
        } else {
          const directoryButton = new DirectoryButton({
            root: this.target,
            initialState: cState,
          });
          this.childrenInstance.push(directoryButton);
        }
      });
    }
  };
  this.render();
}

첫번째로 생각해야 할것은 Content 컴포넌트는 내부의 각 버튼의 상태를 관리 할수 있어야 했기에, 각 자식 요소의 이벤트 결과를 어떻게  받아 올것인가 였다.

props로 버튼 데이터 변경 함수를 넘겨주는 방법도 쓸까 했지만, 깔끔하게 이벤트 버블링을 이용하기로 했다. (구글링해보니 다른사람들도 이렇게 많이 쓰고 있었다.)

 

각 버튼의 태그에 id를 박아두고 버블링 되어 올라오는 이벤트로 넘어오는 target을 구별시켰다.

this.target.addEventListener('focusout', event => {
  console.log('Input lost focus', event.target, event);
  // 다시 edit이전 상태로 돌아가기
  const $idx = getIdxFromClassList(event.target.classList);
  const newChildren = this.state.children.map((child, idx) => {
    const newChild = { ...child };
    if (idx === $idx && event.target.classList[0] === 'btn-input-text') {
      newChild.edit = false;
      console.log('newChild', idx, newChild);
      return newChild;
    } else {
      return newChild;
    }
  });
  this.setState({ children: newChildren });
});

포커스 아웃 이벤트 리스너 코드인데, target의 class에서 파싱한 아이디값과 클래스를 통해 변경된 자식 데이터만 수정하는 것을 볼 수 있다. 

 

두번쨰는 그냥 클릭해서 선택할때와 텍스트 수정을 위해 더블클릭한 경우를 어떻게 구별할 것인가 였다.

생각한 방법은 첫클릭시 무조건 버튼 선택이 되며, 첫클릭 이후 0.5초내에 다시 클릭했을때 더블클릭되어 input으로 바뀌게 하고 싶었다.

this.target.onclick = ev => {
  const $idx = getIdxFromClassList(ev.target.classList);
  if ($idx === undefined || $idx === null || $idx < 0) {
    return;
  }
  const newChildren = this.state.children.map((c, idx) => {
    const newChild = { ...c };
    if ($idx === idx) {
      newChild.active = true;
      // clicked가 없는데 클릭했으면 clicked 넣어주고 setTImeOut실행
      if (!this.clicked) {
        const clickTimeout = setTimeout(() => {
          this.clicked = undefined;
        }, 500);
        this.clicked = clickTimeout;
      } else {
        // clicked가 있는데 클릭했으면 더블클릭한 경우이므로 edit으로 바꿔주고 clearTimeout해주기
        clearTimeout(this.clicked);
        this.clicked = undefined;
        newChild.edit = true;
      }

      return newChild;
    } else {
      newChild.active = false;
      newChild.edit = false;
      return newChild;
    }
  });
  this.setState({ children: newChildren });
};

 

먼저 현재 클릭한게 첫 클릭인지 아닌지를 구별해야 시키기 위해 Content컴포넌트에 clicked 상태를 만들어 구별시켰다. 그래서 첫클릭 하면 clicked가 true로 바뀐 상태에서 0.5초 내에 클릭하면 더블클릭으로 만들었다. 0.5초가 지나면 clicked는 초기화 된다. 

 

세번쨰는 어떻게 DOM을 변경을 최소화 할것인가 였는데, 수정모드에서 change 이벤트를 받아서 input의 입력이 끝나고 blur처리 될떄만 이벤트를 받게 했다. 그리고 각각의 버튼에서는 내부에 미리 input과 기본 텍스트 엘레먼트를 넣어두고 수정모드일땐 기본 텍스트요소를 display:none 시키는 방식으로 구현했다. 

 

display :none은 크기와 위치가 이미 정해진 상태에서 화면에 표시만 되지 않기에 reflow를 최소화 시킬 수 있고, 적용된 요소는 다시 Repaint 되지 않기 때문에 그때그떄 요소를 새로 교체하거나 hidden처리 하는것보다 최적화에 유리하다.

// 디렉토리 버튼 컴포넌트
const _initialState = {
  text: '',
  active: false,
  id: 0,
  edit: false,
};
export function DirectoryButton({ root, initialState = _initialState }) {
  this.state = { ..._initialState, ...initialState };
  this.target = document.createElement('button');
  this.target.className = `directory-button idx-${this.state.id} ${this.state.active ? 'active' : ''} ${this.state.edit ? 'edit' : ''} `;
  this.target.innerHTML = `<input class="btn-input-text idx-${this.state.id} ${this.state.active ? 'active' : ''} ${this.state.edit ? 'edit' : ''}" style="display:none" value="${this.state.text}"/>
    <div class="btn-text idx-${this.state.id} ${this.state.active ? 'active' : ''} ${this.state.edit ? 'edit' : ''}">${this.state.text}</div>`;
  root.appendChild(this.target);

  this.setState = newState => {
    this.state = newState;
    this.render();
  };
  this.render = () => {
    //console.log('setState', this.state.text);
    this.target.className = `directory-button idx-${this.state.id} ${this.state.active ? 'active' : ''} ${this.state.edit ? 'edit' : ''} `;
    this.target.children[0].className = `btn-input-text idx-${this.state.id} ${this.state.active ? 'active' : ''} ${this.state.edit ? 'edit' : ''}`;
    this.target.children[0].value = this.state.text;

    this.target.children[1].className = `btn-text idx-${this.state.id} ${this.state.active ? 'active' : ''} ${this.state.edit ? 'edit' : ''}`;
    this.target.children[1].textContent = this.state.text;
    if (this.state.edit) {
      this.target.children[0].style.display = '';
      this.target.children[1].style.display = 'none';
      this.target.children[0].focus();
    } else {
      this.target.children[0].style.display = 'none';
      this.target.children[1].style.display = '';
    }
  };
  this.render();
}

그리고 상위 Content 컴포넌트에서 이벤트 target을 받을때 버튼 내부의 일반 텍스트나 input요소가 상관없이 들어 올 수 있으므로 각 요소에 모두 아이디값과 구별 할 수 있는 구분 클래스를 붙여주었다. 

 

 

아래는 지금까지 구현된 결과이다.