2021. 7. 19. 08:06ㆍFrontend
위 아티클은 React 17버전(17.0.1) 기준으로 작성되었습니다. 17버전 이전(16버전 이하)에서는 조금 다르게 동작할 수 있으며, 이 차이점에 대해서는 이어지는 시리즈(2)에서 다룰 예정입니다.
Overview
리액트로 웹 애플리케이션을 개발하다보면 사용자와 유연하게 상호작용하는 컴포넌트를 만들기 위해 onClick, onChange등의 이벤트 핸들러(Eventhandler)를 사용해야 합니다. 크로스 브라우저 대응 등을 위해 리액트 자체적으로 제공하는 안정성 있는 이벤트 핸들링 시스템 덕분에 대부분의 경우 핸들러를 붙이기 원하는 컴포넌트에 다음과 같은 형태로 이벤트 핸들러를 넣어주면 되지만, 컴포넌트들이 복잡하게 상호작용하고 있는 대형 애플리케이션의 경우, 때로는 이 이벤트 핸들러들로 인해 의도치 않은 부수효과(Side Effect)가 발생하기도 합니다.
import React, { useCallback } from "react";
export default function App() {
const handleClick = useCallback((e) => {
console.log("Event Object: ", e);
}, []);
return (
<div className="App">
<button onClick={handleClick}>Try Me!</button>
</div>
);
}
따라서 이번 포스팅에서는 React 17 버전을 기준으로, 리액트가 onClick, onChange 등의 이벤트 핸들러들을 어떻게 관리하는지에 대해 조금 깊이 있게 다루어보려고 합니다.
How Browser Handles Event
리액트의 개입 없이 HTML과 JavaScript만으로 이벤트를 처리하는 방식은 다음과 같습니다. 리액트가 등장하기 전, 자바스크립트만으로 웹 프로그램을 만들던 때에는 대부분 이런 방식으로 이벤트 핸들링을 구현했습니다.
STEP1:
Javascript를 사용해서 이벤트 핸들러를 붙일 DOM Node를 Select (getElementById등의 Browser 기본 제공 API를 사용합니다.)
STEP2:
Select된 해당 DOM Node에 addEventListener 함수를 사용해서 특정 콜백 함수를 등록합니다.
STEP3:
사용자가 해당 이벤트 리스너가 바인드된 DOM Node와의 상호작용을 통해 해당 이벤트를 트리거하면, 콜백함수가 동작합니다.
// HTML
<!DOCTYPE html>
<html>
<head>
<title>Simple EventHandler</title>
<meta charset="UTF-8" />
</head>
<body>
<div id="root">
<button id="button">Try Me!</button>
</div>
<script src="src/index.js"></script>
</body>
</html>
// Javascript
document.getElementById("button").addEventListener("click", (e) => {
console.log("Event: ", e);
});
간단한 동작이지만, 이후 소개할 “How React Handles Event” 와는 다르게 위의 방식은 “이벤트 등록이 필요한 DOM Node에 이벤트를 직접 등록한다”는 점을 꼭 기억해두셔야 합니다.
How React Handles Event
In React 17, React will no longer attach event handlers at the document level. Instead, it will attach them to the root DOM container into which your React tree is rendered: — React Blog.
리액트 공식문서에 따르면, React 17버전부터 이벤트 핸들러를 “Document” 레벨이 아닌 리액트 트리가 렌더되는 root DOM container에 attach한다고 합니다. 16 이하 버전과 17 이후 버전에 이벤트 핸들러를 부착하는 방식이 차이가 있다는 것인데, 이 차이점에 대해서는 차차 살펴보기로 하고 우선 위 문구에서 드러난 다음의 사실에 집중해 보겠습니다.
“Instead, it will attach them to the root DOM container into which your React tree is rendered”
즉, 이벤트 핸들러를 내가 Button 컴포넌트에 붙이든, Input 컴포넌트에 붙이든 상관없이 모든 이벤트 핸들러들이 root DOM container에 부착된다는 의미입니다. 실제로 이렇게 동작하는지를 확인하기 위해 간단한 예시를 만들어서 확인한 결과, 다음과 같이 실제로 Native Event Handler가 button node가 아닌 root node에 attach된 것을 확인할 수 있습니다.
// React Version 17.0.2
import React, { useCallback } from "react";
export default function App() {
const handleClick = useCallback((e) => {
console.log(e.nativeEvent.currentTarget);
}, []);
return (
<div className="App">
<button id="test" onClick={handleClick}>
Try Me!
</button>
</div>
);
}
e.nativeEvent.currentTarget이 root Node임을 확인할 수 있습니다.
Synthetic Event
리액트는 브라우저 호환(크로스 브라우징)을 위해 NativeEvent를 그대로 사용하는 것이 아닌 SyntheticEvent 객체를 이용해서 NativeEvent를 감싸는 방식을 사용합니다.아래 BaseSyntheticEvent타입(전체 SyntheticEvent 타입의 일부)을 통해 알 수 있듯, 브라우저 NativeEvent를 비롯해, 해당 이벤트를 발생시킨 FiberNode의 정보등을 가지고 있습니다. 실제로 이벤트가 발생했을 때 리액트의 ‘onClick’, ‘onChange’등의 이벤트 핸들러에 전달하는 값은 NativeEvent가 아닌 바로 이 SyntheticEvent 객체가 됩니다.
type BaseSyntheticEvent = {
isPersistent: () => boolean,
isPropagationStopped: () => boolean,
_dispatchInstances?: null | Array<Fiber | null> | Fiber,
_dispatchListeners?: null | Array<Function> | Function,
_targetInst: Fiber,
nativeEvent: Event,
target?: mixed,
relatedTarget?: mixed,
type: string,
currentTarget: null | EventTarget,
};
이 SyntheticEvent를 사용하기 때문에 리액트에서는 이 이벤트를 처리하는 핸들러(‘onClick’, ‘onChange’등)를 따로 보유하게 되는 것이며, NativeEvent(‘click’, ‘change’)와 이 이벤트 핸들러를 매핑해주는 단계가 필요합니다.
Your event handlers will be passed instances of SyntheticEvent, a cross-browser wrapper around the browser’s native event. It has the same interface as the browser’s native event, including stopPropagation() and preventDefault(), except the events work identically across all browsers.: — React Blog
리액트가 처리하는 이벤트 핸들러(‘onClick’, ‘onChange’등)를 root DOM node에 붙이는 과정은 Virtual DOM(Fiber Tree)를 생성하는 시점에 일어납니다. 리액트는 이 Fiber Tree를 root DOM node에 _reactRootContainer 라는 key로 저장하게 되며, 리액트에서 처리하는 이벤트 핸들러들은 이 Fiber Tree가 생성되고 나면, 다음의 단계를 거쳐 root DOM node에 attach 됩니다.
STEP1:
‘click’, ‘change’, ‘dblclick’ 등 핸들링 되어야 할 모든 Native Event들의 목록을 정리합니다. 리액트는 코드 안에 NativeEvent List를 저장해두고 있습니다.
const discreteEventPairsForSimpleEventPlugin = [
('cancel': DOMEventName), 'cancel',
('click': DOMEventName), 'click',
('close': DOMEventName), 'close',
('contextmenu': DOMEventName), 'contextMenu',
('copy': DOMEventName), 'copy',
('cut': DOMEventName), 'cut',
('auxclick': DOMEventName), 'auxClick',
('dblclick': DOMEventName), 'doubleClick', // Careful!
('dragend': DOMEventName), 'dragEnd',
('dragstart': DOMEventName), 'dragStart',
... some other events
];
// https://github.com/facebook/react/blob/v17.0.2/packages/react-dom/src/events/DOMEventProperties.js#L45
리액트는 코드 안에 handling 되어야 할 Native Event의 목록을 가지고 있습니다.
STEP2:
NativeEvent 이름과 리액트 이벤트 핸들러 Property를 매핑합니다. 이를테면 click: 'onClick', change: 'onChange' 와 같은 형식입니다. 이렇게 생성된 매핑테이블은 추후 이벤트가 발생했을 때, 이를 적절한 이벤트 핸들러와 연결해줍니다.
function registerSimplePluginEventsAndSetTheirPriorities(
eventTypes: Array<DOMEventName | string>,
priority: EventPriority,
): void {
// As the event types are in pairs of two, we need to iterate
// through in twos. The events are in pairs of two to save code
// and improve init perf of processing this array, as it will
// result in far fewer object allocations and property accesses
// if we only use three arrays to process all the categories of
// instead of tuples.
for (let i = 0; i < eventTypes.length; i += 2) {
const topEvent = ((eventTypes[i]: any): DOMEventName);
const event = ((eventTypes[i + 1]: any): string);
const capitalizedEvent = event[0].toUpperCase() + event.slice(1);
const reactName = 'on' + capitalizedEvent;
eventPriorities.set(topEvent, priority);
topLevelEventsToReactNames.set(topEvent, reactName);
registerTwoPhaseEvent(reactName, [topEvent]);
}
}
// https://github.com/facebook/react/blob/v17.0.2/packages/react-dom/src/events/DOMEventProperties.js#L159
capitalizedEvent등을 거쳐서 native ‘click’ 이벤트가 우리에게 익숙한 ‘onClick’으로 바뀌게 됩니다.
STEP3:
Discrete Event, UserBlocking Event, Continuous Event 등 리액트에서 정의한 이벤트 타입에 따라 부여하는 이벤트의 우선순위가 다른데, 전체 Native Event를 리액트에서 부여하는 기준에 맞게 우선순위를 설정합니다.
export const DiscreteEvent: EventPriority = 0;
export const UserBlockingEvent: EventPriority = 1;
export const ContinuousEvent: EventPriority = 2;
// https://github.com/facebook/react/blob/v17.0.2/packages/shared/ReactTypes.js#L91
STEP4:
위 1 ~ 3단계를 진행한 이후에 리액트의 Virtual DOM(root Fiber Node)에 이 이벤트 핸들러들을 등록하는 과정을 거칩니다.
코드상으로 특정 컴포넌트(e.g Button Component)에 명시한 이벤트 핸들러라도, 리액트가 Virtual DOM을 생성하는 과정에서 이를 root DOM Node에 등록하기 때문에 실제로 브라우저 API인 getEventListeners() 를 통해 확인해보면 root Node에 모든 이벤트 핸들러들이 붙어 있는 것을 확인할 수 있습니다.
What Happens when User Clicks The Button?
위의 내용을 정리해보면, 리액트는 NativeEvent를 한번 감싼 SyntheticEvent 객체를 사용해 이벤트를 처리하며, FiberTree가 생성되는 시점에 NativeEvent의 이름과 리액트 이벤트 핸들러의 Property를 매핑해주는 매핑 테이블을 생성하고, 전체 NativeEvent에 대해 Loop을 돌면서 해당 이벤트에 리액트 이벤트 핸들러를 등록합니다. (이때 등록되는 EventListener는 dispatchEvent 라는 공통 인터페이스를 사용합니다.)
이 단계들은 앱이 최초로 렌더링 되기 전에 모두 이루어지기 때문에 실제로 사용자가 버튼을 클릭하거나, Input에 값을 입력하는 등의 User Action이 발생하는 시점에는 root DOM node에 모든 이벤트 핸들러가 등록되어 있는 상태입니다.
그렇다면 이 시점에서 유저가 버튼을 ‘클릭' 했을 때, 실제로 리액트 이벤트 시스템에서는 어떤 일이 일어날까요? 다음의 예시에서 한번 살펴보도록 하겠습니다.
import React, { useCallback } from "react";
export default function App() {
const handleClickDiv = useCallback((e) => {
console.log("Div Clicked: ", e);
}, []);
const handleClickButton = useCallback((e) => {
console.log("Button Clicked: ", e);
}, []);
return (
<div className="App">
<div id="test-wrapper" onClick={handleClickDiv}>
<button id="test" onClick={handleClickButton}>
Try Me!
</button>
</div>
</div>
);
}
STEP1:
Button을 클릭하면 리액트에서 ‘click’ 이벤트를 감지하고, attach한 이벤트 리스너가 트리거됩니다. 이때, 이 이벤트 리스너는 리액트에서 정의한 dispatchEvent 함수를 호출하게 됩니다.
export function dispatchEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
): void {
if (!_enabled) {
return;
}
let allowReplay = true;
if (enableEagerRootListeners) {
allowReplay = (eventSystemFlags & IS_CAPTURE_PHASE) === 0;
}
if (
allowReplay &&
hasQueuedDiscreteEvents() &&
isReplayableDiscreteEvent(domEventName)
) {
queueDiscreteEvent(
null, // Flags that we're not actually blocked on anything as far as we know.
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
return;
}
// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-dom/src/events/ReactDOMEventListener.js#L182
STEP2:
호출시 넘어온 이벤트 객체로부터 target DOM node(여기서는 Button node)를 식별하며, 내부적으로 사용하는 키인 internalInstanceKey 를 사용하여 이 DOM node가 어떤 Fiber node instance와 매칭되는지를 확인합니다
export function getClosestInstanceFromNode(targetNode: Node): null | Fiber {
let targetInst = (targetNode: any)[internalInstanceKey];
if (targetInst) {
return targetInst;
}
let parentNode = targetNode.parentNode;
... for simplification, i've omitted below
}
// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-dom/src/client/ReactDOMComponentTree.js#L73
targetNode에서 internalInstnaceKey를 통해 fiberNode Instance에 접근합니다.
STEP3:
해당 Fiber node instance를 찾고 나면, 해당 node로부터 출발해서 root node에 이르기까지 Fiber Tree를 순회합니다. 이때 매칭되는 이벤트 Property(‘onClick’)과 매칭되는 이벤트를 가지는 Fiber Node(여기서는 onClick 이 바인딩된 ‘div’ node)를 발견할때 마다 이 이벤트 리스너(콘솔을 찍는 함수)들을 dispatchQueue 라고 불리는 Array에 저장합니다.
STEP4:
root node에 도착하고 나면, 처음 들어간 순서대로 리스너 함수를 실행합니다. (즉 위의 예시에서는 Button의 리스너가 먼저 실행되고, div의 리스너가 나중에 실행됩니다.) 아래 코드에서 주목할 만한 부분은 리스너로부터 fiberNode instance, currentTarget, listener함수를 추출하여 propagation 여부를 검사하고, 이벤트 중복 여부를 확인한 이후에 executeDispatch 함수를 트리거한다는 점입니다.
리액트에서 e.stopPropagation 함수를 호출했을 때, parent Element의 이벤트가 더 이상 전파되지 않는 이유를 확인할 수 있습니다.
function processDispatchQueueItemsInOrder(
event: ReactSyntheticEvent,
dispatchListeners: Array<DispatchListener>,
inCapturePhase: boolean,
): void {
let previousInstance;
if (inCapturePhase) {
for (let i = dispatchListeners.length - 1; i >= 0; i--) {
const {instance, currentTarget, listener} = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
} else {
for (let i = 0; i < dispatchListeners.length; i++) {
const {instance, currentTarget, listener} = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
}
}
function executeDispatch(
event: ReactSyntheticEvent,
listener: Function,
currentTarget: EventTarget,
): void {
const type = event.type || 'unknown-event';
event.currentTarget = currentTarget;
invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event);
event.currentTarget = null;
}
// https://github.com/facebook/react/blob/12adaffef7105e2714f82651ea51936c563fe15c/packages/react-dom/src/events/DOMPluginEventSystem.js#L233
Conclusion
리액트 이벤트 시스템은 리액트의 fiberNode 구조(Virtual DOM)와 Synthetic Event 객체를 사용하여 이벤트 핸들링이 필요한 컴포넌트에 직접 이벤트 핸들러를 붙이지 않고도 동작할 수 있도록 구현되었습니다. 리액트 공식 문서에서도 밝혔듯, 17 버전은 17 이후의 버전을 위한 Stepping Stone 이고, 이벤트 핸들링 시스템을 위와 같이 구현함으로써, 하나의 페이지에 서로 다른 버전의 리액트를 적용하더라도, Document 객체가 아닌 각각의 Root Node에 이벤트 핸들러가 바인딩 되므로 쉬운 마이그레이션을 가능하게 해 줍니다. 다음 포스팅에서는 React 16 과 17의 이벤트 시스템을 심도 있게 비교함으로써, 이벤트 시스템에 대한 이해의 폭을 넓히려고 합니다.
'Frontend' 카테고리의 다른 글
React Deep Dive - React Event System (2) (0) | 2021.12.31 |
---|---|
[React] Atomic Design Pattern에 대한 고찰 (0) | 2021.09.18 |
Async / Await Under the Hood (1) | 2021.06.29 |
[Webpack] Code Splitting (2) | 2021.06.17 |
[Webpack] Plugins (0) | 2021.06.12 |