본문 바로가기

FrontEnd

리액트 이벤트 리스너는 어떻게 등록되고 처리되는가

반응형

React

우리는 리액트 컴포넌트의 JSX에 콜백 형태로 이벤트 리스너를 등록합니다.

이는 natiive DOM API를 사용할 땐 권장되지 않는 방식입니다.

보통 셀렉터와 이벤트 리스너 API를 사용하죠.

 

그런데도 우리는 리액트로 UI를 개발할 때, 콜백 이벤트 핸들러를 너무나도 자연스럽게 사용하고 있습니다.

왜 그래도 되는지 알아봅시다.


 

오개념 (The misconception)

먼저 질문을 던저 봅시다.
우리는 다음과 같은 코드를
function App() {
  return (
     <button onClick={() => console.log('Click on the button')}>
        Click me
     </button>
  );
}

 

이렇게 이해해도 되는걸까요?
// `buttonRef` an imaginary reference added by React on the button
buttonRef.addEventListener('click', onClick);


실제로 어떻게 동작할까요?

이벤트 핸들러 생성

React는 이벤트 리스너 처리 방법을 알기 위해 런타임에 여러 객체를 초기화합니다. 예를 들면 다음과 같습니다.
  • 처리되는 모든 기본 이벤트의 배열:
const handledNativeEvents = ['click', 'change', 'dblclick', ...]
  • 네이티브 이벤트와 이벤트 핸들러 속성 간의 매핑을 수행하는 객체
    • 각 이벤트 간 우선 순위 개념도 있습니다.
const reactEventHandlerPropByEventName = {
   'click': 'onClick',
   'dblclick': 'onDoubleClick',
   ...
}

루트 / 컨테이너 노드의 파이버 객체 생성

실제로 이벤트 핸들러 등록은 루트 파이버 노드를 만드는 동안 이루어집니다.
React가 초기화되는 애플리케이션의 진입점을 살펴봅시다.

import { StrictMode } from "react";
import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <StrictMode>
    <App />
  </StrictMode>,
  rootElement
);

ReactDOM.render 뒤에 있는 코드는
파이버 트리의 생성과 업데이트를 동시에 처리합니다.

React는 Fiber 트리를 생성해야 하는지 아닌지 어떻게 알 수 있을까요?

사실 React는 루트 DOM 노드에 _reactRootContainer라는 키를 저장합니다.

다음을 입력하여 브라우저에서 가져올 수 있습니다.

// In my example, my container id is `root`
document.getElementById('root')._reactRootContainer
즉, 해당 값이 있으면 Fiber 트리가 이미 생성되었음을 의미합니다.

마지막으로 루트 파이버 트리를 생성하는 동안
루트 DOM 노드에 React에서 처리하는 모든 이벤트의 이벤트 리스너를 생성 및 연결합니다.

