[React] How to Manage Events Globally in React.

Pakxe·2024년 3월 25일
2

React

목록 보기
6/8

주어진 과제를 진행하면서 이벤트를 제일 상위(전역)에서 버블링으로 관리하는 중이었다. 이벤트 핸들링 책임을 전역에 떠넘기는 꼴이라.. 선배 개발자분께 이렇게 해도 될지 여쭤보니 리액트 또한 전역으로 이벤트를 관리한다고 전해 들었다. 전역이라는 추상적인 표현으로 넘기기엔 그 내부가 궁금하여 실제로 리액트가 어떻게 이벤트를 전역으로 관리하고 있는지 찾아보고 이렇게 글로 남기게 되었다.

내가 알고 싶은 것은, 아래와 같은 코드를 실행하는데 어떤 식으로 이벤트가 전역에 달리는지 그리고 어떻게 () => console.log('hi')를 찾아 실행하는 건지이다.

<button onClick={() => console.log('hi')}>

리액트에서 event.currentTarget

리액트의 event.currentTarget의 결과를 알기 위해선, 일단 리액트에서 event 객체는 SyntheticEvent 인스턴스로 감싸져 나온다는 사실을 알아야 한다. (감싸는 이유는 다양한 브라우저에서 일관된 이벤트 객체를 제공하기 위함이라고 한다.)

그리고 이 SyntheticEvent에는 nativeEvent라는 원래의 event 객체를 참조할 수 있도록 남겨두었기 때문에 이를 이용해 event.currentTarget에 접근할 수 있다. 아래 콜백처럼 말이다.


위 버튼을 클릭한 결과는 아래와 같다.


이벤트를 분명 button 태그에 등록했는데, currentTarget은 root로 출력된다. 왜 그런지 알아보자.

거치는 모든 과정들에 생략(함수 건너뛰기)이 없도록 했기 때문에 글이 길 수 있다.






▲ react의 소스코드에서 rootContainer를 만드는 함수가 있는데, 이 안에 사전 작업으로 listenToAllSupportedEvents라는 함수를 호출하는 모습을 볼 수 있다.


listenToAllSupportedEvents는 allNativeEvents(브라우저에서 제공하는 이벤트를 말하는 것 같음)를 순회하며 listenToNativeEvent를 호출한다. 이름부터 모든 지원되는 모든 이벤트를 듣는다는걸 보면, click이나 change같은 이벤트들에 listener를 장착하는 함수라고 추측할 수 있다. 안에서


listenToNativeEvent는 단일 이벤트에 대해서 리스너를 장착하는 함수라고 이름에서 추측할 수 있다. 위임으로 처리하지 않는 이벤트인데 캡처리스너가 아니라면 리액트에서 만든 에러라는 조건문은 지금은 중요하지 않으니 무시하며, addTrappedEventListener를 호출하고 있다.


addTrappedEventListener에선 listener 변수에 createEventListenerWrapperWithPriority가 반환하는 값을 넣고있다.

그리고 이 listener를 아래에 보이는 2중 조건문에서 addEventBubbleListener등에 인자로 주입해주고있다.

addEventBubbleListener는 아래 코드가 전부인 함수다. 즉 우리에게 익숙한 addEventListener를 등록해주는 함수이다.

target.addEventListener(eventType, listener, true);
return listener;

넘겨준 target은 제일 처음 봤던 root요소이므로 리액트가 모든 nativeEvent를 root에 장착해 핸들한다는 것을 여기서 알 수 있게 됐다. 결국 listenToAllSupportedEvents함수는 이름 값을 잘 한 것.

다만 전역에 모든 listener가 장착되어있는건 알겠는데, 이벤트가 발생한 요소를 어떻게 특정하며 이 요소에 붙어있는 onChanage, onClick과 같은 props에 할당된 핸들 함수는 어떻게 가져오고 실행할 수 있는 것일까? 아직 의문이 많다.

이벤트가 발생했을 때 listener를 실행할 텐데 이게 무슨 일을 하는진 아직 모르므로 createEventListenerWrapperWithPriority에 대해 계속 추적해보자.


createEventListenerWrapperWithPriority에서는 인자로 넘겨준 이벤트 이름으로 eventPriority를 받는걸 보면, 이벤트에서도 우선순위가 있음을 알 수 있다.

그리고 이 우선순위에 따라 listenrWrapper에 dispatchEvent를 주입해 반환한다. 아마 단일 이벤트랑 연속적으로 일어나는 이벤트(스크롤등)의 switch문인 것으로 확인된다.

