독서/2024

[프레임워크 없는 프론트엔드 개발] - 3장. DOM 이벤트 관리

_OIL 2024. 7. 6. 21:41
반응형

YAGNI 원칙

YAGNI는 You Aren’t Gonna Need It의 약자로 “정말 필요하다고 간주할 때까지 기능을 추가하지 마라” 라는 원칙이다. 자신만의 아키텍처를 작성할 때 반드시 YAGNI 원칙을 적용해 당시에 직면한 문제만을 해결해야 한다.

DOM 이벤트 API

이벤트는 웹 애플리케이션에서 발생하는 동작이다. 전체 이벤트 리스트

  • 마우스 이벤트 (클릭, 더블 클릭 등)
  • 키보드 이벤트 (키다운, 키업 등)
  • 뷰 이벤트 (뷰 리사이징, 스크롤 등)

을 포함한 사용자가 트리거한 이벤트에 반응 할 수 있다. 또한 네트워크의 상태나 DOM 콘텐츠의 변화에 따라 이벤트를 발생시킬 수 있습니다.

기본 클릭 이벤트 라이프사이클


속성에 핸들러 연결

on속성 이용 : onclick, ondblclick, onmouseover, onblur, onfocus 등

  • 빠르지만 지저분한 방법
  • 이 방법을 사용하면 한번에 하나의 핸들러만 연결할 수 있다. 즉 다른 코드가 onclick핸들러를 덮어 쓰면 원래 핸들러는 사라진다.
let button = document.querySelector("#button");
button.onclick = () => {
  console.log("Click 1");
};
button.onclick = () => {
  console.log("Click 2");
};

button = document.querySelector("#eventListener");
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);
  • EventTarget가 모든 DOM 노드의 베이스에 있기때문에 모든 DOM 노드에서 '이벤트’를 사용할 수 있다.
  • addEventListener의 첫 번째 매개변수는 이벤트 타입이다.
  • 두 번째 매개 변수는 콜백이며 이벤트가 트리거될 때 호출된다.
  • property메서드와 달리 addEventListener는 중복 이벤트 등록이 가능하다.
  • DOM에 요소가 더 이상 존재하지 않으면 메모리 누수를 방지하고자 이벤트 리스너도 삭제해야 한다. 이를 위해 removeEventListerner 메서드를 사용한다.

이벤트 객체

button = document.querySelector("#event");
button.addEventListener("click", (e) => {
  console.log("event", e);
});
  • addEventListener의 콜백에는 이벤트를 나타내는 매개변수를 포함할 수 있다.
  • 이벤트 객체에는 포인터 좌표, 이벤트 타입, 이벤트를 트리거한 요소 같은 유용한 정보가 많이 들어있다.

DOM 이벤트 라이프사이클

addEventListener(type, listener, useCapture);
  • 세 번째 매개변수는 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('Button Clicked', false)
  • 버튼을 클릭하면 button이 div안에 있으므로 button부터 시작해 두개의 핸들러가 모두 호출된다. 즉, 이벤트 객체는 트리거한 DOM 노드(예제의 경우 button)에서 시작해 모든 조상 노드로 올라간다. 이러한 매커니즘을 이벤트 버블링 이라고 한다.
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("Button Clicked");
  },
  false
);
  • 위 예제에서는 div 핸들러는 동작하지 않는다.
  • Event 인터페이스의 stopPropagation메서드를 사용해 버블 체인을 중지 할 수 있다.
  • 이 기술은 복잡한 레이아웃에서 유용할 수 있지만 핸들러의 순서에 의존하는 경우 코드를 유지하기 어려울 수 있다. 이런 경우 3장 끝에서 나오는 이벤트 위임 패턴이 유용하다.
const button = document.querySelector('button')
const div = document.querySelector('div')

