개발 관련 서적 스터디

프레임워크 없는 프론트엔드 개발 3장(DOM 이벤트 관리)

옹재 2021. 4. 27. 23:12
728x90
반응형

DOM 이벤트 관리

웹 애플리케이션은 고정된 그림이 아니기 때문에 내용은 시간이 지남에 따라 변경됩니다. 이린 변경이 발생하게 만드는 것이 이벤트입니다. 이벤트는 사용자에 의해 또는 시스템에 의해 생성됐는지 여부와 관계없이 DOM API에 매우 중요한 부분입니다.

YAGNI 원칙

앞 장에서는 이벤트를 무시한 불완전한 엔진을 학습했는데 그 이유는 가독성과 단순성 때문이라고 합니다. 저자는 실제 프로젝트에도 동일한 접근 방식을 사용한다고 합니다. 가장 중요한 기능에 초점을 맞춰 개발하고 새로운 요구가 생기면 이에 따라 아키텍처를 지속적으로 발전시켜나가는 형태를 선호한다고 합니다. 이러한 형식을 YAGNI(You are't gonna need it : 정말 필요하다고 간주할 때까지 기능을 추가하지 마라)라고 하는 익스트림 프로그래밍 원칙 중 하나라고 합니다.

이는 좋은 원칙이지만 프레임워크없는 프로젝트에서는 절대적으로 중요하다고 합니다. 프레임워크 없는 접근 방식에 대해 종종 듣는 비판 중 하나가 "아무도 유지 관리하지 않는 또 다른 프레임워크를 작성했다"는 것이라고 합니다. 아키텍처를 엔지니어링할 때는 분명 이런 위험이 따르고 이러한 것을 YAGNI 원칙을 적용해 문제를 해결해야 한다고 합니다. 아키텍처를 작성하는 데 있어서 미래를 예견하지 말라고 조언하고 있습니다.

DOM 이벤트 API

이벤트는 웹 애플리케이션에서 발생하는 동작으로, 브라우저는 사용자에게 이를 알려줘 사용자는 어떤 방식으로든 반응할 수 있습니다. 이벤트에는 다양한 타입이 있는데 모질라 개발자 네트워크에서 전체 리스트를 참고할 수 있습니다. 마우스, 키보드, 뷰 이벤트를 포함한 사용자가 트리거한 이벤트, 네트워크 상태의 변화나 DOM 콘텐츠가 로드될 대 발생되는 이벤트 같은 시스템 자체 이벤트도 생성할 수 있습니다.

이벤트에 반응하려면 이벤트를 트리거한 DOM 요소에 연결해야 합니다.

뷰나 시스템 이벤트의 경우 이벤트 핸들러를 window 객체에 연결해야 합니다.

속성에 핸들러 연결

이벤트 핸들러를 DOM 요소에 연결하는 방법에는 빠르지만 지저분한 on 속성을 사용하는 방법이 있습니다. 모든 이벤트 타입마다 DOM 요소에 해당되는 속성을 가지는데 버튼에는 onclick, ondblclick, onmouseover, onblur, onfocus 등의 속성이 있습니다. 핸들러는 아래의 코드와 같이 이벤트에 연결할 수 있습니다.

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.addListener('click', () => {
    console.log('Clicked using addEventListener')
})

첫 번째 매개변수는 이벤트 타입입니다. 두 번째 매개변수는 콜백이며 이벤트가 트리가 될 때 호출됩니다.

property 메서드와 달리 addEventListener는 필요한 모든 핸들러를 연결할 수 있습니다.

const button = document.querySelector('button')
button.addListener('click', () => {
    console.log('First handler')
})
button.addListener('click', () => {
    console.log('Second handler')
})

만약 DOM에 요소가 남아있지 않으면 메모리 누수 방지를 위해 이벤트 리스너도 removeEventListener 메소드를 통해 삭제해줘야 합니다. 이벤트 핸들러를 제거하려면 removeEventListener 메소드에 매개변수로 전달할 수 있도록 이에 대한 참조를 유지해야합니다.

const button = document.querySelector('button')
const firstHandler = () => {
    console.log('First handler')
}
const secondHandler = () => {
    console.log('Second handler')
}
button.addEventListener('click', firstHandler)
button.addEventListener('click', secondHandler)

window.setTimeout(() => {
    button.removeEventListener('click', firstHandler)
    button.removeEventListener('click', secondHandler)
    console.log('Removed Event Handlers')
}, 1000)

이벤트 객체

지금까지의 코드는 매개변수 없이 이벤트 핸들러가 작성됐습니다. 하지만 이벤트 핸들러의 서명은 DOM 노드나 시스템에서 생성한 이벤트를 나타내는 매개변수를 포함할 수도 있습니다.

const button = document.querySelector('button')
button.addEventListener('clcik', e => {
    console.log('event', e)
})

위 코드는 이벤트를 단순히 콘솔에 출력합니다. 이벤트에는 포인터 좌표, 이벤트 타입, 이벤트를 트리거한 요소같은 정보가 많이 들어있습니다. 웹 애플리케이션에 전달된 모든 이벤트는 Event 인터페이스를 구현합니다. 타입에 따라서는 Event 인터페이스를 확장하는 구체적인 Event 인터페이스를 구현할 수 있습니다.

click 이벤트는 MouseEvent 인터페이스를 구현하는데 이 인터페이스는 이벤트 중 마우스 포인터의 좌표나 이동에 대한 정보와 다른 정보를 포함하고 있습니다. MouseEvent 인터페이스의 계층 구조는 아래와 같습니다.

 

DOM 이벤트 라이프사이클

button.addEventListener('click', handler, false)

addEventListener 메소드를 사용해 핸들러를 추가하는 코드에는 일반적으로 세 번째 매개변수로 false 값이 추가된다. 이 매개변수는 useCapture이라고 부르고 기본값은 false입니다. 선택사항이긴 하지만 이상적으로 폭넓은 브라우저 호환성을 얻으려면 포함시켜야 합니다. 이 매개변수의 역할은 아래의 예제코드로 알아보겠습니다.

<body>
    <div>
        This is a container
        <button>
            Click Here
        </button>
    </div>
</body>
const button = document.querySelector('button')
const div = document.querySelector('div')

div.addEventListener('click', () => {
    console.log('Div Clicked')
}, false)

button.addEventListener('click', () => {
    console.log('ButtonClicked')
}, false)

버튼을 클릭하면 어떤 일이 발생할까요? button이 div안에 있으므로 button부터 시작해 두 핸들러가 모두 호출됩니다. 따라서 이벤트 객체는 이를 트리거한 DOM노드에서 시작해 모든 조상 노드로 올라갑니다. 이 메커니즘을 버블 단계나 이벤트 버블리이라고 합니다. Event 인터페이스의 stopPropagation 메소드를 사용해 버블 체인을 중지할 수 있습니다.

const button = document.querySelector('button')
const div = document.querySelector('div')

div.addEventListener('click', () => {
    console.log('Div Clicked')
}, false)

button.addEventListener('click', (e) => {
    e.stopPropagation()
    console.log('ButtonClicked')
}, false)

이렇게 하면 div 핸들러는 호출되지 않습니다. 이 기술은 복잡한 레이아웃에서 유용할 수 있지만 핸들러의 순서에 의존하는 경우 코드를 유지하기 어려울 수 있습니다. 이런 경우에는 이벤트 위임 패턴이 유용할 수 있습니다.

useCapture 매개변수를 사용하면 실행 순서를 반대로 할 수 있습니다.

const button = document.querySelector('button')
const div = document.querySelector('div')

div.addEventListener('click', () => {
    console.log('Div Clicked')
}, true)

button.addEventListener('click', () => {
    console.log('ButtonClicked')
}, true)

즉, addEventListener를 호출 할 때 useCapture 매개변수에 true를 사용하면 버블 단계 대신 캡처 단계에 이벤트 핸들러를 추가한다는 것을 의미합니다. 버블 단계에서는 핸들러가 상향식으로 처리되는 반면 캡처 단계에서는 반대로 처리됩니다. 생성된 모든 DOM 이벤트에 대해 브라우저는 캡처 단계를 실행한 다음 버블 단계를 실행합니다. 목표 단계라고 하는 단계도 있는데 이벤트가 목표 요소에 도달할 때 발생합니다.

  1. 캡처 단계 : 이벤트가 html에서 목표 요소로 이동합니다.
  2. 목표 단계 : 이벤트가 목표 요소에 도달합니다.
  3. 버블 단계 : 이벤트가 목표 요소에서 html로 이동합니다.

사용자 정의 이벤트

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)
})