handledNativeEvents.forEach(eventName => {
      rootDomNode.addEventListener(eventName, myEventListener);
      rootDomNode.addEventListener(eventName, myEventListener, true);
  }
추가된 이벤트 리스너는 무엇인가요?

코드를 조금 더 깊이 살펴보면
리스너가 dispatchEvent라는 동일한 메서드(우선 순위가 다름)를 호출하는 것을 볼 수 있습니다.

이제 브라우저의 개발자 콘솔에서 React가 루트 DOM 노드에 리스너를 추가한 것을 볼 수 있습니다.

추가된 이벤트 리스너

약간의 마법

React가 DOM 노드에서 수행하는 몇 가지 마법을 알아야 합니다.
React는 DOM 노드에 다음과 같은 정보를 추가합니다.

  • [internalInstanceKey]라는 키 아래 Fiber 노드에 대한 참조
  • [internalPropsKey] 키 아래 prop에 대한 참조
internalInstanceKey 및 internalPropsKey의 값을 알고 싶다면
(그리고 자동 완성을 지원하는 스마트 콘솔이 없는 경우)
react-dom 코드에 디버거 지점을 추가해야 합니다.

react-dom 코드를 어떻게 분석하나요?

React Developer Tools를 설치한 다음 이 작은 gif를 따라합니다.

위를 따라한 뒤 새로고침하면 원하는 값을 얻을 수 있습니다.

경고: 페이지를 새로고침하면 값이 바뀝니다.

클릭 후 발생하는 프로세스

다음 예에서 버튼을 클릭하면 어떤 일이 발생할까요?
function App() {
  return (
     <button onClick={() => console.log('Click on the button')}>
        Click me
     </button>
  );
}
이전에 React에 의해 추가된 리스너가 dispatchEvent 메서드를 호출하는 것을 보았습니다.

이벤트에서 우리는 target DOM 노드를 알 수 있고

해당 target DOM 노드의 키 internalInstanceKey 덕분에

이 DOM 노드의 파이버 노드 인스턴스(우리의 경우 버튼)를 알 수 있습니다.

참고: 타겟은 클릭된 버튼 엘리먼트이고
currentTarget은 루트 DOM 노드 엘리먼트(해당 이벤트 리스너가 적용됨)입니다.

클릭한 Fiber 노드에서 루트 파이버 노드까지 Fiber 트리를 타고 위로 올라갈 수 있습니다.
각 Fiber 노드에 대해 React는 다음을 확인합니다.

  • 컴포넌트가 HostComponent(예: html 엘리먼트)인지
  • reactEventHandlerPropByEventName 객체 키에 해당하는 React 이벤트의 핸들러에 해당하는 prop이 있는지
우리 버튼의 경우 리액트는 onClick prop을 검색합니다.
이 리스너는 dispatchQueue라는 배열에 저장됩니다.
참고: 기본적으로 타겟 노드에서 루트 노드 엘리먼트까지 파이버 트리 내 노드를 전부 순회합니다.
다음은 리스너를 가져온 뒤, dispatchQueue를 채우는 프로세스를 이해하기 위한 이미지 입니다.
  1. 버튼을 클릭합니다.
  2. 해당 버튼부터 루트 노드까지 파이버 트리를 순회하며 호출 대상 이벤트 리스너를 리스너 큐에 추가합니다.
    • 컴포넌트 인스턴스, 이벤트 리스너, currentTarget(해당 파이버 노드에 해당하는 DOM) 정보를 갖고 있습니다.
  3. 2에서 만들어진 리스너 큐를 이용하여 dispatchQueue를 채웁니다.
    • event 정보가 추가됩니다. (뒤에 설명합니다.)

반응형
dispatchQueue가 완성된 모습
그런 다음 이 리스너를 순서대로 실행하여 dispatchQueue를 처리합니다.
function executeDispatchQueue(event) {
  for (const listener of dispatchQueue) {
    listener(syntheticBaseEvent);
  }
}

리액트가 보내는 이벤트

버튼의 onClick 메소드에 디버거 포인트를 설정하면,
이벤트의 타입이 MouseEvent가 아니라 SyntheticBaseEvent임을 알 수 있습니다.

사실 React는 기본 이벤트를 React 이벤트로 래핑합니다.
const syntheticBaseEvent = {
  nativeEvent,
  target,
  currentTarget,
  type,
  _reactName,
  ...
}

native Event를 wapping 하는 이유는 크로스 브라우징 처리의 용이성 때문입니다.


배운 내용을 활용하기

위 본문 내용을 참조하면, dispatchQueue의 listeners는 이벤트 버블링 순서대로 정렬되게 됩니다.
캡쳐링은 그렇다면 이 배열을 뒤집어, 각 이벤트 리스너들을 실행해 주는게 되겠죠.
(실제로는 뒤에 Cpature가 붙는 jsx prop이 따로 있습니다.)
따라서 리액트의 캡쳐링과 버블링은 컴포넌트 트리 상에서 정상적으로 일어납니다.

 

아래 게시물은 swiper 안의 버튼을 클릭했는데, swiper의 dom click 이벤트가 먼저 일어나는 현상을 설명하는데요, 

지금까지 배운 내용을 토대로 왜 dom click 이벤트가 먼저 일어나는지 알 수 있습니다.

https://fe-developers.kakaoent.com/2022/220908-react-event-and-browser-event

 

React 이벤트와 브라우저 이벤트

카카오엔터테인먼트 FE 기술블로그

fe-developers.kakaoent.com

 문제가 발생한 코드가 보면 아래와 같은 코드가 있습니다.

  onSwiper={(swiper) => {
    swiperRef.current = swiper;
    swiper.el.addEventListener('click', onSwiperClick);
  }}
  • 이벤트 버블링은 dom 트리를 따라 먼저 발생하게 됩니다.
  • 이는 실제 dom 트리에 addEventListener로 부착된 스와이퍼 이벤트 리스너를 먼저 호출합니다.
  • 그 다음 루트 노드의 dispatchEvent 핸들러를 실행합니다.
  • 실제 우리가 사용할 리스너는 파이버의 prop으로 존재하다가, 클릭 시에 dispatchQueue 쪽으로 전달되어, 본문에서 설명하는 바와 같이 실행됩니다.

따라서 엄밀하게는 링크한 게시물에서 언급하는것과 같이 button 클릭 이벤트 리스너가 root에 붙는 것은 아닙니다.
결과적으로 링크한 게시물에서 해결한 방법도 dom 트리를 사용한 이벤트 버블링이지,
리액트 컴포넌트 트리를 이용한 버블링을 사용해 해결한 것은 아닙니다.


결론

React는 DOM 요소에 클릭 이벤트 리스너를 추가하지 않습니다.
루트 노드에 이벤트 리스너(캡처 모드도 마찬가지)만 추가합니다.

사용자가 이벤트를 트리거하면 루트 노드의 이벤트 리스너가 호출됩니다.

이벤트의 target 속성 덕분에 React는 DOM을 가져올 수 있고,

해당 DOM의 _reactFiber... 속성 덕택에 파이버 노드를 가져올 수 있습니다.

 

React는 해당 Fiber 노드를 통해 Fiber 트리 위로 순회하며,

React 이벤트 이름과 일치하는 모든 리스너를 가져와 디스패치 큐에 삽입합니다.

(이벤트 리스너는 파이버의 prop를 이용해 알 수 있습니다.)

이후 해당 큐의 모든 콜백이 실행됩니다.

참고

https://dev.to/romaintrotard/under-the-hood-of-event-listeners-in-react-4g01

 

Under the hood of event listeners in React

Recently, during the migration to React 17, I had a problem between event listeners handled by React...

dev.to

 

반응형