이 시리즈는 "가장 쉬운 웹개발 with Boaz" 님의 [React 까보기 시리즈] 를 기반으로 만들어졌습니다.
scheduler가 WORK를 실행하기 위해서는 WORK를 언제 실행해야할지 우선순위를 판단해야 합니다. 우선순위 기준 값은 reconciler가 스케줄러에게 알려줘야 합니다. 이 때, 사용되는 값이 expirationTime
프로퍼티이며 fiber
객체 내부에 있습니다.
만약 단일 VDOM에 여러 업데이트가 발생하여 복수의 Work 스케줄링 요청이 들어온다고 생각해봅시다. 이때 요청이 들어온 Work와 이미 스케줄링된 Work 사이에 교통정리가 필요합니다.
fiber.expirationTime
값이 무엇일까요?
간단히 이야기 해서, 사용자가 이벤트를 발생시켰을 때의 시점값입니다.
좀 더 자세히 알아볼까요??
expirationTime
은 scheduler
와 reconciler
에서 사용되는데, 다른 의미로 사용됩니다.
scheduler
의 expirationTime
reconciler
의 expirationTime
reconciler
는 같은 expirationTime
에서 발생한 연속적인 이벤트는 하나의 이벤트로 간주하고 expirationTime
이 달라야지만 개별 이벤트로 판단합니다.그렇다면 expirationTime
은 어떻게 계산될까요??
이는 computeExpirationForFiber
함수를 통해 계산되어집니다.
export function computeExpirationForFiber(
currentTime: ExpirationTime,
fiber: Fiber,
suspenseConfig: null | SuspenseConfig,
): ExpirationTime {
// 계산 코드...
return expirationTime;
}
이 함수의 인자를 보면 currentTime
을 가지고 어떤 작업을 하고 있음을 알 수 있으므로 currentTime
과 관련된 함수를 찾아봅시다.
// ReactFiberWorkLoop.js
export function getCurrentTime() {
return msToExpirationTime(now());
}
//SchedulerWithReactIntegration.js
let initialTimeMs: number = Scheduler_now();
export const now =
initialTimeMs < 10000 ? Scheduler_now : () => Scheduler_now() - initialTimeMs;
// ReactFiberExpirationTime.js
import MAX_SIGNED_31_BIT_INT from './maxSigned31BitInt';
export const NoWork = 0;
export const Never = 1;
export const Idle = 2;
let ContinuousHydration = 3;
export const Sync = MAX_SIGNED_31_BIT_INT;
export const Batched = Sync - 1;
const UNIT_SIZE = 10;
const MAGIC_NUMBER_OFFSET = Batched - 1;
...
// 1 unit of expiration time represents 10ms.
export function msToExpirationTime(ms: number): ExpirationTime {
// Always add an offset so that we don't clash with the magic number for NoWork.
return MAGIC_NUMBER_OFFSET - ((ms / UNIT_SIZE) | 0);
}
Scheduler_now
함수의 경우, 구형 브라우저의 경우 Date.now()
, 최신 브라우저의 경우 performance.now()
라고 주석처리가 되어 있습니다.
즉, 많은 시간이 흐를 수록 now
가 반환하는 값은 커집니다.
msToExpirationTime
함수는 특정한 상수(MAGIC_NUMBER_OFFSET
)에서 (ms/10)|0
을 빼기 때문에 시간이 흐를수록 작아집니다.
그렇다면, 왜 OFFSET을 만들어서 처리를 할까요?
MAX_SIGNED_31_BIT_INT
값은 2의 32승입니다. 이것이 Sync
값인 것이구요. Sync
는 동기로 처리되어야 하는 이벤트를 위한 것입니다.
이론상으로 ms()
의 반환값은 0이 될 수 있습니다. 만약 OFFSET을 주지 않고, MAX_SIGNED_31_BIT_INT
에서 0을 그냥 빼게 된다면 이 이벤트가 Sync
인지 계산되어 반환된 ExpirationTime
인지 파악하기 어려워질 수 있습니다.
Sync
는 더 자세히 말하면 동기적으로 처리해야하는 이벤트를 말합니다. 즉,Concurrent Mode
가 아닌 Legacy Mode
에서 리액트가 Work를 스케줄링하는 과정에서 비교하는 ExpirationTime
이 동일한 경우를 표현한다고 보면 됩니다.
legacy mode 에서는 대부분의 경우,
expirationTime
이Sync
입니다.
Idle
은 유휴상태를 표현하는 값입니다. 추후에 ExpirationTime
을 Idle
와 같다면 Idle
상태인 것입니다.
reconciler
의 사전 작업ReactDOM.render() 호출을 통해 컴포넌트를 삽입하는 부모 태그가 root입니다. (<div id=root></div>
인 태그)
root 하나에 VDOM을 하나 갖고 있으며, 1:1 관계입니다. root는 VDOM을 대표할 수 있는 변하지 않는 객체입니다. 그래서 root에는 많은 정보가 기입되는데 그 중 하나가 스케줄정보입니다.
여러 개의 update
가 발생하여 여러 개의 WORK를 스케줄링해야할 때, 우선순위 판단을 위해 기준값이 필요하며 이 기준값을 root fiber에 넣습니다.
reconciler/ReactFiberWorkLoop.js
로 가보자
export const scheduleWork = scheduleUpdateOnFiber;
결국 우리가 찾아봐야할 함수는 scheduleUpdateOnFiber
임을 알 수 있습니다.
이 함수가 reconciler
에서 WORK를 scheduler
에 전달하는 입구 역할을 하는 함수입니다. (때문에 중요하죠)
export function scheduleUpdateOnFiber(
fiber: Fiber,
expirationTime: ExpirationTime,
) {
checkForNestedUpdates();
warnAboutInvalidUpdatesOnClassComponentsInDEV(fiber);
const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);
if (root === null) {
warnAboutUpdateOnUnmountedFiberInDEV(fiber);
return;
}
checkForInterruption(fiber, expirationTime);
recordScheduleUpdate();
// TODO: computeExpirationForFiber also reads the priority. Pass the
// priority as an argument to that function and this one.
const priorityLevel = getCurrentPriorityLevel();
if (expirationTime === Sync) {
if (
// Check if we're inside unbatchedUpdates
(executionContext & LegacyUnbatchedContext) !== NoContext &&
// Check if we're not already rendering
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
// Register pending interactions on the root to avoid losing traced interaction data.
schedulePendingInteractions(root, expirationTime);
// This is a legacy edge case. The initial mount of a ReactDOM.render-ed
// root inside of batchedUpdates should be synchronous, but layout updates
// should be deferred until the end of the batch.
performSyncWorkOnRoot(root);
} else {
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
if (executionContext === NoContext) {
// 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.
flushSyncCallbackQueue();
}
}
} else {
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
}
if (
(executionContext & DiscreteEventContext) !== NoContext &&
// Only updates at user-blocking priority or greater are considered
// discrete, even inside a discrete event.
(priorityLevel === UserBlockingPriority ||
priorityLevel === ImmediatePriority)
) {
// This is the result of a discrete event. Track the lowest priority
// discrete update per root so we can flush them early, if needed.
if (rootsWithPendingDiscreteUpdates === null) {
rootsWithPendingDiscreteUpdates = new Map([[root, expirationTime]]);
} else {
const lastDiscreteTime = rootsWithPendingDiscreteUpdates.get(root);
if (lastDiscreteTime === undefined || lastDiscreteTime > expirationTime) {
rootsWithPendingDiscreteUpdates.set(root, expirationTime);
}
}
}
}
코드를 천천히 분해해보겠습니다
const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);
markUpdateTimeFromFiberToRoot
함수를 통해 fiber(current)로부터 얻은 updateTime을 root에 마킹을 함을 알 수 있습니다.
잠깐 딴 길로 새서, markUpdateTimeFromFiberToRoot
함수를 봅시다.
function markUpdateTimeFromFiberToRoot(fiber, expirationTime) {
// Update the source fiber's expiration time
if (fiber.expirationTime < expirationTime) {
fiber.expirationTime = expirationTime;
}
let alternate = fiber.alternate;
if (alternate !== null && alternate.expirationTime < expirationTime) {
alternate.expirationTime = expirationTime;
}
// Walk the parent path to the root and update the child expiration time.
let node = fiber.return;
let root = null;
if (node === null && fiber.tag === HostRoot) {
root = fiber.stateNode;
} else {
while (node !== null) {
alternate = node.alternate;
if (node.childExpirationTime < expirationTime) {
node.childExpirationTime = expirationTime;
if (
alternate !== null &&
alternate.childExpirationTime < expirationTime
) {
alternate.childExpirationTime = expirationTime;
}
} else if (
alternate !== null &&
alternate.childExpirationTime < expirationTime
) {
alternate.childExpirationTime = expirationTime;
}
if (node.return === null && node.tag === HostRoot) {
root = node.stateNode;
break;
}
node = node.return;
}
}
if (root !== null) {
if (workInProgressRoot === root) {
// Received an update to a tree that's in the middle of rendering. Mark
// that's unprocessed work on this root.
markUnprocessedUpdateTime(expirationTime);
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: This happens to work when receiving an update during the render
// phase, because of the trick inside computeExpirationForFiber to
// subtract 1 from `renderExpirationTime` to move it into a
// separate bucket. But we should probably model it with an exception,
// using the same mechanism we use to force hydration of a subtree.
// TODO: This does not account for low pri updates that were already
// scheduled before the root started rendering. Need to track the next
// pending expiration time (perhaps by backtracking the return path) and
// then trigger a restart in the `renderDidSuspendDelayIfPossible` path.
markRootSuspendedAtTime(root, renderExpirationTime);
}
}
// Mark that the root has a pending update.
markRootUpdatedAtTime(root, expirationTime);
}
return root;
}
이 함수도 천천히 살펴보도록 해보죠.
if (fiber.expirationTime < expirationTime) {
fiber.expirationTime = expirationTime;
}
let alternate = fiber.alternate;
if (alternate !== null && alternate.expirationTime < expirationTime) {
alternate.expirationTime = expirationTime;
}
fiber
의 expirationTime
보다 인자로 받은 expirationTime
이 더 클 경우 재할당 해줍니다.
expirationTime
이 클수록 먼저 처리해야하는 업데이트(먼저 발생한 업데이트)입니다.
current와 alternate 모두 처리해주는 이유는 fiber
가 current
인지 workInProgress
인지 정확히 알 수 없기 때문에 (이는 렌더링 과정에서 current
변경 및 자기복제가 이뤄지기 때문) 서로 참조하는 alternate
프로퍼티를 통해 둘 다 넣어줍니다
// Walk the parent path to the root and update the child expiration time.
let node = fiber.return;
let root = null;
if (node === null && fiber.tag === HostRoot) {
root = fiber.stateNode;
} else {
while (node !== null) {
alternate = node.alternate;
if (node.childExpirationTime < expirationTime) {
node.childExpirationTime = expirationTime;
if (
alternate !== null &&
alternate.childExpirationTime < expirationTime
) {
alternate.childExpirationTime = expirationTime;
}
} else if (
alternate !== null &&
alternate.childExpirationTime < expirationTime
) {
alternate.childExpirationTime = expirationTime;
}
if (node.return === null && node.tag === HostRoot) {
root = node.stateNode;
break;
}
node = node.return;
}
}
return
을 통해서 fiber
의 부모 노드를 가져와서 node
변수에 할당합니다. 그 이후, root
엔 fiber
의 return
프로퍼티가 null인 fiber
가 할당됩니다.
반복문의 코드를 보시면 root가 나올때 까지 자식에서 부모로 순회하면서 부모의 childExpirationTime
에 expirationTime
을 할당합니다.
이 값이 VDOM Reconciliation을 확인할 때, 어떤 것을 Reconciliation해야 하는지를 결정하는 매우 중요한 값입니다. (자손에서 이벤트가 발생했음을 확인)
이 값은 특히 React 18에서 Concurrent Mode를 쓸 때 어떤 값을 우선적으로 업데이트할지를 결정하는데는 중요합니다.
이후 root
를 반환합니다
Sync
)다시 scheduleUpdateOnFiber
함수 코드로 돌아가서 실행이 된다고 가정해봅시다. 이전까지 fiber의 모든 조상 노드의 childExpirationTime
속성에 expirationTime
값을 할당하였었죠.
그 다음으로 넘어가봅시다!
// 주석은 풀버전 코드를 위에 명시해두었으니 그곳에서 보시면 됩니다!
if (expirationTime === Sync) { // 동기적으로 수행되는 코드
if ( // VDOM이 최초로 생성되었을 때,
(executionContext & LegacyUnbatchedContext) !== NoContext &&
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
schedulePendingInteractions(root, expirationTime);
performSyncWorkOnRoot(root);
} else { // VDOM에 WORK를 하는 경우
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
} else { // 비동기적으로 수행되는 코드
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
}
이 코드를 분석해야 합니다.
먼저 expirationTime
이 Sync
인지 여부를 판단합니다. Legacy Mode에서는 대부분 Sync
입니다. 즉 WORK가 동기적으로 호출되어야 함을 의미합니다.
먼저, 동기적으로 동작되어야 하는 WORK를 살펴봅시다.
여기도 분기처리되어 있습니다.
if (
// Check if we're inside unbatchedUpdates
(executionContext & LegacyUnbatchedContext) !== NoContext &&
// Check if we're not already rendering
(executionContext & (RenderContext | CommitContext)) === NoContext
) {
schedulePendingInteractions(root, expirationTime);
performSyncWorkOnRoot(root);
} else {
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}
}
executionContext
, RenderContext
, CommitContext
을 가지고 비교를 하네요.
executionContext
NoContext
가 아닙니다.NoContext
입니다.RenderContext
Render Phase
시에는 RenderContext
가 CommitContext
가 아님CommitContext
Commit Phase
시에는 CommitContext
가 CommitContext
가 아님즉, 조건을 보면 VDOM이 최초로 생성되는 시기인지를 판단하는 것입니다. 그 때만이 Render Phase와 Commit Phase가 모두 실행되지 않은 시점입니다.
VDOM이 최초로 생성될 때는 performSyncWorkOnRoot(root)
Sync Work를 root에서 수행해줍니다. React-DOM에 대한 최초의 마운트가 될 때 이 부분이 실행됩니다.
else
부분은 VDOM이 이미 생성되고 난 뒤에 업데이트를 시작했을 때 실행되는 코드입니다. (대부분 여기서 작업이 되겠죠)
ensureRootIsScheduled(root)
는 Root가 스케줄되어있는지를 보장하는 함수입니다. 이 함수는 비동기인 경우에도 수행되는 함수이므로 뒤에서 알아보도록 하겠습니다.
그리고 내부 조건문을 또 보면 executionContext
가 NoContext
인 경우(Reconciler
가 어떠한 Work
도 진행하고 있지 않은 경우), flushSyncCallbackQueue
함수를 호출합니다.
flushSyncCallbackQueue
함수는 SyncCallbackQueue
를 비워주는(flush) 함수입니다.
reconciler
가 어떠한 WORK
도 작업 중이지 않은 상태이기 때문에 SyncCallback
을 비워주는 것입니다.
비동기 처리 로직은 아래와 같습니다.
else { // 비동기적으로 수행되는 코드
ensureRootIsScheduled(root);
schedulePendingInteractions(root, expirationTime);
}
먼저 동기와 비동기 모두 수행되는 ensureRootIsScheduled(root)
에 대해 알아볼 차례입니다. 리액트가 처리중인 WORK 의 우선순위를 비교하는 방법입니다.
함수는 아래와 같이 생겼습니다.
function ensureRootIsScheduled(root: FiberRoot) {
const lastExpiredTime = root.lastExpiredTime;
if (lastExpiredTime !== NoWork) {
// Special case: Expired work should flush synchronously.
root.callbackExpirationTime = Sync;
root.callbackPriority = ImmediatePriority;
root.callbackNode = scheduleSyncCallback(
performSyncWorkOnRoot.bind(null, root),
);
return;
}
const expirationTime = getNextRootExpirationTimeToWorkOn(root);
const existingCallbackNode = root.callbackNode;
if (expirationTime === NoWork) {
// There's nothing to work on.
if (existingCallbackNode !== null) {
root.callbackNode = null;
root.callbackExpirationTime = NoWork;
root.callbackPriority = NoPriority;
}
return;
}
// TODO: If this is an update, we already read the current time. Pass the
// time as an argument.
const currentTime = requestCurrentTimeForUpdate();
const priorityLevel = inferPriorityFromExpirationTime(
currentTime,
expirationTime,
);
// If there's an existing render task, confirm it has the correct priority and
// expiration time. Otherwise, we'll cancel it and schedule a new one.
if (existingCallbackNode !== null) {
const existingCallbackPriority = root.callbackPriority;
const existingCallbackExpirationTime = root.callbackExpirationTime;
if (
// Callback must have the exact same expiration time.
existingCallbackExpirationTime === expirationTime &&
// Callback must have greater or equal priority.
existingCallbackPriority >= priorityLevel
) {
// Existing callback is sufficient.
return;
}
// Need to schedule a new task.
// TODO: Instead of scheduling a new task, we should be able to change the
// priority of the existing one.
cancelCallback(existingCallbackNode);
}
root.callbackExpirationTime = expirationTime;
root.callbackPriority = priorityLevel;
let callbackNode;
if (expirationTime === Sync) {
// Sync React callbacks are scheduled on a special internal queue
callbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
} else if (disableSchedulerTimeoutBasedOnReactExpirationTime) {
callbackNode = scheduleCallback(
priorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
} else {
callbackNode = scheduleCallback(
priorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
// Compute a task timeout based on the expiration time. This also affects
// ordering because tasks are processed in timeout order.
{timeout: expirationTimeToMs(expirationTime) - now()},
);
}
root.callbackNode = callbackNode;
}
ensureRootIsScheduled(root)
은 아래의 작업을 수행합니다.
root
에서 필요한 정보들을 가져와서 새로운 WORK의 우선순위 얻기root
당 task(처리 중인 WORK)는 1개입니다.scheduleCallback
(schedule과 관련된 함수)를 호출하여 WORK를 scheduler에 전달합니다필요한 정보란 expirationTime
, currentTime
이며 이를 사용하여 inferPriorityExpirationTime
함수를 실행합니다.
const existingCallbackNode = root.callbackNode // 스케줄링되어 있는 Task 객체
const expirationTime = getNextRootExpirationTimeToWorkOn(root)
const currentTime = requestCurrentTimeForUpdate()
const priorityLevel = inferPriorityFromExpirationTime(
currentTime,
expirationTime
)
getNextRootExpirationTimeToWorkOn()
은 root가 가지고 있는 정보들을 기반으로(만료된 작업이 남아있음을 나타내는 lastExpiredTime
, suspense
와 관련된 PendingTime…) 현재 처리해야 할 expirationTime
을 가지고 옵니다.
currentTime
은 msToExpirationTime()
함수를 통해 얻습니다.
inferPriorityFromExpirationTime
함수는 아래와 같습니다.
const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150
const HIGH_PRIORITY_BATCH_SIZE = 100
const LOW_PRIORITY_EXPIRATION = 5000
const LOW_PRIORITY_BATCH_SIZE = 250
function inferPriorityFromExpirationTime(
currentTime: ExpirationTime,
expirationTime: ExpirationTime
): ReactPriorityLevel {
if (expirationTime === Sync) {
return ImmediatePriority
}
if (expirationTime === Never || expirationTime === Idle) {
return IdlePriority
}
const msUntil =
expirationTimeToMs(expirationTime) - expirationTimeToMs(currentTime)
if (msUntil <= 0) {
return ImmediatePriority
}
if (msUntil <= HIGH_PRIORITY_EXPIRATION + HIGH_PRIORITY_BATCH_SIZE) {
return UserBlockingPriority
}
if (msUntil <= LOW_PRIORITY_EXPIRATION + LOW_PRIORITY_BATCH_SIZE) {
return NormalPriority
}
return IdlePriority
}
expirationTime
이
ImmediatePriority
(가장 높은 우선순위)를 주게 됩니다.IdlePriority
(낮은 우선 순위)를 주게 됩니다. expirationTimeToMS
함수로부터 ms를 구한 시간으로, ms
는 큰 수가 이후 시점을 나타냅니다.
msUntil
은 만료시간 - 현재시간이 되고, msUntil
이 0이하인 경우는 이미 만료되었으므로 빠르게 처리해주기 위해 ImmediatePriority
를 반환합니다.
Priority가 높은 순서부터 나타내면
Immediate
> UserBlocking
> Normal
> Idle
입니다
const existingCallbackNode = root.callbackNode;
if (existingCallbackNode !== null) {
const existingCallbackPriority = root.callbackPriority;
const existingCallbackExpirationTime = root.callbackExpirationTime;
if (
// Callback must have the exact same expiration time.
existingCallbackExpirationTime === expirationTime &&
// Callback must have greater or equal priority.
existingCallbackPriority >= priorityLevel
) {
// Existing callback is sufficient.
return;
}
// Need to schedule a new task.
// TODO: Instead of scheduling a new task, we should be able to change the
// priority of the existing one.
cancelCallback(existingCallbackNode);
}
root.callbackExpirationTime = expirationTime;
root.callbackPriority = priorityLevel;
callbackNode
(Existing Work)가 있으면 우선순위를 비교합니다
만약 기존 것이 우선순위가 높다면 현재 함수는 종료를 시킵니다.
새로운 것이 우선순위가 높다면 기존 것에 cancelCallback
을 실행해줍니다. 기존 WORK를 취소한
이후에 root.callbackExpirationTime
에 새로운 WORK의 expirationTime
를 새깁니다. root.callbackPriority
에는 새로운 WORK의 priorityLevel
을 새깁니다.
let callbackNode;
if (expirationTime === Sync) {
// Sync React callbacks are scheduled on a special internal queue
callbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
} else if (disableSchedulerTimeoutBasedOnReactExpirationTime) {
callbackNode = scheduleCallback(
priorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
} else {
callbackNode = scheduleCallback(
priorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
// Compute a task timeout based on the expiration time. This also affects
// ordering because tasks are processed in timeout order.
{timeout: expirationTimeToMs(expirationTime) - now()},
);
}
root.callbackNode = callbackNode;
이 코드를 보면 root.callbackNode
는 scheduleCallback
또는 scheduleSyncCallback
(expiration이 Sync인 경우)의 반환값임을 알 수 있습니다.
이제 scheduleCallback
함수와 scheduleSyncCallback
함수를 살펴보도록 합시다.
먼저
fiber
아키텍처를 다시 생각해봅시다.
16버전 이전의stack
아키텍처는 WORK의 우선순위를 조정할 수 없었습니다.Fiber
아키텍처의 도입으로 인해 수행중이던 WORK를 취소하는 등의 WORK의 우선순위를 변경할 수 있게 되었습니다.
scheduleSyncCallback
은 WORK의 우선순위를 변경할 필요가 없습니다. 그러나 우선순위 조정이 가능해진 Fiber
아키텍처의 도입한 이후에는 Sync
이외의 다른 개념들이 필요해졌습니다. 그것이 바로 Concurrent
(동시성)입니다.
먼저 scheduleCallback
함수를 호출하는 코드를 봅시다.
callbackNode = scheduleCallback(
priorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
인자로 priorityLevel
과 performConcurrentWorkOnRoot
를 root
에 bind한 함수를 반환합니다.
performConcurrentWorkOnRoot
함수 이름에서 알 수 있듯이 root
에 concurrent
작업을 수행하는 함수입니다.그 다음으로 scheduleCallback
함수를 보시죠.
export function scheduleCallback(
reactPriorityLevel: ReactPriorityLevel,
callback: SchedulerCallback,
options: SchedulerCallbackOptions | void | null,
) {
const priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel);
return Scheduler_scheduleCallback(priorityLevel, callback, options);
}
reconciler
가 concurrent WORK
를 scheduler
에게 우선순위와 함께 전달하며, scheduler
에서 우선순위를 알아서 정해달라고 위임을 한 것입니다.
이 때, 사용했던 함수가 Scheduler_scheduleCallback
함수입니다.
즉, 함수를 호출함으로써 reconciler
는 자신이 갖고 있는 WORK 수행 우선순위에 대한 책임을 scheduler
에게 넘기는 것입니다.
이제 callback에 해당하는 Concurrent Work는 scheduler가 알아서 실행하게 되며 reconciler는 이제 신경쓰지 않습니다.
options에는 Task의 만료 시간을 나타내는 timeout과 시작시간을 미룰 수 있는 delay 속성이 존재합니다.
다음으로 scheduleSyncCallback
함수를 호출하는 코드를 봅시다.
callbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
인자로 performSyncWorkOnRoot
함수를 root
에 bind 시킨 함수를 넘겨줍니다.
root
에 sync WORK를 수행하는 함수입니다.export function scheduleSyncCallback(callback: SchedulerCallback) {
// Push this callback into an internal queue. We'll flush these either in
// the next tick, or earlier if something calls `flushSyncCallbackQueue`.
if (syncQueue === null) {
syncQueue = [callback];
// Flush the queue in the next tick, at the earliest.
immediateQueueCallbackNode = Scheduler_scheduleCallback(
Scheduler_ImmediatePriority,
flushSyncCallbackQueueImpl,
);
} else {
// Push onto existing queue. Don't need to schedule a callback because
// we already scheduled one when we created the queue.
syncQueue.push(callback);
}
return fakeCallbackNode;
}
콜백을 받아서 콜백들을 담아두는 큐(syncQueue
)에 넣습니다.
왜냐하면 sync
WORK는 우선순위를 조정하는 작업이 필요없기 때문에, scheduler
에 위임하지 않아도 됩니다. reconciler
가 실행하기 적절한 때를 판단해서 실행하기 위해 syncQueue를 사용하게 되는 것이죠.
async WORK
와 달리 callback
을 scheduler
에게 넘기는 것이 아닌 내부 큐에 넣습니다. 대신 이 큐를 소비하는 flushSyncCallbackQueueImpl()
를 스케줄링해줍니다. 그
이 때, scheduler
에게 flushSyncCallbackQueueImpl
을 위임하게 됩니다.
root
에 작동되는 WORK를 실행하는 코드를 스케줄러에게 위임하는 것이 아닌 SyncCallbackQueue
를 비워버리는(flush) 구현체를 위임하는 것입니다
즉, SyncCallbackQueue
를 언제 비울지를 물어보는 것입니다. (우선순위를 물어보지 않음)
이미 syncQueue
가 존재하면 큐에 콜백을 추가하게 됩니다.
그리고
scheduleWork()
에서 사용한flushSyncCallbackQueue
안에서 내부적으로flushSyncCallbackQueueImpl
를 사용하고 있으며,flushSyncCallbackQueue
함수는 아래와 같습니다.export function flushSyncCallbackQueue() { if (immediateQueueCallbackNode !== null) { const node = immediateQueueCallbackNode; immediateQueueCallbackNode = null; Scheduler_cancelCallback(node); } flushSyncCallbackQueueImpl(); }
그리고,
scheduleWork()
에서flushSyncCallbackQueue
를 호출한 코드는 다음과 같습니다.if (executionContext === NoContext) { // 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. flushSyncCallbackQueue(); }
만약 리액트가 idle 상태라
scheduleWork
에서flushSyncCallbackQueue
함수를 호출했다면 반드시 스케줄링된flushSyncCallbackQueueImpl
를 취소시켜 준 다음에 실행해야 합니다.
마지막으로 flushSyncCallbackQueueImpl
를 확인하면 reconciler가 수행해주는 코드를 이해할 수 있게 됩니다.
function flushSyncCallbackQueueImpl() {
if (!isFlushingSyncQueue && syncQueue !== null) {
// 중복 실행 방지 플래그
isFlushingSyncQueue = true
let i = 0
try {
const isSync = true
const queue = syncQueue
runWithPriority(ImmediatePriority, () => {
for (; i < queue.length; i++) {
let callback = queue[i]
do {
callback = callback(isSync) // performSyncWorkOnRoot()
} while (callback !== null)
}
})
syncQueue = null
} catch (error) {
// 에러가 발생한 callback만 버린다.
if (syncQueue !== null) {
syncQueue = syncQueue.slice(i + 1)
}
// Resume flushing in the next tick
Scheduler_scheduleCallback(
Scheduler_ImmediatePriority,
flushSyncCallbackQueue
)
throw error
} finally {
isFlushingSyncQueue = false
}
}
}
syncQueue
가 비워질 때까지 계속 WORK(performSyncWorkOnRoot
) 실행을 해주는 코드입니다. 이 때, runWithPriority
함수는 scheduler에게 콜백 함수의 우선순위를 알려주고 실행을 요청하는 함수입니다. 해당 우선순위는 scheduler
의 컨텍스트 변수인 currentPriorityLevel
에 저장됩니다.
만약 중간에 에러가 발생하게 된다면 flushSyncCallbackQueue
를 통해 syncQueue
를 비워줍니다.
이제 reconciler는 현재 진행되는 Work와 관련된 추가 작업이 필요할 때 scheduler의 컨텍스트 우선순위만 참고하면 됩니다. Work는 스케줄링된 순서대로 실행되는 것이 아니고 언제든지 중지되고 재실행 될 수 있기 때문에 재조정 작업만 할 줄 아는 reconciler는 더욱이 이 작업들의 우선순위를 가지고 있을 수 없습니다.