input 이벤트를 관리할 때 값 자체의 길이를 확인한다. 값의 길이가 정확히 5라면 FiveCharInputValue라는 이벤트를 발생기킵니다. 사용자 정의 이벤트를 처리하려면 일반적으로 addEventListener 메소드로 표준 이벤트 리스너를 추가합니다.

TodoMVC에 이벤트 추가

2장에서 했던 TodoMVC 애플리케이션을 살펴보면 관리해야 할 이벤트 목록은 다음과 같습니다.

  • 항목 삭제 : 행의 오른쪽에 있는 십자가를 클릭
  • 항목의 완료 여부 토글 : 행의 왼쪽에 있는 원을 클릭
  • 필터 변경 : 하단의 필터 이름을 클릭
  • 항목 생성 : 상단 입력 텍스트에 값을 입력하고 키보드 Enter 클릭
  • 완성된 모든 항목 삭제 : 'Clear completed' 레이블을 클릭
  • 모든 항목의 완료 여부 토글 : 왼쪽 상단 모서리에 있는 V자 표시를 클릭
  • 항목 편집 : 행을 더블 클릭하고 값을 변경한 후 키보드에서 Enter 클릭

템플릿 요소

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 id="todo-item">
    <li>
        <div class="view">
            <input class="toggle" type="checkbox"/>
            <label></label>
            <button class="destroy"></button>
        </div>
    </li>
