React 내부 동작원리를 알아보자(7) - scheduleWork는 어떤 동작을 수행해줄까?

방구석 코딩쟁이·2024년 1월 25일
0

이 시리즈는 "가장 쉬운 웹개발 with Boaz" 님의 [React 까보기 시리즈] 를 기반으로 만들어졌습니다.

scheduler가 WORK를 실행하기 위해서는 WORK를 언제 실행해야할지 우선순위를 판단해야 합니다. 우선순위 기준 값은 reconciler가 스케줄러에게 알려줘야 합니다. 이 때, 사용되는 값이 expirationTime 프로퍼티이며 fiber 객체 내부에 있습니다.

만약 단일 VDOM에 여러 업데이트가 발생하여 복수의 Work 스케줄링 요청이 들어온다고 생각해봅시다. 이때 요청이 들어온 Work와 이미 스케줄링된 Work 사이에 교통정리가 필요합니다.

fiber.expirationTime 값이 무엇일까요?
간단히 이야기 해서, 사용자가 이벤트를 발생시켰을 때의 시점값입니다.

좀 더 자세히 알아볼까요??
expirationTimeschedulerreconciler에서 사용되는데, 다른 의미로 사용됩니다.

  • schedulerexpirationTime
    Task의 만료시간
  • reconcilerexpirationTime
    이벤트를 구분하는 기준
    • 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 에서는 대부분의 경우, expirationTimeSync입니다.

Idle은 유휴상태를 표현하는 값입니다. 추후에 ExpirationTimeIdle와 같다면 Idle 상태인 것입니다.

reconciler의 사전 작업

  1. 해당 컴포넌트에서 이벤트가 발생했음을 알려주는 expirationTime을 할당합니다.
  2. 이벤트가 발생한 컴포넌트의 VDOM root를 가져옵니다.
  3. root에 스케줄링 정보를 기록합니다.

root란?

ReactDOM.render() 호출을 통해 컴포넌트를 삽입하는 부모 태그가 root입니다. (<div id=root></div> 인 태그)
root 하나에 VDOM을 하나 갖고 있으며, 1:1 관계입니다. root는 VDOM을 대표할 수 있는 변하지 않는 객체입니다. 그래서 root에는 많은 정보가 기입되는데 그 중 하나가 스케줄정보입니다.

여러 개의 update가 발생하여 여러 개의 WORK를 스케줄링해야할 때, 우선순위 판단을 위해 기준값이 필요하며 이 기준값을 root fiber에 넣습니다.

scheduleWork 함수는 뭘까?

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;
}

이 함수도 천천히 살펴보도록 해보죠.

1) 해당 컴포넌트에서 이벤트가 발생했음을 알려주는 expirationTime

 if (fiber.expirationTime < expirationTime) {
    fiber.expirationTime = expirationTime;
  }
  let alternate = fiber.alternate;
  if (alternate !== null && alternate.expirationTime < expirationTime) {
    alternate.expirationTime = expirationTime;
  }

fiberexpirationTime보다 인자로 받은 expirationTime이 더 클 경우 재할당 해줍니다.

expirationTime이 클수록 먼저 처리해야하는 업데이트(먼저 발생한 업데이트)입니다.

current와 alternate 모두 처리해주는 이유는 fibercurrent인지 workInProgress인지 정확히 알 수 없기 때문에 (이는 렌더링 과정에서 current변경 및 자기복제가 이뤄지기 때문) 서로 참조하는 alternate 프로퍼티를 통해 둘 다 넣어줍니다

2) 이벤트가 발생한 컴포넌트의 VDOM root를 가져옵니다.

// 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 변수에 할당합니다. 그 이후, rootfiberreturn 프로퍼티가 null인 fiber가 할당됩니다.

3)root에 스케줄링 정보를 기록합니다.

반복문의 코드를 보시면 root가 나올때 까지 자식에서 부모로 순회하면서 부모의 childExpirationTimeexpirationTime을 할당합니다.
이 값이 VDOM Reconciliation을 확인할 때, 어떤 것을 Reconciliation해야 하는지를 결정하는 매우 중요한 값입니다. (자손에서 이벤트가 발생했음을 확인)

이 값은 특히 React 18에서 Concurrent Mode를 쓸 때 어떤 값을 우선적으로 업데이트할지를 결정하는데는 중요합니다.

이후 root를 반환합니다

4) 스케줄링 요청 전 Work를 동기로 처리해야 하는지 확인하기 (With 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);
}

이 코드를 분석해야 합니다.
먼저 expirationTimeSync인지 여부를 판단합니다. 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

  • reconciler가 WORK를 실행(처리) 중일 때는 NoContext가 아닙니다.
    만약 실행 중이지 않은 경우에는 NoContext입니다.

RenderContext

  • Render Phase 시에는 RenderContextCommitContext가 아님

CommitContext

  • Commit Phase 시에는 CommitContextCommitContext가 아님

즉, 조건을 보면 VDOM이 최초로 생성되는 시기인지를 판단하는 것입니다. 그 때만이 Render Phase와 Commit Phase가 모두 실행되지 않은 시점입니다.

