영문 블로그 글을 번역했습니다. 허가를 받으면 시리즈를 이어갈 예정입니다.
원문링크: https://jser.dev/2023-05-26-how-does-errorboundary-work/
ℹ️ React Internals Deep Dive 에피소드 6, 유튜브에서 제가 설명하는 것을 시청해주세요.
⚠ React@18.2.0 기준, 최신 버전에서는 구현이 변경되었을 수 있습니다.
💬 역자 주석: Jser의 코멘트는 ❗❗로 표시 해뒀습니다.
그 외 주석은 리액트 소스 코드 자체의 주석입니다.
... 은 생략된 코드입니다.
1. ErrorBoundary는 리액트 파이버 트리에 대한 선언적 try...catch 입니다.
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<Profile />
</ErrorBoundary>
Profile
이 렌더링 중 에러를 throw하면 ErrorBoundary는 fallback을 렌더링 하도록 시도하는데, React가 Fiber Tree를 순회하는 방법에서 설명한 대로, 파이버 노드의 렌더링 순서는 아래와 같습니다.
static getDerivedStateFromError(error)
를 구현한 모든 클래스 컴포넌트는 ErrorBoundary입니다.
2. ErrorBoundary는 내부적으로 어떻게 동작하나요?
2.1 에러가 발생하면 가장 가까운 ErrorBoundary에 플래그(ShouldCapture
)로 표시되고 state 업데이트가 예약됩니다.
function renderRootSync(root: FiberRoot, lanes: Lanes) {
...
do {
try {
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue); // ❗❗ handleError
}
} while (true);
...
return workInProgressRootExitStatus;
}
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
...
do {
try {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue); // ❗❗ handleError
}
} while (true);
...
return workInProgressRootExitStatus;
}
동기화(Sync) 모드 또는 동시(Concurrent) 모드의 경우 작업 루프에 큰 try...catch
가 적용되고 handleError()
가 throw된 값을 처리합니다.
ℹ 참고로 동기화 모드와 동시 모드의 차이점은 메인 스레드에 yield해야 하는지 여부를 지속적으로 확인하는
workLoopConcurrent()
에 있습니다. React 스케줄러의 작동 방식에 설명되어 있습니다.
function handleError(root, thrownValue): void {
do {
let erroredWork = workInProgress;
try {
...
throwException( // ❗❗ throwException
root,
erroredWork.return,
erroredWork,
thrownValue,
workInProgressRootRenderLanes,
);
completeUnitOfWork(erroredWork); // ❗❗ completeUnitOfWork
} catch (yetAnotherThrownValue) {
...
}
// Return to the normal work loop.
return;
} while (true);
}
handleError()
는 내부적으로 에러를 던지고 파이버 노드에서 작업을 완료하기 시작합니다. React는 파이버 트리를 어떻게 순회하나요? 에서 설명한 것처럼 에러가 발생하면 더 이상 자식으로 내려갈 필요가 없으므로 complete()
단계를 시작해야 합니다.
function throwException(
root: FiberRoot,
returnFiber: Fiber,
sourceFiber: Fiber,
value: mixed,
rootRenderLanes: Lanes,
): void {
// The source fiber did not complete.
sourceFiber.flags |= Incomplete;
// ❗❗ ↗ 여기 InComplete 플래그는 completeUnitOfWork()에게 중요합니다.
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
// ❗❗ 이 브랜치는 Suspense를 위한 것입니다.
...
} else {
// ❗❗ 이 브랜치는 일반적인 에러 바운더리를 위한 것입니다.
...
value = createCapturedValueAtFiber(value, sourceFiber);
renderDidError(value);
let workInProgress: Fiber = returnFiber;
do {
// ↖ 이 do...while 루프는 루트 경로를 따라서 가장 가까운 에러 바운더리를 찾습니다.
switch (workInProgress.tag) {
case HostRoot: {
...
}
case ClassComponent: // ❗❗ ClassComponent
// Capture and retry
const errorInfo = value;
const ctor = workInProgress.type;
const instance = workInProgress.stateNode;
if (
(workInProgress.flags & DidCapture) === NoFlags &&
(typeof ctor.getDerivedStateFromError === 'function' ||
// ❗❗ ↖ 우린 바운더리를 찾았습니다!
(instance !== null &&
typeof instance.componentDidCatch === 'function' &&
!isAlreadyFailedLegacyErrorBoundary(instance)))
) {
workInProgress.flags |= ShouldCapture;
// ↗ ❗❗ ShouldCapture 는 곧 DidCapture로 바뀔 예정입니다.
const lane = pickArbitraryLane(rootRenderLanes);
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
// Schedule the error boundary to re-render using updated state
const update = createClassErrorUpdate(
// ❗❗ ↗ 이 에러 바운더리는 지금 당장 처리되야 하므로 업데이트를 예약합니다.
workInProgress,
errorInfo,
lane,
);
enqueueCapturedUpdate(workInProgress, update); // ❗❗ enqueueCapturedUpdate(workInProgress, update);
return;
}
break;
default:
break;
}
// $FlowFixMe[incompatible-type] we bail out when we get a null
workInProgress = workInProgress.return;
} while (workInProgress !== null);
}
업데이트 내부에서 실제로 getDerivedStateFromError()
가 호출되는 곳입니다.
function createClassErrorUpdate(
fiber: Fiber,
errorInfo: CapturedValue<mixed>,
lane: Lane,
): Update<mixed> {
const update = createUpdate(lane);
update.tag = CaptureUpdate;
const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
if (typeof getDerivedStateFromError === 'function') {
const error = errorInfo.value;
update.payload = () => {
return getDerivedStateFromError(error); // ❗❗ getDerivedStateFromError
};
update.callback = () => {
logCapturedError(fiber, errorInfo);
};
}
그리고 enqueueCapturedUpdate()
는 업데이트를 오류가 발생한 파이버의 updateQueue
로 설정한 다음, mountClassInstance()
와 updateClassInstance()
내부의 processUpdateQueue()
를 통해 다음 렌더링에 처리되도록 합니다.
이는 Class Components에 대한 세부 사항으로 오래된 내용이므로 여기서는 자세히 다루지 않고, 이 이후에는 가장 가까운 에러 바운더리가 다음 렌더링에 오류를 반영하는 새로운 state를 갖게 된다는 점만 기억하세요.
2.2 언와인딩 중 ShouldCapture
플래그는 DidCapture
플래그로 바뀝니다.
React에서 컨텍스트는 어떻게 작동하나요? 에서 React 런타임은 경로를 따라 많은 정보를 저장하므로 각 파이버에 대한 정리 작업이 엉망이 되지 않도록 하는 것이 중요하다고 언급했습니다.
throwException()
에서 가장 가까운 ErrorBoundary를 찾았지만 아직 파이버 트리의 current 커서를 그 경계로 이동하지 않았습니다. 가장 가까운 에러 바운더리로 거슬러 올라가서 거기서부터 다시 렌더링을 시도하는 것이 언와인딩 과정입니다.
그리고 언와인딩은 이 글의 앞 부분에서 언급했던 completeUnitOfWork()
내부에서 이루어집니다.
ℹ complete() 단계의 작업에 대한 자세한 내용은 React는 파이버 트리를 어떻게 순회 할까요? 를 참고하세요.
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
// The current, flushed, state of this fiber is the alternate. Ideally
// nothing should rely on this, but relying on it here means that we don't
// need an additional field on the work in progress.
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// Check if the work completed or if something threw.
if ((completedWork.flags & Incomplete) === NoFlags) {
// ❗❗ ↗ 여기서 Incomplete 플래그가 중요한 이유를 확인하세요.
// ❗❗ 아래 브랜치는 일반 브랜치입니다.
let next;
if (
!enableProfilerTimer ||
(completedWork.mode & ProfileMode) === NoMode
) {
next = completeWork(current, completedWork, subtreeRenderLanes);
} else {
startProfilerTimer(completedWork);
next = completeWork(current, completedWork, subtreeRenderLanes);
// Update render duration assuming we didn't error.
stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
}
resetCurrentDebugFiberInDEV();
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
} else {
// ❗❗ ↖ 여기가 우리가 언와인드를 해야 할 Incomplete 브랜치입니다
// This fiber did not complete because something threw. Pop values off
// the stack without entering the complete phase. If this is a boundary,
// capture values if possible.
const next = unwindWork(current, completedWork, subtreeRenderLanes);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {
// ❗❗ ↗ 이 브랜치는 completeWork()가 파이버 노드를 반환하면,
// ❗❗ React가 부모에서 completeWork()를 계속하는 대신 해당 노드에서 리-렌더링하는 것을 보여줍니다.
// If completing this work spawned new work, do that next. We'll come
// back here again.
// Since we're restarting, remove anything that is not a host effect
// from the effect tag.
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
returnFiber.flags |= Incomplete;
// ❗❗ ↖ InComplete 파이버의 모든 조상 노드들은 모두 InComplete 합니다.
// ❗❗ 이렇게 하면 가장 가까운 ErrorBoundary가 IncComplete 인지 확인하여,
// ❗❗ 바운더리에 대한 unwindWork()가 호출됩니다.
returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
} else {
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
return;
}
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
// We've reached the root.
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
그리고 unwindWork()
에서는, DidCapture
플래그가 플레이됩니다.
function unwindWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// Note: This intentionally doesn't check if we're hydrating because comparing
// to the current tree provider fiber is just as fast and less error-prone.
// Ideally we would have a special version of the work loop only
// for hydration.
popTreeContext(workInProgress);
switch (workInProgress.tag) {
case ClassComponent: {
const Component = workInProgress.type;
if (isLegacyContextProvider(Component)) {
popLegacyContext(workInProgress);
}
const flags = workInProgress.flags;
if (flags & ShouldCapture) {
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
// ❗❗ ↖ ShouldCapture 가 DidCapture로 바뀌는 것을 보세요.
return workInProgress;
// ❗❗ 앞서 설명했듯이, 이 리턴은 가장 가까운 바운더리인 이 파이버에서
// ❗❗ 리-렌더링을 한다는 의미입니다.
}
return null;
}
...
}
}
2.3 DidCapture 플래그는 ErrorBoundary에서 새 자식으로 리-렌더링 하도록 강제합니다.
function finishClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
shouldUpdate: boolean,
hasContext: boolean,
renderLanes: Lanes,
) {
const didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags;
// ❗❗ ↖
...
const instance = workInProgress.stateNode;
// Rerender
ReactCurrentOwner.current = workInProgress;
let nextChildren;
if (
didCaptureError &&
typeof Component.getDerivedStateFromError !== 'function'
) {
// If we captured an error, but getDerivedStateFromError is not defined,
// unmount all the children. componentDidCatch will schedule an update to
// re-render a fallback. This is temporary until we migrate everyone to
// the new API.
// TODO: Warn in a future release.
nextChildren = null;
} else {
nextChildren = instance.render();
// ❗❗ 이 때, 에러가 throw되면 예약된 업데이트가 생성되기 때문에
// ❗❗ render()가 다른 자식을 반환합니다.
}
// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
if (current !== null && didCaptureError) {
// 오류에서 복구하는 경우 기존 자식을 재사용하지 않고 재조정(reconcile)합니다.
// 개념적으로 정상 자식과 오류로 표시되는 자식은 서로 다른 두 세트이므로
// 아이덴티티(identities)가 일치하더라도 일반 자식을 재사용해서는 안 됩니다.
forceUnmountCurrentAndReconcile(
// ❗❗ 이 이름과 위에 달린 주석들이 모든 것을 설명해줍니다.(💬 주석 번역함)
current,
workInProgress,
nextChildren,
renderLanes,
);
} else {
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
return workInProgress.child;
// ❗❗ ↗ 자식에게 더 깊이 들어가서 조정(reconciling)을 계속합니다.
}
코드는 매우 간단합니다.
3. 요약
이제 몇 가지 다이어그램을 통해 배운 내용을 요약해 보겠습니다.
💬 위 이미지를 클릭하세요
4. 코딩 챌린지
오늘 배운 내용을 강화하기 위해 다음 코딩 퀴즈를 풀어보세요.
💬 위 이미지를 클릭하세요
(원본 게시일: 2023-05-26)