본문 바로가기

카테고리 없음

프레임워크 없는 프론트엔드 개발 | DOM 이벤트 관리

애플리케이션의 내용은 시간이 지남에 따라 변경된다. 이런 변경이 발생하게 만드는 것이 이벤트이다. 이벤트는 사용자에 의해 또는 시스템에 의해 생성됐는지 여부와 관계없이 DOM API에서 매우 중요한 부분이다. 

 

실제 프로젝트에서 중요한 기능에 초점을 맞춰서 개발하고 새로운 요구가 생기면 이에 따라 아키텍처를 지속적으로 변경시키자.

YAGNI 원칙

You aren't gonna need it: 정말 필요하다고 간주할 때까지 기능을 추가하지마라.

YAGNI 원칙은 익스트림 프로그래밍 원칙 중 하나이다. YAGNI 원칙을 더 잘 설명하고자 XP의 창시자 중 한 명인 론 제프리스의 다음 인용문을 자주 사용한다.

 

'당신이 필요하다고 예측할 때가 아니라 실제로 필요할 때 구현하라.'

 

이는 어떤 사례에서도 따를 만한 좋은 원칙이지만 프레임워크가 없는 프로젝트에서는 절대적으로 중요하다. 프레임워크 없는 접근 방식에 대해 종종 듣게 되는 비판 중 하나는 바로 "아무도 유지 관리하지 않는 또 다른 프레임워크를 작성했다"는 것이다. 아키텍처를 과도하게 엔지니어링할 경우 실제로 이런 위험이 따른다. 자신만의 아키텍처를 작성할 때 반드시 YAGNI 원칙을 적용해 당시에 직면한 문제만을 해결해야 한다. 

DOM 이벤트 API

이벤트는 웹 애플리케이션에서 발생하는 동작으로, 브라우저는 사용자에게 이를 알려줘 사용자는 어떤 방식으로든 반응할 수 있다. 다양한 이벤트 타입이 있다.

 

마우스 이벤트(클릭, 더블 클릭등), 키보드 이벤트(키다운, 키업 등), 뷰 이벤트(크기 조정, 스크롤 등)를 포함한 사용자가 트리거한 이벤트에 반응할 수 있다. 또한 시스템 자체에서도 이벤트를 생성할 수 있다. 예를 들어 네트워크 상태의 변화나 DOM 콘텐츠가 로드될 때 발생하는 이벤트에 따라 사용자는 반응할 수 있다. 

 

이벤트에 반응하려면 이벤트를 트리거한 DOM 요소(이벤트 핸들러로 불리는 콜백)에 연결해야 한다. 뷰나 시스템의 경우 이벤트 핸들러를 window 객체에 연결해야 한다. 이벤트 핸들러를 DOM/ 요소에 연결하는 방법에 빠르지만 지저분한 방법으로 on*속성을 이용하는 방법이 있다. 모든 이벤트 타입마다 DOM 요소에 해당되는 속성을 가진다. 

 

const button = document.querySelector('button')
button.onclick = () => {
  console.log('Click managed using onclick property')
}

 

이 방법으로는 빠르게 이벤트 핸들러를 연결할 수 있지만, 일반적으로 나쁜 관행으로 여겨진다. 가장 큰 이유는 속성을 사용하면 한번에 하나의 핸들러만 연결할 수 있기 때문이다. 따라서 코드가 onclick 핸들러를 덮어 쓰면 원래 핸들러는 영원히 손실된다.

 

addEventListener로 핸들러 연결

이벤트를 처리하는 모든 DOM 노드에 EventTarget 인터페이스를 구현한다. 이 인터페이스의 addEventListener 메서드는 이벤트 핸들러를 DOM 노드에 추가한다.

 

const button = document.querySelector('button')
button.addEventListener('click', () => {
  console.log('Clicked using addEventListener')
})

 

첫번째 매개변수는 이벤트 타입이다. 두번째 매개변수는 콜백이며 이벤트가 트리거될 때 호출된다. 이 방법을 사용하면 click 이벤트를 여러개 연결할 수 있다.

 

DOM에 요소가 더 이상 존재하지 않으면 메모리 누수를 방지하고자 이벤트 리스너도 삭제해야 한다. 이를 위해 removeEventListener 메서드를 사용한다. 중요한점은 addEventListener에게 전달하는 콜백함수와 removeEventListener에 전달하는 콜백 함수의 참조가 동일해야 한다는 점이다. 

 

이벤트 객체

웹 애플리케이션에 전달된 모든 이벤트는 Event 인터페이스를 구현한다. 타입에 따라 이벤트 객체는 Event 인터페이스를 확장하는 좀 더 구체적인 Event 인터페이스를 구현할 수 있다. click 이벤트(또는 dblclick, mouseup, mousedown)은 MouseEvent 인터페이스를 구현한다. 이 인터페이스에는 이벤트 중 포인터의 좌표나 이동에 대한 정보와 다른 유용한 데이터가 포함돼 있다.

 

사용자 정의 이벤트 사용

DOM 이벤트 API에서는 사용자 정의 이벤트 타입을 정의하고 다른 이벤트처럼 처리할 수 있다. 이는 도메인에 바인딩되고 시스템 자체에만 발생한 DOM 이벤트를 생성할 수 있기 때문에 DOM 이벤트 API에서 중요한 부분이다. 

 

