React - useState 자세히 알아보기(2)
어쩌다 2편이..?
내가 간과한 사실이 있었다. 나는 리액트 18버전으로 동작 원리를 분석하고 있었다. 물론 리액트 버전 18과 19의 차이에서 useState의 변화가 있거나 핵심 원리가 변경된 점은 없다. 하지만 코드를 보니 약간의 차이가 있어서 리액트 버전 19로 정리를 해보겠다.
추가적으로 dispatch관련해서 너무 대략적으로 적었나 싶은 생각이 든다. 조금더 동작 순서와 각 로직의 의미를 정리해보고자 한다.
변경된 사항
위에서 얘기했듯이 동작 원리가 바뀐건 없다. 대충 훑어봤을때 로직이 분리된 점에서 조금 차이가 있어 다시 개념 정리를 할겸, 변경된 로직을 알아볼겸 겸사겸사 진행해보겠다.
hook을 가져오는 과정
큰 변화는 없다. 기존에 적용되던
- reconciler -> shared/ReactSharedInternal -> react/ReactSharedInternal -> react/ReactCurrentDispatcher -> react/ReactHooks -> react -> 개발자
이 순서와 동일하다. 약간의 파일 이름과 변수가 변경된 점만 다뤄보겠다.
function resolveDispatcher() { const dispatcher = ReactSharedInternals.H; return ((dispatcher: any): Dispatcher); } ... export function useState<S>( initialState: (() => S) | S, ): [S, Dispatch<BasicStateAction<S>>] { const dispatcher = resolveDispatcher(); return dispatcher.useState(initialState); }
전 포스팅에서는 ReactSharedInternals.current를 가져와서 사용하는 것으로 되어있었는데 지금은 약간 바뀌었다. 하지만 ReactSharedInternals를 확인해보면
export type SharedStateClient = { H: null | Dispatcher, // ReactCurrentDispatcher for Hooks A: null | AsyncDispatcher, // ReactCurrentCache for Cache T: null | BatchConfigTransition, // ReactCurrentBatchConfig for Transitions S: null | ((BatchConfigTransition, mixed) => void), // onStartTransitionFinish } ... const ReactSharedInternals: SharedStateClient = ({ H: null, A: null, T: null, S: null, }: any);
이렇게 약간씩 바뀌었다. 아마 전 포스팅에서 이 부분을 다룰때는 더 이전의 정보를 가지고 학습하고 정리하면서 새롭게 추가된 기능들에 대한 변경점이 적용되지 않았다. 우선 변경된 이유는 리액트의 기능 확장때문이다. 리액트는 업데이트를 하면서 caching과 transition과 같은 비동기 동작의 기능을 확장시켜갔다. 그렇다보니 기존 객체에서는 hook과 업데이트 관련된 값들만 있었다면 이후에 필요에 의해 여러 값들이 추가된 것이다. 아무튼 우리가 보던 useState에서 사용하는 ReactSharedInternals.current와 동일하다.
hook을 주입하는 과정
주입하는 과정도 동일하다. reconciler를 통해 hook 로직을 주입하고, 렌더링하면서 Fiber여부에 따라 마운트용과 업데이트용 hook을 주입하게 된다.
export function renderWithHooks<Props, SecondArg>( current: Fiber | null, workInProgress: Fiber, Component: (p: Props, arg: SecondArg) => any, props: Props, secondArg: SecondArg, nextRenderLanes: Lanes, ): any { renderLanes = nextRenderLanes; currentlyRenderingFiber = workInProgress; workInProgress.memoizedState = null; workInProgress.updateQueue = null; workInProgress.lanes = NoLanes; ReactSharedInternals.H = current === null || current.memoizedState === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate; ...
여기에서 추가적으로 설명을 하자면 lanes은 업데이트 우선순위를 정하는 것이다. 리액트 16버전부터 Fiber 아키텍쳐가 적용되면서 우선순위를 정하고 업데이트한다. 이후에 업데이트되면서 Fiber의 성능이 향상되고 안정적으로 구현되면서 lanes라는 우선순위 값이 생긴 것이다.
그럼 현재 lanes방식 이전에는 어떻게 우선순위를 정했을까?
const UNIT_SIZE = 10 // 1 unit of expiration time represents 10ms. export function msToExpirationTime(ms: number): ExpirationTime { return MAGIC_NUMBER_OFFSET - ((ms / UNIT_SIZE) | 0) } export function expirationTimeToMs(expirationTime: ExpirationTime): number { return (MAGIC_NUMBER_OFFSET - expirationTime) * UNIT_SIZE }
expirationTime라는 값을 업데이트가 생성될때 같이 생성한다. 업데이트를 관리하는 scheduler에서는 만료 시간을 나타내고, reconciler에서는 이벤트를 구분하는 기준으로 쓰인다. 하지만 업데이트가 일어난 시점을 가지고 구분하다보니, 동시간에 여러개의 업데이트에 대해서 우선순위를 조정하기 어렵고 한계를 가지고 있다. 그래서 도입된 것이 lanes 이라고 생각하면 된다.
function mountState<S>( initialState: (() => S) | S, ): [S, Dispatch<BasicStateAction<S>>] { const hook = mountStateImpl(initialState); const queue = hook.queue; const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind( null, currentlyRenderingFiber, queue, )); queue.dispatch = dispatch; return [hook.memoizedState, dispatch]; }
마운트 로직은 크게 바뀐것이 없다. 기존에 있던 hook을 설정하는 로직들이 mountStateImpl라는 함수로 분리된 것 뿐이다.
function mountStateImpl<S>(initialState: (() => S) | S): Hook { const hook = mountWorkInProgressHook(); if (typeof initialState === 'function') { const initialStateInitializer = initialState; // $FlowFixMe[incompatible-use]: Flow doesn't like mixed types initialState = initialStateInitializer(); } hook.memoizedState = hook.baseState = initialState; const queue: UpdateQueue<S, BasicStateAction<S>> = { pending: null, lanes: NoLanes, dispatch: null, lastRenderedReducer: basicStateReducer, lastRenderedState: (initialState), }; hook.queue = queue; return hook; }
저번에 dispatch를 정리할때 제대로 정리를 못했던것 같아서 추가적으로 정리해보겠다. 어떻게 동작하고 Fiber를 고정하고 있는지 알아보겠다.
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind( null, currentlyRenderingFiber, queue, ));
우선 dispatch, 즉 setState는 기존 Fiber에 저장된 state 값을 가져와 업데이트하는 역할이다. 그러므로 현재 작업중인 Fiber를 저장할 필요가 있다. 그래서 그 값을 저장하기 위해 사용하는 것이 bind이다. bind는 this를 적용할 객체를 직접 바인딩하기 위한 요소이지만, 첫번째 값만 바인딩할 객체이고 그 외의 값들은 고정된 값으로 사용이 가능하다.
function dispatchSetState<S, A>( fiber: Fiber, queue: UpdateQueue<S, A>, action: A, ): void { ...
그래서 dispatchSetState를 보면 action이라는 값을 받지만, 실제로 마운트 시점에서는 아무런 action을 주지 않는다. 여기에서 action은 실제로 setState가 실행될때 들어가는 것이다.
setState(newState) = dispatchSetState(currentlyRenderingFiber, queue, newState);
그래서 setState를 하면 이런 식으로 함수가 실행된다고 보면 된다.
function dispatchSetState<S, A>( fiber: Fiber, queue: UpdateQueue<S, A>, action: A, ): void { const lane = requestUpdateLane(fiber); const didScheduleUpdate = dispatchSetStateInternal( fiber, queue, action, lane, ); if (didScheduleUpdate) { startUpdateTimerByLane(lane); } }
이제 dispatchSetState전체를 보겠다. 전에는 update객체를 직접 생성해줬지만 이제는 dispatchSetStateInternal이라는 함수로 분리해서 생성해준다.
function dispatchSetStateInternal<S, A>( fiber: Fiber, queue: UpdateQueue<S, A>, action: A, lane: Lane, ): boolean { const update: Update<S, A> = { lane, revertLane: NoLane, action, hasEagerState: false, eagerState: null, next: null, }; if (isRenderPhaseUpdate(fiber)) { enqueueRenderPhaseUpdate(queue, update); } else { const alternate = fiber.alternate; if ( fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes) ) { const lastRenderedReducer = queue.lastRenderedReducer; if (lastRenderedReducer !== null) { let prevDispatcher = null; try { const currentState: S = (queue.lastRenderedState); const eagerState = lastRenderedReducer(currentState, action); update.hasEagerState = true; update.eagerState = eagerState; if (is(eagerState, currentState)) { enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update); return false; } } catch (error) { // Suppress the error. It will throw again in the render phase. } finally { if (__DEV__) { ReactSharedInternals.H = prevDispatcher; } } } } const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, fiber, lane); entangleTransitionUpdate(root, queue, lane); return true; } } return false; }
로직 자체는 기존과 동일하다. 우선 isRenderPhaseUpdate를 통해 현재 렌더링중에 업데이트가 이뤄졌는지 확인하고 로직을 처리한다. 유휴상태에서 업데이트가 이뤄졌다면 현재 Fiber가 업데이트 대기중인지 lanes값을 통해 확인하고, 변경 전 Fiber에도 동일하게 대기중인 업데이트가 없는지 확인하는 조건을 거친다.
이렇게 아무런 업데이트가 예정되어 있지 않다면 업데이트를 한다. 우선적으로 현재 상태와 lastRenderedReducer 를 통해 얻은 사전 계산 상태값을 활용해 두 값에 차이가 있는지 확인하고, 동일하다면 업데이트를 진행하지 않는다.
사전 계산 결과, 값이 다르다는 것을 확인하면 스케쥴러에 등록하고 순서에 맞게 리렌더링을 실행한다. 그리고 이 로직의 결과는 boolean값으로 나온다.
if (didScheduleUpdate) { startUpdateTimerByLane(lane); }
업데이트가 정상적으로 잘 이뤄져서 true라면 리렌더링 최적화를 위한 lanes timer를 설정해준다.
해당 부분 PR : React GitHub
마무리
처음 톺아보기할때 참고한 블로그는 리액트 16버전이었다. 그래서 지금과 다소 다른 부분이 존재하는 로직들이 많았기에 정리하는 과정에서 혼선을 겪었다. 그러다보니 미리 정리하지 않으면 나중에 더 복잡해질것 같다는 생각에 우선 정리를 한번 더했다. 한번 더 찾아보고 비교하면서 어떤 부분이 어떻게 달라졌는지 명확하게 알게 된것 같다.
다음에는 진짜 업데이트와 관련된 로직을 다뤄보겠다.
개의 댓글
1
어쩌다 2편이..?
변경된 사항
hook을 가져오는 과정
hook을 주입하는 과정
마무리