VDOM이 최초로 생성될 때는 performSyncWorkOnRoot(root) Sync Work를 root에서 수행해줍니다. React-DOM에 대한 최초의 마운트가 될 때 이 부분이 실행됩니다.

else 부분은 VDOM이 이미 생성되고 난 뒤에 업데이트를 시작했을 때 실행되는 코드입니다. (대부분 여기서 작업이 되겠죠)

ensureRootIsScheduled(root)는 Root가 스케줄되어있는지를 보장하는 함수입니다. 이 함수는 비동기인 경우에도 수행되는 함수이므로 뒤에서 알아보도록 하겠습니다.

그리고 내부 조건문을 또 보면 executionContextNoContext인 경우(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)은 아래의 작업을 수행합니다.

  1. root에서 필요한 정보들을 가져와서 새로운 WORK의 우선순위 얻기
    root 당 task(처리 중인 WORK)는 1개입니다.
  2. 기존 WORK와 비교
    a. 기존 WORK > 새로운 WORK: 함수 종료
    b. 기존 WORK < 새로운 WORK: 새로운 WORK의 스케줄 정보 새기기
    그 다음에 scheduleCallback(schedule과 관련된 함수)를 호출하여 WORK를 scheduler에 전달합니다

1) root에서 필요한 정보를 가져와서 새로운 WORK의 우선순위를 얻기

필요한 정보란 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을 가지고 옵니다.
currentTimemsToExpirationTime()함수를 통해 얻습니다.

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

  • Sync인 경우, ImmediatePriority(가장 높은 우선순위)를 주게 됩니다.
  • Never나 Idle인 경우, IdlePriority(낮은 우선 순위)를 주게 됩니다.

expirationTimeToMS함수로부터 ms를 구한 시간으로, ms는 큰 수가 이후 시점을 나타냅니다.
msUntil은 만료시간 - 현재시간이 되고, msUntil이 0이하인 경우는 이미 만료되었으므로 빠르게 처리해주기 위해 ImmediatePriority를 반환합니다.

Priority가 높은 순서부터 나타내면
Immediate > UserBlocking > Normal > Idle 입니다

2) 기존 work와 비교

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.callbackNodescheduleCallback 또는 scheduleSyncCallback(expiration이 Sync인 경우)의 반환값임을 알 수 있습니다.

이제 scheduleCallback 함수와 scheduleSyncCallback 함수를 살펴보도록 합시다.

먼저 fiber 아키텍처를 다시 생각해봅시다.
16버전 이전의 stack 아키텍처는 WORK의 우선순위를 조정할 수 없었습니다. Fiber 아키텍처의 도입으로 인해 수행중이던 WORK를 취소하는 등의 WORK의 우선순위를 변경할 수 있게 되었습니다.

scheduleSyncCallback 은 WORK의 우선순위를 변경할 필요가 없습니다. 그러나 우선순위 조정이 가능해진 Fiber 아키텍처의 도입한 이후에는 Sync 이외의 다른 개념들이 필요해졌습니다. 그것이 바로 Concurrent(동시성)입니다.

concurrent Work

먼저 scheduleCallback 함수를 호출하는 코드를 봅시다.

callbackNode = scheduleCallback(
  priorityLevel,
  performConcurrentWorkOnRoot.bind(null, root),
);

인자로 priorityLevelperformConcurrentWorkOnRootroot에 bind한 함수를 반환합니다.

  • performConcurrentWorkOnRoot 함수 이름에서 알 수 있듯이 rootconcurrent 작업을 수행하는 함수입니다.

그 다음으로 scheduleCallback 함수를 보시죠.

export function scheduleCallback(
  reactPriorityLevel: ReactPriorityLevel,
  callback: SchedulerCallback,
  options: SchedulerCallbackOptions | void | null,
) {
  const priorityLevel = reactPriorityToSchedulerPriority(reactPriorityLevel);
  return Scheduler_scheduleCallback(priorityLevel, callback, options);
}

reconcilerconcurrent WORKscheduler에게 우선순위와 함께 전달하며, scheduler에서 우선순위를 알아서 정해달라고 위임을 한 것입니다.
이 때, 사용했던 함수가 Scheduler_scheduleCallback 함수입니다.

즉, 함수를 호출함으로써 reconciler는 자신이 갖고 있는 WORK 수행 우선순위에 대한 책임을 scheduler에게 넘기는 것입니다.

이제 callback에 해당하는 Concurrent Work는 scheduler가 알아서 실행하게 되며 reconciler는 이제 신경쓰지 않습니다.

options에는 Task의 만료 시간을 나타내는 timeout과 시작시간을 미룰 수 있는 delay 속성이 존재합니다.

sync Work

다음으로 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와 달리 callbackscheduler에게 넘기는 것이 아닌 내부 큐에 넣습니다. 대신 이 큐를 소비하는 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는 더욱이 이 작업들의 우선순위를 가지고 있을 수 없습니다.

profile
풀스택으로 나아가기

0개의 댓글