사용자 정의 이벤트를 생성하려면 CustomEvent 생성자 함수를 사용한다.

 

const EVENT_NAME = 'FiveCharInputValue'
const input = document.querySelector('input')

input.addEventListener('input', () => {
  const { length } = input.value
  console.log('input length', length)
  
  if (length === 5) {
    const time = (new Date()).getTime()
    const event = new CustomEvent(EVENT_NAME, {
      detail: {
        time
      }
    }
    
    input.dispatchEvent(event)
  }
})

input.addEventListener(EVENT_NAME, e => {
  console.log('handling custom event...', e.detail)
})

 

사용자 정의 이벤트를 처리하려면 일반적으로 addEventListener 메서드로 표준 이벤트 리스너를 추가한다. 

 

템플릿 요소

프로그래밍 방식으로 DOM 노드를 생성하는 다양한 기술이 있다. 그 중 하나는 개발자가 document.createElement API를 사용해 새 DOM 노드를 생성하는 것이다. 

 

const newDiv = document.createElement('div')
if (!condition) {
  newDiv.classList.add('disabled')
}

const newSpan = document.createElement('span')
newSpan.textContent = 'Hello World!'

newDiv.appendChild(newSpan)

 

이 API를 사용해 빈 li를 생성한 후 다양한 div 핸들러, input 핸들러 등을 추가할 수 있습니다. 그러나 이 코드는 읽고 유지보수하기 어렵습니다. 다른 옵션은 index.html 파일의 template 태그 안에 todo 요소의 마크업을 유지하는 것입니다. template 태그는 이름에서 알 수 있듯이 렌더링 엔진의 '스탬프'로 사용할 수 있는 보이지 않는 태그입니다. 

 

<template>
  <li>
    <div class="view>
      <input class="toggle" type="checkbox">
      <label></label>
      <button class="destroy"></button>
    </div>
    <input class="edit">
  </li>
 </template>

 

아래와 같이 사용할 수 있습니다.

 

let template

const createNewTodoNode = () => {
  if (!tempalte) {
    template = document.getElementById('todo-item')
  }
  
  return template.content.firstElementChild.cloneNode(true)
}

const getTodoElement = todo => {
  const { text, completed } = todo
  const element = createNewTodoNode()
  
  element.querySelector('input.edit').value = text
  element.querySelector('label').textContent = text
  
  if (completed) {
    element.classList.add('completed')
    element.querySelector('input.toggle').checked = true
  }
  
  return element
}

export default (targetElement, { todos }) => {
  const newTodoList = targetElement.cloneNode(true)
  newTodoList.innerHtml = ''

  todos.map(getTodoElement).forEach((element) => {
    newTodoList.appendChild(element)
  })
  
  return newTodoList
}

 

이런 방식으로 앱 컴포넌트를 생성해 템플릿 방식을 모든 애플리케이션으로 확장할 수 있다. 

 

<body>
  <template id="todo-item">
    <!-- todo 항목 내용을 여기에 넣는다 -->
  </template>
  <template id="todo-app">
    <section class="todoapp">
      <!-- 앱 내용을 여기에 넣는다 -->
    </section>
  </template>
  
  <div id="root">
    <div data-component="app"></div>
  </div>
</body>

 

기본 이벤트 처리 아키텍처

새로운 상태마다 새로운 DOM 트리를 생성해 가상 DOM 알고리즘을 적용할 수 있다. 이 시나리오에서는 '루프'에 이벤트 핸들러를 쉽게 삽입할 수 있다. 모든 이벤트 다음에 상태를 조작한 후 새로운 상태로 메인 렌더링 함수를 호출한다.

 

const state = {
  todos = [],
  currentFilter: 'All'
}

const events = {
  deleteItem: (index) => {
    state.todos.splice(index, 1)
    render()
  },
  addItem: (text) => {
    state.todos.push({
      text,
      completed: false
    })
    render()
  }
}

const render = () => {
  window.requestAnimationFrame(() => {
    const main = document.querySelector('#root')
    const newMain = registry.renderRoot(main, state, events)
    
    applyDiff(document.body, main, newMain)
  })
}

render()

렌더링 엔진의 진입점인 renderRoot 함수는 이벤트를 포함하는 세번째 매개변수를 받는다. 곧 새로운 매개변수가 모든 컴포넌트에 접근할 수 있다는 것을 알게될 것입니다. 이벤트는 상태를 수정하고 새로운 렌더링을 수동으로 호출하는 아주 간단한 함수이다. 실제 애플리케이션에서는 개발자가 핸들러를 빠르게 추가하고 새로운 렌더링 주기를 자동으로 호출하는데 도움이 되는 일종의 이벤트 레지스트리를 생성하는 것이좋다.

이벤트 위임(Event delegation)

이벤트 위임은 대부분의 프론트엔드 프레임워크에서 제공되는 기능으로, 일반적으로 보이지 않게 잘 감춰져 있다. 하위 리스트의 이벤트를 위임한다 했을 때 리스트가 아주 길다면 이 접근 방식으로 성능과 메모리 사용성을 개선시킬 수 있다.