반환한 값이 addEventListenr의 핸들함수(listener인자)로 넘어가는 것을 알고 있지만 정확히 어떤 일이 수행되는지 아직 알지 못했다. 그러니 switch문에서 listenerWrapper에 할당되는 3개의 이벤트 중에 dispatchContinousEvent를 더 파고들어보기로 했다.


dispatchContinousEvent의 인자중 마지막 인자인 nativeEvent는 이벤트가 실제 발생했을 때 주입되는 인자이다. 즉 addEventListener에 넘겨준 콜백의 인자가 당연하게도 저 nativeEvent인 것이다. 이벤트가 발생한 요소를 어떻게 특정하는지에 대해선 이제 해결되었다.

이벤트가 발생하면 이 dispatchContinousEvent에 nativeEvent가 넘겨지고 dispatchEvent라는 함수를 호출하게 된다.

이때 커스텀 이벤트가 발생했음을 알리기 위해 사용하는 함수때문에 dispatch라는 단어에 혼동이 왔다. 지금부터의 dispatch는 이벤트가 발생했음을 알리는 용도보다는, target에 부착된 이벤트를 찾아 실행한다는 의미로 알면 좋을 것 같다.


dispatchEvent가 길지만 우리의 목표(onChanage, onClick과 같은 props에 할당된 핸들 함수는 어떻게 가져오고 실행할 수 있는 것일지)에 맞는 함수는 dispatchEventForPluginEventSystem이다.


dispatchEventForPluginEventSystem에선 dispatchEventsForPlugins를 호출한다. nativeEvent는 계속 넘겨지고 있다.


dispatchEventsForPlugins에선 우리가 원하는 목표와 가까워졌음을 함수 내부 구현에서 볼 수 있다.

nativeEventTarget에는 getEventTarget을 반환한 값이 담기는데 이 함수는 return nativeEvent.target을 해주는 함수라서 결국 이벤트가 발생한 요소가 저 곳에 담김을 알 수 있다.

dispatchQueue를 생성하고 이를 extractEvents의 인자로 넘겨주고 있다.

extractEvents에서 dispatchQueue를 채우고 나면 processDispatchQueue를 통해 큐에 채워진 이벤트 핸들 함수들을 차례로 실행한다는 것을 알 수 있다.

extractEvents와 processDispatchQueue순서대로 DFS방식으로 탐색해보자.

extractEvents는 EnterLeaveEventPlugin, ChangeEventPlugin등의 extractEvents를 모두 호출하는 기능을 수행하고 있다. 이중 하나인 ChangeEventPlugin에 대해서만 살펴보았다.


ChangeEventPlugin의 extractEvents에선 dispatchQueue를 createAndAccumulateChangeEvent에서 필요로 하니 이 함수를 살펴보자.


createAndAccumulateChangeEvent에선 listener에 accumulateTwoPhaseListeners에서 반환된 값을 넘겨주는데 이 함수의 인자로 넘어가는 것이 Fiber타입의 inst와 'onChange'이다.
이렇게 받아온 listeners를 dispatchQueue에 push한다. 뭘 push하는지 더 알아보자.



accumulateTwoPhaseListeners에서 getListener를 호출해 listener들을 가져오는데 바로 이 함수가 타겟의 props목록에서 onChange같은 prop의 인자로 넘겨준 핸들 함수를 가져오는 함수이다. 즉 () => console.log('hi')를 가져온다는 것이다.

그렇게 가져온 핸들 함수를 listeners에 집어넣는데 phase에 따라 head, tail에 집어넣을지가 결정된다. 그리고 이 안에 있는 while 문에서 target에서부터 root를 향해 가기 때문에, 중간에 있는 핸들 함수들을 점부 잡아서 listeners에 넣게 된다.


이렇게 하나의 이벤트 타겟에 대한 change 이벤트 핸들 함수들이 dispatchQueue의 item({systheticEvent, listener})으로 push되게 된다.

다시 하나씩 타고 올라가면, 이렇게 모든 eventPlugin들에 대해서 dispatchQueue를 채워준 후, processDispatchQueue를 수행하게 된다.

processDispatchQueue는 이름에서부터 쉽게 알 수 있기 때문에 실제 코드 캡처는 제외했다. queue를 순회하면서 listeners에 넣어줬던 이벤트들을 실행한다.


▲ 실행하는 함수의 이름은 executeDispatch이며 이때 listener의 인자로 넘어가는 event 객체가 바로 이전에 우리가 dispatchQueue.push({event, listeners})에서의 event 즉, SyntheticEvent객체이다. 그래서 리액트의 이벤트는 우리가 아는 event객체가 아닌 syntheticEvnet객체가 인자로 넘어가는 것이다.