div.addEventListener('click', () => console.log('Div Clicked'), true)
button.addEventListener('click', () => console.log('Button Clicked', true)

// output
Div Clicked
Button Clicked
  • useCapture 매개 변수를 사용해 핸들러의 실행 순서를 반대로 할 수 있다.
  • 즉, addEventListener 를 호출할때 useCapture 파라미터에 true 값을 주게되면 버블 단계 대신에 캡쳐 단계에 이벤트 핸들러를 추가한다는 것을 의미한다.

정리해보면

캡쳐 단계: 이벤트가 html에서 목표 요소로 이동한다.
(하향식) 목표 단계: 이벤트가 목표 요소에 도달한다.
(상향식) 버블 단계: 이벤트가 목표 요소에서 html로 이동한다.

  • 브라우저의 암흑 시에 일부 브라우저는 캡처 단계만 지원한 반면 다른 브라우저들은 버블 단계만 지원했다.
  • 일반적으로 버블 단계 핸들러만 사용해도 좋지만 복잡한 상황을 관리하려면 캡처 단계를 알아야한다.

사용자 정의 이벤트 사용

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장에서 작성한 마지막 구현의 문제점은 todos 구성 요소가 문자열로 동작한다는 것이다.

const TodoElement = (todo: Todo) => {
  const { text, completed, id } = todo;
  return `
  <li class="todo-item ${completed ? "completed" : ""}" data-id="${id}">
        <div class="display-todo">
          <label for="toggle-todo" class="toggle-todo-label visually-hidden">Toggle Todo</label>
          <input id="toggle-todo" class="toggle-todo-input" type="checkbox" ${completed ? "checked" : ""} />
          <span class="todo-item-text truncate-singleline" tabindex="0">${text}</span>
          <button class="remove-todo-button" title="Remove Todo"></button>
        </div>
        <div class="edit-todo-container">
          <label for="edit-todo" class="edit-todo-label visually-hidden">Edit todo</label>
          <input id="edit-todo" class="edit-todo-input" />
        </div>
      </li>
      `;
      

const Todos = (targetElement: Element, { todos }: State) => {
  const newTodoList = targetElement.cloneNode(true) as Element;
  const todosElements = todos.map(TodoElement).join("");
  newTodoList.innerHTML = todosElements;
  return newTodoList;
};

export default Todos;

기존 코드: 문자열로 만드는 todo-item

  • 리스트의 모든 todo 요소는 문자열로 생성되고 하나로 합쳐진 다음 innerHTML로 부모 리스트에 추가된다. 그러나 문자열에는 이벤트 핸들러를 추가 할 수 없다.

템플릿 요소

document.createElement API를 사용해 비어있는 새 DOM 노드를 생성하는 방법도있지만 코드를 읽고 유지하기 어렵다. 더 나은 방법으로는 index.html 파일의 template 태그 안에 todo 요소의 마크업을 유지하는 것이다. template태그는 이름에서 알 수 있듯이 렌더링 엔진의 ‘스탬프’로 사용할 수 있는 보이지 않는 태그이다.

  <template id="todo-item">
    <li class="todo-item">
      <div class="display-todo">
        <label for="toggle-todo" class="toggle-todo-label visually-hidden"
          >Toggle Todo</label
        >
        <input id="toggle-todo" class="toggle-todo-input" type="checkbox" />
        <span class="todo-item-text truncate-singleline" tabindex="0"
          >${text}</span
        >
        <button class="remove-todo-button" title="Remove Todo"></button>
      </div>
      <div class="edit-todo-container">
        <label for="edit-todo" class="edit-todo-label visually-hidden"
          >Edit todo</label
        >
        <input id="edit-todo" class="edit-todo-input" />
      </div>
    </li>
  </template>

index.html 에 todo-item template요소 생성

 

let template: HTMLTemplateElement;

const createTodoNode = () => {
  if (!template) {
    template = document.getElementById("todo-item") as HTMLTemplateElement;
  }

  const firstChild = template?.content?.firstElementChild;
  return firstChild?.cloneNode(true) as HTMLElement;
};

const TodoElement = (todo: Todo, index: number) => {
  const { text, completed } = todo;
  const element = createTodoNode();
  if (element) {
    const inputEdit = element.querySelector("input.edit-todo-input") as HTMLInputElement;
    const label = element.querySelector("toggle-todo-label");
    const inputToggle = element.querySelector("input.toggle-todo-input") as HTMLInputElement;
    const buttonDestroy = element.querySelector("button.remove-todo-button") as HTMLButtonElement;
    const spanText = element.querySelector("span.todo-item-text ") as HTMLButtonElement;
    spanText.textContent = text;
    if (inputEdit) inputEdit.value = text;
    if (label) label.textContent = text;

    if (completed) {
      element.classList.add("completed");
      if (inputToggle) inputToggle.checked = true;
    }

    if (buttonDestroy) buttonDestroy.dataset.index = index.toString();

    return element;
  }
};

const Todos = (targetElement: Element, { todos }: State, { deleteItem }: Events) => {
  const newTodoList = targetElement.cloneNode(true) as HTMLUListElement;
  newTodoList.innerHTML = "";

  todos.map(TodoElement)!.forEach((element) => {
    if (element) newTodoList.appendChild(element);
  });

  newTodoList.addEventListener("click", (e: MouseEvent) => {
    const target = e.target as HTMLElement;

    if (target.matches("button.remove-todo-button")) {
      deleteItem(Number(target.dataset.index));
    }
  });
  return newTodoList;
};

export default Todos;

템플릿을 사용해 todo-item 생성

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

전체 앱에 템플릿 사용

let template: HTMLTemplateElement;

const createAppElement = (): HTMLElement => {
  if (!template) {
    template = document.getElementById("todo-app") as HTMLTemplateElement;
  }

  const firstChild = template?.content?.firstElementChild;
  return firstChild?.cloneNode(true) as HTMLElement;
};

const TodoApp = (targetElement: Element, state: State, events: Events) => {
  const newApp = targetElement.cloneNode(true) as Element;

  newApp.innerHTML = "";
  newApp.appendChild(createAppElement());
  addEvents(newApp, events);

  return newApp;
};
import { TodoApp } from "@pages/todo-app";
import { registry, applyDiff } from "@shared/libs";
import { State } from "@shared/types/index.js";
import { getTodos } from "@shared/utils";
import { Counter } from "@widgets/counter";
import { Filters } from "@widgets/filters";
import { Todos } from "@widgets/todos";

registry.add("app", TodoApp);
registry.add("todos", Todos);
registry.add("counter", Counter);
registry.add("filters", Filters);

const state: State = {
  // todos: getTodos(),
  todos: [],
  currentFilter: "All",
};

const render = () => {
  window.requestAnimationFrame(() => {
    const main = document.querySelector("#root") as Element;
    const newMain = registry.renderRoot(main, state);
    applyDiff(document.body, main, newMain);
  });
};
  • app 이라는 data-component가 새로 생겼다. app 컴포넌트는 새로 작성된 템플릿을 사용해 콘텐츠를 생성한다.

기본 이벤트 처리 아키텍처

문자열 대신 DOM 요소로 동작하는 새로운 렌더링 엔진을 작성했다. 2장에서 작성한 렌더링 엔진은 상태를 가져오고 DOM 트리를 생성하는 순수 함수를 기반으로 한다. 이 시나리오에서는 ‘루프’ 사이에서 이벤트 핸들러를 쉽게 연결할 수 있다. 모든 이벤트 다음에 상태를 조작한 후 새로운 상태로 렌더링 함수를 호출하면 된다.

import { TodoApp } from "@pages/todo-app";
import { registry, applyDiff } from "@shared/libs";
import { Events, State } from "@shared/types/index.js";
import { getTodos } from "@shared/utils";
import { Counter } from "@widgets/counter";
import { Filters } from "@widgets/filters";
import { Todos } from "@widgets/todos";

registry.add("app", TodoApp);
registry.add("todos", Todos);
registry.add("counter", Counter);
registry.add("filters", Filters);

const state: State = {
  // todos: getTodos(),
  todos: [],
  currentFilter: "All",
};

const events: Events = {
  deleteItem: (index: number) => {
    state.todos.splice(index, 1);
    render();
  },
  addItem: (text: string) => {
    state.todos.push({
      text,
      completed: false,
      id: state.todos.length + 1,
    });
    render();
  },
};

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

render();

index.js에 이벤트를 가진 컨트롤러 작성

  • events 객체는 상태를 수정하고 렌더링 함수를 수동으로 호출 하는 간단한 함수이다.
  • 렌더링 엔진의 진입점인 renderRoot 함수는 이벤트를 포함하는 events 를 세 번째 매개 변수를 받는다.
  • events 는 모든 data-component에 접근할 수 있다.
 import { Events, State } from "@shared/types";
 
 const addEvents = (targetElement: Element, events: Events): void => {
  const inputElement = targetElement.querySelector(".new-todo-input") as HTMLInputElement;
  if (inputElement) {
    inputElement.addEventListener("keypress", (e: KeyboardEvent) => {
      if (e.key === "Enter") {
        console.log("inputElement.value", inputElement.value);

        events.addItem(inputElement.value);
        inputElement.value = "";
      }
    });
  }
};

export const TodoApp = (targetElement: Element, state: State, events: Events) => {
  const newApp = targetElement.cloneNode(true) as Element;

  newApp.innerHTML = "";
  newApp.appendChild(createAppElement());
  addEvents(newApp, events);

  return newApp;
};

addItem 이벤트를 가진 앱 구성 요소

  • 모든 렌더링 주기에 대해 새 DOM 요소를 생성하고 새 todo-item의 값을 삽입하는 데 사용되는 input 핸들러에 이벤트 핸들러를 연결한다.
  • 사용자가 Enter를 누르면 addItem함수가 호출된 후 input 핸들러가 지워진다.

이벤트 위임

이벤트 위임은 대부분의 프론트엔드 프레임워크에서 제공되는 기능으로, 일반적으로 보이지 않게 잘 감춰져 있다.

import { Todo, State, Events } from "@shared/types";
import { createTodoNode } from "../model";

const TodoElement = (todo: Todo, index: number) => {
  const { text, completed } = todo;
  const element = createTodoNode();
  if (element) {
    const inputEdit = element.querySelector("input.edit-todo-input") as HTMLInputElement;
    const label = element.querySelector("toggle-todo-label");
    const inputToggle = element.querySelector("input.toggle-todo-input") as HTMLInputElement;
    const buttonDestroy = element.querySelector("button.remove-todo-button") as HTMLButtonElement;
    const spanText = element.querySelector("span.todo-item-text ") as HTMLButtonElement;
    spanText.textContent = text;
    if (inputEdit) inputEdit.value = text;
    if (label) label.textContent = text;

    if (completed) {
      element.classList.add("completed");
      if (inputToggle) inputToggle.checked = true;
    }

    if (buttonDestroy) buttonDestroy.dataset.index = index.toString();

    return element;
  }
};

const Todos = (targetElement: Element, { todos }: State, { deleteItem }: Events) => {
  const newTodoList = targetElement.cloneNode(true) as HTMLUListElement;
  newTodoList.innerHTML = "";

  todos.map(TodoElement)!.forEach((element) => {
    if (element) newTodoList.appendChild(element);
  });

  newTodoList.addEventListener("click", (e: MouseEvent) => {
    const target = e.target as HTMLElement;

    if (target.matches("button.remove-todo-button")) {
      deleteItem(Number(target.dataset.index));
    }
  });
  return newTodoList;
};

이벤트 위임 기반의 todo-item

  • todo-item 마다 별도의 이벤트 핸들러를 갖지 않고 있으며 리스트 자체에 하나의 이벤트 핸들러만 연결돼 있다
  • 리스트가 아주 길다면 이 접근 방식으로 성능과 메모리 사용성을 개선 시킬 수 있다.
  • matches API는 요소가 실제 이벤트 대상인지 확인하는 데 사용한다.

요약

  • 3장에서는 DOM 이벤트 API의 몇 가지 기본 개념을 설명했다.
  • 이벤트 핸들러를 추가하고 삭제하는 방법 버블 단계와 캡처 단계의 차이점, 사용자 정의 이벤트를 생성하는 방법을 배웠다.
  • 프레임워크 없이 애플리케이션이 충분히 성능을 유지할 수 있도록 새주는 중요한 패턴인 이벤트 위임의 개념을 소개했다.

참고

 

<template>: 콘텐츠 템플릿 요소 - HTML: Hypertext Markup Language | MDN

HTML <template> 요소는 페이지를 불러온 순간 즉시 그려지지는 않지만, 이후 JavaScript를 사용해 인스턴스를 생성할 수 있는 HTML 코드를 담을 방법을 제공합니다.

developer.mozilla.org

 

 

[JavaScript] 이벤트 생명주기 (Event Life Cycle)

자바스크립트에서 작업할 때 이벤트는 간단한 호버나 클릭과 같이 흔한 현상이다.각 이벤트마다 동작은 문서 객체 모델(DOM)을 통해 전파된다. DOM은 형제, 자식 및 부모 요소를 포함한 트리 구조

velog.io

 

 

Event reference | MDN

Events are fired to notify code of "interesting changes" that may affect code execution. These can arise from user interactions such as using a mouse or resizing a window, changes in the state of the underlying environment (e.g. low battery or media events

developer.mozilla.org

 

반응형