</template>
let template

const createNewTodoNode = () => {
    if(!template){
        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.quertSelector('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
}

그런 다음 앱 구성 요소를 생성해 템플릿 기술을 모든 애플리케이션으로 확장할 수 있습니다. todo 리스트의 마크업을 template 요소로 감싸는 것입니다.

<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>

app이라는 새로운 구성요소를 생성했습니다. 이 구성 요소는 새로 작성된 템플릿을 사용해 콘텐츠를 생성합니다.

let template

const createAppElement = () => {
  if (!template) {
    template = document.getElementById('todo-app')
  }

  return template
    .content
    .firstElementChild
    .cloneNode(true)
}

export default (targetElement) => {
  const newApp = targetElement.cloneNode(true)
  newApp.innerHTML = ''
  newApp.appendChild(createAppElement())
  return newApp
}

기본 이벤트 처리 아키텍처

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

상태 - 렌더링 - 이벤트 루프를 테스트해보겠습니다.

  • 초기상태 : 비어있는 dom 리스트
  • 렌더링 : 사용자에게 비어있는 리스트를 표시
  • 이벤트 : 사용자가 더미 항목이라는 새 항목을 생성
  • 새로운 상태 : 하나의 항목을 가진 todo 리스트
  • 렌더링 : 사용자에게 하나의 항목을 가진 리스트를 표시
  • 이벤트 : 사용자가 항목을 삭제
  • 새로운 상태 : 비어 있는 todo 리스트
  • 렌더링 : 사용자에게 비어있는 리스트를 표시

import todosView from './view/todos.js'
import counterView from './view/counter.js'
import filtersView from './view/filters.js'
import appView from './view/app.js'
import applyDiff from './applyDiff.js'

import registry from './registry.js'

registry.add('app', appView)
registry.add('todos', todosView)
registry.add('counter', counterView)
registry.add('filters', filtersView)

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()
let template

const getTemplate = () => {
  if (!template) {
    template = document.getElementById('todo-app')
  }

  return template
    .content
    .firstElementChild
    .cloneNode(true)
}

const addEvents = (targetElement, events) => {
  targetElement
    .querySelector('.new-todo')
    .addEventListener('keypress', e => {
      if (e.key === 'Enter') {
        events.addItem(e.target.value)
        e.target.value = ''
      }
    })
}

export default (targetElement, state, events) => {
  const newApp = targetElement.cloneNode(true)

  newApp.innerHTML = ''
  newApp.appendChild(getTemplate())

  addEvents(newApp, events)

  return newApp
}
let template

const createNewTodoNode = () => {
  if (!template) {
    template = document.getElementById('todo-item')
  }

  return template
    .content
    .firstElementChild
    .cloneNode(true)
}

const getTodoElement = (todo, index, events) => {
  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
  }

  const handler = e => events.deleteItem(index)

  element
    .querySelector('button.destroy')
    .addEventListener('click', handler)

  return element
}

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

  newTodoList.innerHTML = ''

  todos
    .map((todo, index) => getTodoElement(todo, index, events))
    .forEach(element => {
      newTodoList.appendChild(element)
    })

  return newTodoList
}
728x90
반응형