- Jser.dev의 React Internals Deep Dive 시리즈 중 How does useState() work internally in React?를 번역한 글입니다.
- 원글, 본글 모두 react 18.2.0 버전 기준입니다.
- React 코드의 주석이 아닌, 저자가 추가한 주석엔 '저자'를 앞에 붙였습니다.
아마 당신은 useState()
에 익숙할 것 입니다. 아래의 간단한 카운터 앱은 useState()
가 어떻게 컴포넌트에 상태를 추가하는지 보여줍니다. 이번 에피소드에서는 소스 코드를 살펴보며 useState()
가 내부적으로 어떻게 동작하는지 알아보겠습니다.
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count => count + 1)}>click {count}</button>
</div>
);
}
useState()
초기 렌더링은 매우 간단합니다.
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
// 저자) 새 훅이 만들어집니다.
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
// 저자) 훅의 memoizedState는 실제 상태 값을 가지고 있습니다.
hook.memoizedState = hook.baseState = initialState;
/*
저자)
업데이트 큐는 미래의 상태 업데이트를 저장하기 위한 것입니다.
상태를 set 할 때, 상태 값이 즉시 업데이트 되는게 아니라는 것을 명심하세요.
이는 업데이트마다 다른 우선순위를 가질 수 있으며, 바로 처리될 필요가 없기 때문입니다.
그러므로, 업데이트를 임시로 저장한 뒤 나중에 처리해야 합니다.
*/
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
// 저자) lanes는 우선순위입니다.
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
// 저자) 이 큐는 훅의 업데이트 큐라는 것을 기억하세요.
hook.queue = queue;
const dispatch: Dispatch<
BasicStateAction<S>,
/*
저자)
이 dispatchSetState()는 실제 상태 setter로서 우리가 받는 함수입니다.
이 함수는 current fiber에 바인딩 되어 있다는 점에 주목하세요.
*/
> = (queue.dispatch = (dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue,
): any));
// 저자) 여기 useState()으로부터 받는 친숙한 문법이 있습니다.
return [hook.memoizedState, dispatch];
}
Lane에 대한 더 자세한 내용은 What are Lanes in React source code? 글을 참고하세요.
setState()
에서는 무슨 일이 일어날까?위 코드로부터 setState()
가 내부적으로는 바인딩 된 dispatchSetState()
라는 것을 알 수 있습니다.
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
// 저자) update의 우선순위를 결정합니다.
const lane = requestUpdateLane(fiber);
// 저자) 넵, 저장해 둘 업데이트 객체가 여기 있습니다.
const update: Update<S, A> = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
/*
저자)
렌더링 중에 setState를 할 수 있으며, 이는 유용한 패턴입니다.
(참고: https://react.dev/reference/react/useState#storing-information-from-previous-renders)
하지만, 이는 무한 렌더링을 유발할 수 있으므로 조심하세요.
*/
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
/*
저자)
이 조건문은 동일한 상태를 설정할 때 빠른 탈출(bailout)을 위한 것 입니다.
탈출은 하위 트리의 리렌더링을 건너 뛰기 위해, 더 깊이 가는 것을 멈추는 것을 의미합니다.
여기서는 리렌더링을 스케줄링 하지 않기 위함입니다.
하지만, 여기서의 조건은 사실 일종의 트릭으로, 필요한 것보다 엄격한 규칙입니다.
이 말은 React가 최선을 다해 리렌더링을 피하려고 하지만, 그럴 것을 보장할 수 없다는 것을 의미합니다.
이 내용은 아래 주의 사항 섹션에서 자세히 다뤄보겠습니다.
*/
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.
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
let prevDispatcher;
try {
const currentState: S = (queue.lastRenderedState: any);
const 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 (is(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);
// 저자) 이 return 문은 업데이트가 예약되는 것을 방지합니다.
return;
}
} catch (error) {
// Suppress the error. It will throw again in the render phase.
} finally {
if (__DEV__) {
ReactCurrentDispatcher.current = prevDispatcher;
}
}
}
}
// 저자) 업데이트를 임시로 저장합니다. 업데이트는 실제 리렌더링이 시작될 때 처리되고, fiber에 부착됩니다.
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
const eventTime = requestEventTime();
/*
저자)
리렌더링을 스케줄링 합니다.
리렌더링은 즉시 발생하지 않는다는 점에 유의하세요.
실제 예약은 React scheduler에 따라 결정됩니다.
*/
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
entangleTransitionUpdate(root, queue, lane);
}
}
}
업데이트 객체가 어떻게 처리되는지 더 알아보겠습니다.
// If a render is in progress, and we receive an update from a concurrent event,
// we wait until the current render is over (either finished or interrupted)
// before adding it to the fiber/hook queue. Push to this array so we can
// access the queue, fiber, update, et al later.
const concurrentQueues: Array<any> = [];
let concurrentQueuesIndex = 0;
let concurrentlyUpdatedLanes: Lanes = NoLanes;
/*
저자)
이 함수는 리렌더의 초기 단계 중 하나인 prepareFreshStack() 안에서 호출됩니다.
이 함수는 리렌더링이 본격적으로 시작되기 전에 모든 상태 업데이트가 저장된다고 말합니다.
*/
export function finishQueueingConcurrentUpdates(): void {
const endIndex = concurrentQueuesIndex;
concurrentQueuesIndex = 0;
concurrentlyUpdatedLanes = NoLanes;
let i = 0;
while (i < endIndex) {
const fiber: Fiber = concurrentQueues[i];
concurrentQueues[i++] = null;
const queue: ConcurrentQueue = concurrentQueues[i];
concurrentQueues[i++] = null;
const update: ConcurrentUpdate = concurrentQueues[i];
concurrentQueues[i++] = null;
const lane: Lane = concurrentQueues[i];
concurrentQueues[i++] = null;
if (queue !== null && update !== null) {
/*
저자)
hook.queue에 대해 말한 것을 기억하나요?
여기서 우리는 임시로 저장된 업데이트들이 마침내 fiber에 붙는 것을 볼 수 있습니다.
이는, 처리될 준비가 된다는 것을 의미합니다.
*/
const pending = queue.pending;
if (pending === null) {
// This is the first update. Create a circular list.
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.pending = update;
}
if (lane !== NoLane) {
/*
저자)
이 함수 호출에도 주목하세요. 이 함수는 fiber 노드의 경로를 더럽게 표시합니다.
자세한 내용은 'How does React bailout work in reconcilation' 글의 그림을 참고하세요.
(링크: https://jser.dev/react/2022/01/07/how-does-bailout-work/#lanes--childlanes)
*/
markUpdateLaneFromFiberToRoot(fiber, update, lane);
}
}
}
function enqueueUpdate(
fiber: Fiber,
queue: ConcurrentQueue | null,
update: ConcurrentUpdate | null,
lane: Lane,
) {
// Don't update the `childLanes` on the return path yet. If we already in
// the middle of rendering, wait until after it has completed.
// 저자) 내부적으로 업데이트는 메시지 큐처럼 배열에 모관되며, 일괄 처리됩니다.
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
// The fiber's `lane` field is used in some places to check if any work is
// scheduled, to perform an eager bailout, so we need to update it immediately.
// TODO: We should probably move this to the "shared" queue instead.
fiber.lanes = mergeLanes(fiber.lanes, lane);
// 저자) current와 alternate fiber들 모두 '더럽게' 표시되는 것을 볼 수 있습니다. 이 주의사항(아래 5절)을 이해하는 것이 중요합니다.
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
}
export function enqueueConcurrentHookUpdate<S, A>(
fiber: Fiber,
queue: HookQueue<S, A>,
update: HookUpdate<S, A>,
lane: Lane,
): FiberRoot | null {
const concurrentQueue: ConcurrentQueue = (queue: any);
const concurrentUpdate: ConcurrentUpdate = (update: any);
enqueueUpdate(fiber, concurrentQueue, concurrentUpdate, lane);
return getRootForUpdatedFiber(fiber);
}
function markUpdateLaneFromFiberToRoot(
sourceFiber: Fiber,
update: ConcurrentUpdate | null,
lane: Lane,
): void {
// Update the source fiber's lanes
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
let alternate = sourceFiber.alternate;
/*
저자)
lanes가 current fiber와 alternate fiber 모두에 대해 업데이트 되는 것에 주목하세요.
앞서 보았듯, dispatchSetState는 source fiber에 바인딩 되어 있다는 것을 기억하세요.
그러므로, 상태를 set할 때, 항상 current fiber 트리를 업데이트 하는 것은 아빈디ㅏ.
두 fiber 트리 모두를 업데이트하면 모든 것이 제대로 동작하지만, 이는 side effect를 일으킬 수 있습니다.
이러한 내용을 아래 주의사항 섹션(5절)에서 다루겠습니다.
*/
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
// 저자) 자세한 내용은 'how React bailout works' 글을 참고하세요.
// Walk the parent path to the root and update the child lanes.
let isHidden = false;
let parent = sourceFiber.return;
let node = sourceFiber;
while (parent !== null) {
parent.childLanes = mergeLanes(parent.childLanes, lane);
alternate = parent.alternate;
if (alternate !== null) {
alternate.childLanes = mergeLanes(alternate.childLanes, lane);
}
if (parent.tag === OffscreenComponent) {
const offscreenInstance: OffscreenInstance = parent.stateNode;
if (offscreenInstance.isHidden) {
isHidden = true;
}
}
node = parent;
parent = parent.return;
}
if (isHidden && update !== null && node.tag === HostRoot) {
const root: FiberRoot = node.stateNode;
markHiddenUpdate(root, update, lane);
}
}
export function scheduleUpdateOnFiber(
root: FiberRoot,
fiber: Fiber,
lane: Lane,
eventTime: number,
) {
checkForNestedUpdates();
// Mark that the root has a pending update.
markRootUpdated(root, lane, eventTime);
if (
(executionContext & RenderContext) !== NoLanes &&
root === workInProgressRoot
) {
// Track lanes that were updated during the render phase
workInProgressRootRenderPhaseUpdatedLanes = mergeLanes(
workInProgressRootRenderPhaseUpdatedLanes,
lane,
);
} else {
if (root === workInProgressRoot) {
// Received an update to a tree that's in the middle of rendering. Mark
// that there was an interleaved update work on this root. Unless the
// `deferRenderPhaseUpdateToNextBatch` flag is off and this is a render
// phase update. In that case, we don't treat render phase updates as if
// they were interleaved, for backwards compat reasons.
if (
deferRenderPhaseUpdateToNextBatch ||
(executionContext & RenderContext) === NoContext
) {
workInProgressRootInterleavedUpdatedLanes = mergeLanes(
workInProgressRootInterleavedUpdatedLanes,
lane,
);
}
if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
// The root already suspended with a delay, which means this render
// definitely won't finish. Since we have a new update, let's mark it as
// suspended now, right before marking the incoming update. This has the
// effect of interrupting the current render and switching to the update.
// TODO: Make sure this doesn't override pings that happen while we've
// already started rendering.
markRootSuspended(root, workInProgressRootRenderLanes);
}
}
/*
저자)
이 함수에서 유일하게 신경 써야할 줄입니다.
이 코드는 대기 중인 업데이트가 있으면 리렌더링이 예약되도록 합니다.
실제 리렌더링이 아직 시작되지 않았기 때문에 업데이트는 아직 처리되지 않았습니다.
리렌더링의 실제 시작은 이벤트 종류나 스케줄러 상태와 같은 몇 가지 요인에 따라 달라집니다.
이 함수를 여러 번 만났을텐데, 더 자세이 알고 싶다면 'How does useTransition() work internally' 글을 참고하세요.
*/
ensureRootIsScheduled(root, eventTime);
if (
lane === SyncLane &&
executionContext === NoContext &&
(fiber.mode & ConcurrentMode) === NoMode &&
// Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
!(__DEV__ && ReactCurrentActQueue.isBatchingLegacy)
) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbacksOnlyInLegacyMode();
}
}
}
useState()
업데이트가 임시 저장된 후, 이제 실제로 업데이트를 실행하고 상태 값을 업데이트할 때입니다.
이는 리렌더 시의 useState()
에서 일어납니다.
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
// 저자) 이는 이전에 생성된 훅을 제공하기 때문에, 그 값을 가져올 수 있습니다.
const hook = updateWorkInProgressHook();
/*
저자)
이는 모든 업데이트를 가지고 있는 업데이트 큐라는 것을 기억하세요.
리렌더링이 시작된 후 useState()가 호출되기 때문에, 임시로 저장된 업데이트는 fiber들로 이동합니다.
*/
const queue = hook.queue;
if (queue === null) {
throw new Error(
'Should have a queue. This is likely a bug in React. Please file an issue.',
);
}
queue.lastRenderedReducer = reducer;
const current: Hook = (currentHook: any);
/*
저자)
baseQueue에 대한 설명이 필요합니다.
제일 좋은 케이스는, 업데이트가 처리될 때 이를 그냥 버리는 것입니다.
그러나 서로 다른 우선순위의 여러 업데이트가 있을 수 있기 때문에, 일부는 나중에 처리하기 위해 건너 뛰어야할 수도 있습니다.
이것이 baseQueue에 저장하는 이유입니다.
또한, 처리된 업데이트라 하더라도, 최종 상태가 올바른지 확인하기 위해 일단 한 번 baseQueue에 넣으면 나머지 따라오는 업데이트들도 baseQueue에 넣어야합니다.
예를 들어, 상태 값이 1이고, 세 개의 업데이트가 있다고 가정해봅시다.
+1(낮은 우선순위), *10(높은 우선순위), -2(낮은 우선순위)
*10의 우선순위가 높기 떄문에, 그것을 처리하면 1 * 10 = 10이 됩니다.
이후 낮은 우선순위 업데이트를 처리할 때, *10을 큐에 넣지 않으면 1 + 1 - 2 = 0이 됩니다.
하지만 우리가 원하는 것은 (1 + 1) * 10 - 2입니다.
*/
// The last rebase update that is NOT part of the base state.
let baseQueue = current.baseQueue;
// The last pending update that hasn't been processed yet.
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// We have new updates that haven't been processed yet.
// We'll add them to the base queue.
if (baseQueue !== null) {
// Merge the pending queue and the base queue.
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
// 저자) 대기 큐는 비워지고, baseQueue로 합쳐집니다.
queue.pending = null;
}
if (baseQueue !== null) {
// We have a queue to process.
const first = baseQueue.next;
let newState = current.baseState;
let newBaseState = null;
// 저자) baseQueue가 처리된 뒤, 새 baseQueue가 만들어집니다.
let newBaseQueueFirst = null;
let newBaseQueueLast = null;
let update = first;
// 저자) 이 do...while 루프는 모든 업데이트의 처리를 시도합니다.
do {
...
// Check if this update was made while the tree was hidden. If so, then
// it's not a "base" update and we should disregard the extra base lanes
// that were added to renderLanes when we entered the Offscreen tree.
const shouldSkipUpdate = isHiddenUpdate
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
: !isSubsetOfLanes(renderLanes, updateLane);
if (shouldSkipUpdate) {
// Priority is insufficient. Skip this update. If this is the first
// skipped update, the previous update/state is the new base
// update/state.
// 저자) 넵, 낮은 우선순위에 대한 이야기입니다.
const clone: Update<S, A> = {
lane: updateLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
// 저자) 이 업데이트는 처리되지 않았기 때문에 새 baseQueue에 들어갑니다.
if (newBaseQueueLast === null) {
newBaseQueueFirst = newBaseQueueLast = clone;
newBaseState = newState;
} else {
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Update the remaining priority in the queue.
// TODO: Don't need to accumulate this. Instead, we can remove
// renderLanes from the original lanes.
currentlyRenderingFiber.lanes = mergeLanes(
currentlyRenderingFiber.lanes,
updateLane,
);
markSkippedUpdateLanes(updateLane);
} else {
// This update does have sufficient priority.
/*
저자)
앞서 설명한 baseQueue에 대해, 여기서는 newBaseQueue가 비어있지 않으면,
모든 이후 업데이트들은 나중에 사용하기 위해 반드시 임시 저장되어야 한다고 말합니다.
*/
if (newBaseQueueLast !== null) {
const clone: Update<S, A> = {
// This update is going to be committed so we never want uncommit
// it. Using NoLane works because 0 is a subset of all bitmasks, so
// this will never be skipped by the check above.
lane: NoLane,
action: update.action,
hasEagerState: update.hasEagerState,
eagerState: update.eagerState,
next: (null: any),
};
newBaseQueueLast = newBaseQueueLast.next = clone;
}
// Process this update.
if (update.hasEagerState) {
// If this update is a state update (not a reducer) and was processed eagerly,
// we can use the eagerly computed state
newState = ((update.eagerState: any): S);
} else {
const action = update.action;
newState = reducer(newState, action);
}
}
update = update.next;
} while (update !== null && update !== first);
if (newBaseQueueLast === null) {
newBaseState = newState;
} else {
newBaseQueueLast.next = (newBaseQueueFirst: any);
}
// Mark that the fiber performed work, but only if the new state is
// different from the current state.
if (!is(newState, hook.memoizedState)) {
// 저자) 이는 리렌더링 중 상태의 변화가 없으면 실제로 '탈출' 합니다. (조기 탈출 아님)
markWorkInProgressReceivedUpdate();
}
// 저자) 마침내, 새 상태가 set됩니다.
hook.memoizedState = newState;
hook.baseState = newBaseState;
// 저자) 새 baseQueue가 다음 리렌더를 위해 세팅됩니다.
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
if (baseQueue === null) {
// `queue.lanes` is used for entangling transitions. We can set it back to
// zero once the queue is empty.
queue.lanes = NoLanes;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
// 저자) 이제, 우리는 새 상태를 얻습니다! 그리고 dispatch()는 그대로구요.
return [hook.memoizedState, dispatch];
}
내부를 설명하는 간단한 슬라이드입니다.
(원본 글의 슬라이드 링크)
React 문서엔 주의사항 목록이 있습니다. 왜 이러한 주의사항이 존재하는지 이해해 봅시다.
set 함수는 다음 렌더링에 대한 state 변수만 업데이트합니다. set 함수를 호출한 후에도 state 변수에는 여전히 호출 전 화면에 있던 이전 값이 담겨 있습니다.
이는 이해하기 쉽습니다. 이미 setState()
가 다음 틱의 리렌더에 스케줄링하는 방식을 보았습니다. 이는 동기적이지 않으며, 상태 업데이트는 setState()
가 아닌 useState()
에서 이루어지기 떄문에 업데이트 된 값은 다음 렌더링에서만 얻을 수 있습니다.
setState()
를 해도 리렌더를 발생시킬 수 있습니다.사용자가 제공한 새로운 값이
Object.is
에 의해 현재 state와 동일하다고 판정되면, React는 컴포넌트와 그 자식들을 리렌더링하지 않습니다. 이것이 바로 최적화입니다. 경우에 따라 React가 자식을 건너뛰기 전에 컴포넌트를 호출해야 할 수도 있지만, 코드에 영향을 미치지는 않습니다.
이것은 가장 이상한 주의사항입니다. 아래 퀴즈를 시도해보세요. (console에 어떻게 찍힐까요?)
function A() {
console.log(2);
return null;
}
function App() {
const [_state, setState] = useState(false);
console.log(1);
return (
<>
<button
onClick={() => {
console.log("click");
setState(true);
}}
data-testid="action"
>
click me
</button>
<A />
</>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
const action = document.querySelector('[data-testid="action"]');
fireEvent.click(action);
fireEvent.click(action);
fireEvent.click(action);
솔직히 말해서, 같은 값을 set 하는데도 리렌더가 발생하는 이유를 찾는데 오래 걸렸습니다.
이를 이해하기 위해서는 우리가 깊이 들어가지 않았던 dispatchSetState()
내부의 빠른 탈출 조건으로 돌아가야 합니다.
// 저자) 이 분기에서 상태가 변경되지 않았다면, 리렌더 예약을 피하려고 시도합니다.
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
...
이전 슬라이드에서 설명했듯이, 훅의 대기 업데이트 큐와 baseQueue가 비어 있는지를 확인하는 것이 최고의 방법입니다. 그러나 현재 구현에서는 실제로 재렌더링을 시작하기 전까지는 이를 알 수 없습니다.
따라서 여기서는 더 단순한 검사로 대체되어 fiber 노드에 업데이트가 없는지 확인합니다. 업데이트가 큐에 들어가면 fiber가 더럽게 표시된다는 것을 이미 보았기 때문에, 재렌더링이 시작될 때까지 기다릴 필요는 없습니다.
하지만 여기에 부작용이 있습니다.
function enqueueUpdate(
fiber: Fiber,
queue: ConcurrentQueue | null,
update: ConcurrentUpdate | null,
lane: Lane,
) {
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
}
업데이트를 큐에 넣을 때, 현재와 대체 fibers 모두 lanes와 함께 더럽게 표시되는 것을 볼 수 있습니다. 이것은 필요합니다. 왜냐하면 dispatchSetState()
가 source fiber에 바인딩되어 있기 때문에, current와 alternate 모두를 업데이트하지 않으면 업데이트가 처리될 것이라고 확신할 수 없기 때문입니다.
current와 alternate 관련해서는 제 디버깅 비디오를 참고하세요.
그러나 lanes의 초기화는 실제 리렌더링이 발생하는 beginWork()
에서만 일어납니다.
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
// Before entering the begin phase, clear pending update priority.
// TODO: This assumes that we're about to evaluate the component and process
// the update queue. However, there's an exception: SimpleMemoComponent
// sometimes bails out later in the begin phase. This indicates that we should
// move this assignment out of the common path and into each branch.
workInProgress.lanes = NoLanes;
...
}
이로 인해 업데이트가 예약되면 더러운 lanes 플래그의 완전한 초기화는 최소 2회 이상의 재렌더링 이후에만 이루어지게 됩니다.
단계는 대략 다음과 같습니다.
setState(true)
→ true가 false와 다르기 때문에 초기 탈출(early bailout)은 발생하지 않습니다.setState(true)
→ fibers 중 하나가 깨끗하지 않기 때문에 초기 탈출은 여전히 발생하지 않습니다.setState(true)
→ 이번에는 두 fiber가 모두 깨끗하며 실제로 초기 탈출을 수행할 수 있습니다!이 문제를 해결할 수 있는 방법이 있을까요? 하지만 아마도 fiber 아키텍처와 훅의 동작 방식 때문에 비용이 많이 들 것입니다. 대부분의 경우에는 큰 문제가 되지 않기 때문에 React 팀은 이를 수정할 의도가 없다는 논의가 있었습니다.
React가 필요하다고 느끼면 재렌더링을 수행한다는 점을 염두에 두고, 성능 트릭이 항상 작동한다고 가정해서는 안 됩니다.
React는 state 업데이트를 batch 합니다. 모든 이벤트 핸들러가 실행되고
set
함수를 호출한 후에 화면을 업데이트합니다. 이렇게 하면 단일 이벤트 중에 여러 번 리렌더링 하는 것을 방지할 수 있습니다. 드물지만 DOM에 접근하기 위해 React가 화면을 더 일찍 업데이트하도록 강제해야 하는 경우,flushSync
를 사용할 수 있습니다.
앞서 설명한 슬라이드에서처럼, 업데이트는 실제로 처리되기 전에 임시로 저장되며, 그런 다음 함께 처리됩니다.