사실 과정에서 마주치는 모든 코드를 이해하진 못했지만 지금 2일 정도 짬짬히 읽으면서 그랬듯이 앞으로 더 이해할 수 있는 코드가 많아질 것이다. 그러니 지금은 지금의 목표의 답을 알 수 있음에 만족하기로 했다.

결과

내가 알고 싶은 것은, 아래와 같은 코드를 실행하는데 어떤 식으로 이벤트가 전역에 달리는지 그리고 어떻게 () => console.log('hi')를 찾아 실행하는 건지이다.

<button onClick={() => console.log('hi')}>

답: 리액트에서 모든 이벤트는 root에서 listener가 달린다.
이 listener에 넘겨지는 콜백 함수는 클릭된 이벤트 요소와 이 요소에서 루트로 향하는 모든 경로에 있는 컴포넌트의 인자(props['onChange'])로 접근해 핸들 함수들을 모조리 가져와 phase 순서대로 호출하게 된다.

클릭된 요소인 button에서 target이 출발하고 이 button의 props['onClick']으로 콘솔 hi를 찍는 함수를 가져와 listeners에 넣고 이를 processDispatchQueue에서 하나씩 listener()로 실행할 때 드디어 콘솔창에 hi를 찍게 되는 것이다.

내가 미션에서 의도한 전역 이벤트는 깊은 depth의 컴포넌트가 커스텀 이벤트를 호출하고 이를 제일 상위에서 catch해 핸들하는 식이었다.

리액트는 이 방식과는 다르게 root에 장착된 핸들 함수 = 클릭된 요소에서부터 root로 올라오면서 보이는 이벤트들을 모아서 실행 이었다.

나는 props drilling 때문에 각 컴포넌트 내부에 핸들 함수를 두지 못했지만, 리액트는 각 컴포넌트 내부에 핸들 함수를 두면서도 최상위에서 이를 잡아주는 로직이 내부에 구현되어있다. 따라서 나처럼 최상위 컴포넌트(컨트롤러)가 이벤트 핸들로 인해 더러워질 일이 없는 방식이었다.

요약

react에서 모든 nativeEvent들은 root에서 listener가 장착된다.

장착된 listener에 넘겨지는 핸들 함수(콜백 함수)는 이벤트가 일어난 엘리먼트에서 root로 올라오면서 이벤트 핸들러에 넘겨준(ex onClick등에 넘겨준) 함수들을 dispatchQueue에 수집한다.

root로 도달해 수집이 완료되면 모인 dispatchQueue의 요소들을 하나씩 순차 실행한다.

잡설

인스턴스니 Fiber니 명확하게는 이해하지 못했지만 리액트에선 컴포넌트를 저런 하나의 단위로 포장했다. 그리고 컴포넌트의 props는 이 포장된 객체에서 [key]로 접근해 사용할 수 있음을 알게 되었다.

타입스크립트로 리액트가 구현되어있었는데, 타입이 있으니 코드 추론하기가 편했다. 인자의 이름만으로는 추상적인걸 타입이 명확하게 알려주었기 때문이다.

dispatchQueue의 이름이 왜 dispatch일까를 생각해보았는데 이벤트의 흐름대로 간다면 결국의 종착지는 root이고 어느 지점이든 전파가 되어야 root에서 처리가 가능하니까 dispatchQueue이지 않을까 싶었다.

함수의 추상화 수준(타고타고 가는 과정중에서 아직 얕을 때)이 높을 수록 읽기가 어려웠다. react에 대해서 잘 몰라서인지, 함수 이름을 봐도 이해가 어려워서인지는 잘 모르겠다. trapped, supported, listen, listener, dispatch등의 용어에 대한 의미가 아직 제대로 와닿지 않아서 일 수도..

후기

정리하는 것엔 영 재주가 없어 아이패드로 손글씨, 노트에 손글씨, 컴퓨터 메모장에 정리 3가지 방법을 모두 사용해보았는데.. 길이 제약, 표현 한계로 인해 프리폼이 가장 편했다. 코드 하이라이팅도 가능하며(캡처라서), 화살표로 관계를 이어주는게 가능하기 때문이다.

화살표 관계하면 떠오르는 마인드 맵은 코드 하이라이팅이 어렵고, 하이라이팅이 되면서 관계 표현이 가능한 옵시디언은 내가 썼을 땐 각 노드가 페이지로 관리되었기 때문에 이리저리 왔다갔다 하기가 어려웠다.

0개의 댓글