멋사에서 리액트를 공부하는 중에,
리액트에서 렌더링이 어떻게 이뤄지는지 익힘으로써,
앞으로 리액트에서 어떤 작업을 하더라도 설계한 대로 될 수 있도록 정리해보고자 한다.
React에서의 렌더링
리액트의 렌더링 과정은 크게 세 단계(Trigger - Render - Commit)로 나뉜다.
Trigger(Render Trigger)
// main.jsx(or index.jsx)
ReactDOM.createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>
);
위 코드를 실행하면서 리액트 렌더링 프로세스가 시작된다.
렌더링 트리거 단계에서는 애플리케이션에 변경사항이 발생할 때 새로운 렌더링을 시작하는데, 주요 트리거는 다음과 같다.
- 초기 렌더링: 컴포넌트 최초 생성 시 자동으로 호출된다.
- 상태 변경: useState 또는 this.setState를 사용하여 상태를 변경할 때 발생한다.
- 부모 컴포넌트의 프로퍼티 변경: 부모 컴포넌트에서 전달받은 프로퍼티가 변경되면 자식 컴포넌트에서 렌더링이 발생한다.
Render(Components Rendering)
컴포넌트 렌더링 단계에서는 컴포넌트가 렌더링되면 리액트는 해당 컴포넌트와 그 하위 컴포넌트들의 가상 DOM(React Element Tree)을 구축한다. 각 컴포넌트에서 render 함수를 호출하여 결과를 수집하고, 결과를 바탕으로 가상 DOM 트리를 구성한다. 이 단계에서는 실제 DOM에는 아무 변화가 없으며, 컴포넌트의 최초 렌더링 또는 상태 변경 시 이 단계가 반복된다. 리액트는 이전 가상 DOM 트리와 새 가상 DOM 트리를 비교하여 차이를 계산한다. 이를 렌더링 과정에서의 최적화 및 성능 향상 기법, 즉 "Reconciliation" (재조정)이라고 한다.
Commit(DOM Commit)
돔 커밋 단계에서는 렌더링 과정에서 계산된 가상 DOM 트리의 변경사항을 실제 DOM(index.html 내부의 js <div id="root"></div>
)에 반영한다. 변경사항만 실제 DOM에 적용되며, 최소한의 DOM 조작을 통해 성능 향상을 이룬다.이 단계에서 브라우저가 UI를 그리거나 업데이트하는 작업이 발생한다.
useState
리액트 함수 컴포넌트에서 상태 관리를 위해 사용되는 Hook으로, useState의 주요 기능은 상태 생성, 상태 변경 및 렌더링 간 상태 유지의 역할을 한다.
useState는 값을 받아 상태 변경 함수(setState)와 함께 초기 상태를 반환하는 함수로, 초기 상태와 함께 배열의 구조 분해 할당([state, setState])을 사용하여 상태 값을 가져오게 된다.
useState의 동작원리
const countState = useState(0); // useState는 상태값과 상태를 변경하는 함수로 구성된 튜플을 반환한다.
console.log(countState); // [0, f] => 첫 번째 요소는 상태의 현재 값, 두 번째 요소는 상태를 변경하는 함수를 가진다.
const [state, setState] = useState(initialState); // 구조 분해 할당한 useState의 기본적인 형태이다. initialState에는 초기값이 들어간다.
useState의 핵심 동작 원리는 클로저와 메모이제이션이다.
함수 컴포넌트가 (재)렌더링될 때마다 새로운 클로저가 생성되어, 내부에서 state 값을 읽고 수정할 수 있다.
또한, 렌더링 간 변경되지 않은 상태 값은 메모이제이션된 값을 이용하여 리소스를 줄인다.
setState를 호출하여 상태를 변경하면 해당 컴포넌트는 새로운 렌더링이 발생한다. 변경된 상태 값은 가상 DOM 트리 구축과정에서 다시 계산되고, 변경사항을 마지막으로 커밋 과정에서 실제 DOM에 적용된다.
이렇게 useState를 사용하면 컴포넌트 내에서 상태 값의 생성, 변경 및 렌더링 간 유지 등의 과정을 안정적으로 처리할 수 있다.
useState 사용예제(Counter)
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(9);
return (
<div>
<button
onClick={() => {
setCount((prevState) => prevState - 1);
}}
>
-1
</button>
<output>{count}</output>
<button
onClick={() => {
setCount((c) => c + 1);
}}
>
+1
</button>
</div>
);
}
export default Counter;
초기 카운트 값은 9이며, 이 상태는 count 변수에 할당된다. setCount 함수는 상태 업데이트를 위한 함수입니다. -1과 +1 버튼에 대한 클릭 이벤트 핸들러를 살펴보자면, setCount 함수는 다음과 같이 이전 상태값을 받아 새로운 상태값으로 업데이트하는 콜백 함수를 전달받는다.
감소버튼
setCount((prevState) => prevState - 1);
증가버튼
setCount((c) => c + 1);
참고사항:
click 이벤트에 setCount(count + 1)과 같이 바로 값을 전달하게 되면 렌더링 무한 루프가 발생할 수 있다.
-> count의 state 값이 변하게 되면서 리렌더링을 하고 onClick 에 다시 setCount() 의 결과값을 넣어주기 위해 setCount 함수를 실행시키게 되고 또 count의 state 값이 변하는 것이 반복되기 때문이다
=> 따라서, 위와 같이 무한루프에 걸리지 않기위해서는 콜백함수 형태로 작성해야 한다.
setState 파보기
자세한 동작원리는 이해하지 못해 LLM의 힘을 빌려 정리해보았다
setState를 console로 출력해보면 다음과 같다.
function dispatchSetState(fiber, queue, action) {
{
if (typeof arguments[3] === 'function') {
error("State updates from the useState() and useReducer() Hooks don't support the " + 'second callback argument. To execute a side effect after ' + 'rendering, declare it in the component body with useEffect().');
}
}
var lane = requestUpdateLane(fiber);
var update = {
lane: lane,
action: action,
hasEagerState: false,
eagerState: null,
next: null
};
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
var alternate = fiber.alternate;
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
// The queue is currently empty, which means we can eagerly compute the
// next state before entering the render phase. If the new state is the
// same as the current state, we may be able to bail out entirely.
var lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
var prevDispatcher;
{
prevDispatcher = ReactCurrentDispatcher$1.current;
ReactCurrentDispatcher$1.current = InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
var currentState = queue.lastRenderedState;
var eagerState = lastRenderedReducer(currentState, action); // Stash the eagerly computed state, and the reducer used to compute
// it, on the update object. If the reducer hasn't changed by the
// time we enter the render phase, then the eager state can be used
// without calling the reducer again.
update.hasEagerState = true;
update.eagerState = eagerState;
if (objectIs(eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
// It's still possible that we'll need to rebase this update later,
// if the component re-renders for a different reason and by that
// time the reducer has changed.
// TODO: Do we still need to entangle transitions in this case?
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update, lane);
return;
}
} catch (error) {// Suppress the error. It will throw again in the render phase.
} finally {
{
ReactCurrentDispatcher$1.current = prevDispatcher;
}
}
}
}
var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
var eventTime = requestEventTime();
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
entangleTransitionUpdate(root, queue, lane);
}
}
markUpdateInDevTools(fiber, lane);
}
setState의 주요 로직
1. Update 생성: 상태 업데이트를 위한 update 객체를 생성
var lane = requestUpdateLane(fiber); var update = { lane: lane, action: action, hasEagerState: false, eagerState: null, next: null };
2. 렌더링 단계 업데이트 처리: 현재 렌더링 단계에서 상태 업데이트가 발생할 경우 처리
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
// ...
}
3. 상태 업데이트 큐에 추가: 변경 사항을 상태 업데이트 큐에 추가하며, 필요에 따라 스케줄을 조정하여 업데이트를 처리
var root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); if (root !== null) { var eventTime = requestEventTime(); scheduleUpdateOnFiber(root, fiber, lane, eventTime); entangleTransitionUpdate(root, queue, lane);
4.root 업데이트: 요청된 변경 사항에 따라 루트(root)를 업데이트하고 컴포넌트를 다시 렌더링하는 코드입니다. 이 부분은 위의 '상태 업데이트 큐에 추가'에서 scheduleUpdateOnFiber 호출을 통해 처리
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
단어 정리
튜플: 튜플(tuple)이란 여러 개의 값을 순서대로 묶은 것을 의미한다. 자바스크립트에서는 배열(array)로 표현되며, 각 요소의 타입이 다양할 수 있습니다. 순서가 중요하고, 여러 값들이 함께 그룹핑되어 관리되어야 할 때 튜플을 사용한다고 한다.
=> js[1, apple, false]
와 같이 배열 형태를 띄는데, 여러 값을 묶은 것을 튜플이라고 부를 수 있는 것 같다.
고민해볼 부분
setState 기저의 동작원리.