React 톺아보기 - 04. Scheduler_2

모든 설명은 v16.12.0 버전 함수형 컴포넌트와 브라우저 환경을 기준으로 합니다. 버전에 따라 코드는 변경될 수 있으며 클래스 컴포넌트는 설명에서 제외됨을 알려 드립니다.

scheduling flow

5. Scheduler_scheduleCallback

scheduler는 전달받은 callback을 Task 객체에 담아 관리합니다. 먼저 이 Task의 생김새부터 확인해봅시다.

var newTask = {
  id: taskIdCounter++,
  callback,
  priorityLevel,
  startTime,
  expirationTime,
  sortIndex: -1,
}

위 속성중 scheduler가 전달받은 것은 callbackpriorityLevel입니다. 이 이외에는 scheduler가 내부적으로 만들어내는 정보이며 이중 시간과 관련된 startTimeexpirationTime을 먼저 알아보도록 하겠습니다.

5 - 1 startTime, expirationTime

reconciler는 시간 정보인 timeout과 delay를 options에 담아 전달합니다. delay와 timeout은 각각 startTime, expirationTime을 구하는데 사용됩니다.

reconciler에서는 expirationTime에 여러 의미가 있었지만, scheduler는 이름 그대로 Task의 만료 시간을 뜻합니다.

scheduler > Scheduler.js

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime()
  var startTime
  var timeout

  if (typeof options === 'object' && options !== null) {
    var delay = options.delay
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay
    } else {
      startTime = currentTime
    }
    timeout =
      typeof options.timeout === 'number'
        ? options.timeout
        : timeoutForPriorityLevel(priorityLevel)
  } else {
    timeout = timeoutForPriorityLevel(priorityLevel)
    startTime = currentTime
  }

  var expirationTime = startTime + timeout
  /*...*/
}

options가 없거나18 timeout이 담겨 있지 않다면15 reconciler가 넘겨준 우선순위를 기준 timeoutForPriorityLevel()을 통해 구합니다.

options는 concurrent mode에서 사용합니다.

scheduler > Scheduler.js

// Times out immediately
var IMMEDIATE_PRIORITY_TIMEOUT = -1
// Eventually times out
var USER_BLOCKING_PRIORITY = 250
var NORMAL_PRIORITY_TIMEOUT = 5000
var LOW_PRIORITY_TIMEOUT = 10000
// Never times out
var IDLE_PRIORITY = maxSigned31BitInt // 1073741823

function timeoutForPriorityLevel(priorityLevel) {
  switch (priorityLevel) {
    case ImmediatePriority:
      return IMMEDIATE_PRIORITY_TIMEOUT
    case UserBlockingPriority:
      return USER_BLOCKING_PRIORITY
    case IdlePriority:
      return IDLE_PRIORITY
    case LowPriority:
      return LOW_PRIORITY_TIMEOUT
    case NormalPriority:
    default:
      return NORMAL_PRIORITY_TIMEOUT
  }
}

reconciler는 sync WorkscheduleSyncCallback()를 통해 넘겨줄 때 우선순위를 Scheduler_ImmediatePriority로 넘겨주었습니다. 해당 우선순위로 expirationTime을 계산해보면(startTime + timeout) currentTime보다 -1ms만큼 빠릅니다.

5 - 2 Task 생성하기

사전작업은 모두 끝났습니다. 이제부터 본격적으로 새로 생성된 Task를 어떻게 다루는지 알아보겠습니다.

scheduler > Scheduler.js

function unstable_scheduleCallback(priorityLevel, callback, options) {
  /*...*/
  // var expirationTime = startTime + timeout

  var newTask = {
    id: taskIdCounter++,
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,
  }

  if (startTime > currentTime) {
    /*Timer..*/
  } else {
    /*Task..*/
  }

  return newTask
}

큰 틀을 보자면 Task를 생성한 후 실행 시점에 따라 분기15처리하고 reconciler에게 반환21합니다(root에 기입하기 위해). 여기서 생성된 Task최소힙 자료구조에 추가되어 우선순위에 맞춰 정렬됩니다. 이때 힙의 정렬 기준은 Task의 sortIndex입니다.

1) Timer

딜레이된 Task는 시간이 지나 timeout이 되어야 실행할 수 있는 Task이므로 Timer라고 부르고 있습니다.

scheduler > Scheduler.js

var taskQueue = [] // Task
var timerQueue = [] // Delayed task

function unstable_scheduleCallback(priorityLevel, callback, options) {
  /*...*/
  /*var newTask = ...;*/

  if (startTime > currentTime) {
    newTask.sortIndex = startTime
    push(timerQueue, newTask)
    if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
      // All tasks are delayed, and this is the task with the earliest delay.
      if (isHostTimeoutScheduled) {
        // 중복 실행 방지
        // Cancel an existing timeout.
        cancelHostTimeout()
      } else {
        isHostTimeoutScheduled = true
      }
      // Schedule a timeout.
      requestHostTimeout(handleTimeout, startTime - currentTime)
    }
  } else {
    /*General task..*/
  }

  return newTask
}
  • 힙 정렬 기준sortIndexstartTime11 입니다. Timer는 시작 시간순으로 정렬됨을 알 수 있습니다.
  • Timer를 힙에 추가12하는데 heap.push(newTask)를 생각했던 것과는 달리 행위push에 동작의 주체timerQueueTimer를 넘겨주고 있습니다.

    힙 구현은 SchedulerMinHeap.js에서 확인할 수 있습니다.

  • taskQueue에 실행대기 중인 Task가 없고13 새로 생성된 TimerTimer중에서 가장 우선순위가 높다면 13 실행 1순위는 생성된 Timer입니다. Timer의 실행 방식은 timeout api를 이용하여 최소 딜레이된 시간 이후에 timeQueue 소비 함수가 실행되도록 구현되어 있습니다.

    실행 1순위 Timer가 바뀌었으므로 requestHostTimeout(timeout api)22를 1순위 Timer의 딜레이 시간으로 갈아 끼워22야 합니다. 이때 주의할 점은 실행된 timeout api가 있다면 취소15 해주어야 합니다.

Timer소비 함수 handleTimeout()

handleTimeout()이 호출되었다는 건 1순위 Timer가 timeout되었음을 나타냅니다. timeout된 Timer는 실행 되어야 하는 Task로 취급 되므로 taskQueue로 옮겨집니다. 그리고 그 안에서 또 우선순위에 따라서 정렬되겠죠.

scheduler > Scheduler.js

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false // 여기에 도달했다는 건 timeout api가 실행된 것이다.
  advanceTimers(currentTime) // timeout Timer를 꺼내 taskQueue에 추가한다.
  /*...*/
}

scheduler > Scheduler.js

function advanceTimers(currentTime) {
  let timer = peek(timerQueue)
  while (timer !== null) {
    // Task는 취소되면 callback은 null로 초기화된다.
    if (timer.callback === null) {
      // 취소된 Timer를 꺼냄
      pop(timerQueue)
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      pop(timerQueue)
      timer.sortIndex = timer.expirationTime
      push(taskQueue, timer)
    } else {
      // Remaining timers are pending.
      return
    }
    timer = peek(timerQueue)
  }
}
  • timerQueue에서 하나씩 timeout여부를 확인9하여 taskQueue에 추가합니다. taskQueue의 정렬기준은 timerQueue와는 달리 expirationTime12입니다.

scheduler > Scheduler.js

function handleTimeout(currentTime) {
  /*...*/
  // advanceTimers(currentTime)

  if (!isHostCallbackScheduled) {
    // 중복 실행 방지
    if (peek(taskQueue) !== null) {
      isHostCallbackScheduled = true
      requestHostCallback(flushWork)
    } else {
      const firstTimer = peek(timerQueue)
      if (firstTimer !== null) {
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)
      }
    }
  }
}
  • timeout Timer가 taskQueue로 옮겨졌는지 확인7합니다. 확인되었으면 taskQueue 소비 함수flushWork를 다음 프레임에 실행될 수 있도록 host_config의 함수인 requestHostCallback()9을 호출합니다.
  • 만약 advanceTimers()를 진행4했는데도 taskQueue가 비어10있다면 취소된 timeout Task로 인해 handleTimeout()이 호출된 것이므로 다음 Timer의 시간으로 다시 timeout api를 실행13합니다.

2) Task

scheduler > Scheduler.js

function unstable_scheduleCallback(priorityLevel, callback, options) {
  /*...*/
  /*var newTask = ...;*/

  if (startTime > currentTime) {
    /*Timer..*/
  } else {
    newTask.sortIndex = expirationTime
    push(taskQueue, newTask)
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true
      requestHostCallback(flushWork)
    }
  }

  return newTask
}
  • taskQueue의 우선순위는 expirationTime9입니다.
  • isPerformingWork는 Work의 진행이 concurrent mode에서는 비동기로 동작하기 때문에 scheduler는 다른 VDOM root가 요청한 Work를 실행해버릴 수 있습니다. 이를 방지하기 위한 플래그입니다.
  • requestHostCallback()의 구현 코드는 requestHostTimeout() 처럼 단순하지 않으므로 host_config를 분석할 때 다루도록 하겠습니다. 단지 지금은 flushWork()를 비동기로 실행하는 api라고 생각하면 됩니다.

원래 흐름대로라면 다음 분석 대상은 hostconfig의 requestHostCallback()나 requestHostTimeout()가 이지만 hostconfig로 넘어갔다가 다시 돌아오기에는 너무 먼 길을 갔다 와야 하므로 scheduler를 마저 분석하고 난 후에 진행하도록 하겠습니다.

9. flushWork

flushWork()는 taskQueue를 소비하기 전 사전작업 공간입니다.

scheduler > Scheduler.js

function flushWork(hasTimeRemaining, initialTime) {
  isHostCallbackScheduled = false
  if (isHostTimeoutScheduled) {
    isHostTimeoutScheduled = false
    cancelHostTimeout()
  }

  isPerformingWork = true // 중복 실행 방지
  const previousPriorityLevel = currentPriorityLevel
  try {
    return workLoop(hasTimeRemaining, initialTime)
  } finally {
    currentTask = null
    currentPriorityLevel = previousPriorityLevel
    isPerformingWork = false
  }
}
  • flushWork()는 requestHostCallback()로 인해 다음 프레임에 호출됩니다. 그러니 비동기 관련 플래그 isHostCallbackScheduled를 꺼줍니다3.
  • timeout api가 실행되어 있다면 Task를 처리할 것이므로 방해받지 않도록 취소6시켜 줍니다.
  • workLoop()가 반환12하는 값을 flushWork()도 그대로 반환하고 있음을 기억해 두세요. 이 값은 host_config가 받아 async Work의 잔여 작업 존재 여부를 판단하여 추가 처리하는데 사용됩니다.

10. workLoop

이해를 돕기 위해 간단한 의사코드로 흐름을 먼저 알아보고 실제 코드를 보겠습니다.

  1. currentTask = peek(taskQueue)
  2. while currentTask !== null

    1. if currentTask의 만료 시간이 여유가 있는 와중에 scheduler에게 할당된 시간이 끝났다.
    2. break
    3. callback = currentTask.callback;
    4. if callback !== null
    5. callback을 실행해 Work를 시작한다.
    6. if callback이 잔여 작업을 반환했다.

      • currentTask.callback = continuationCallback;
    7. else

      • 잔여 작업이 없다면 currentTask를 taskQueue에서 꺼낸다.
    8. end if
    9. else
    10. callback이 없다는 건 Task가 취소되었다는 뜻이므로 taskQueue에서 꺼낸다.
    11. end if
    12. currentTask = peek(taskQueue);
  3. end while
  4. if currentTask !== null

    • currentTask가 null이 아니라면 scheduler에게 할당된 시간이 끝나 while 문이 중단되어 Task가 아직 남아 있다는 뜻으로 config_host에게 계속해서 작업을 이어갈 수 있도록 알려준다.
  5. else

    • Task는 모두 처리되었으므로 Timer가 존재한다면 소비하기 위해 requestHostTimeout()를 호출하고 잔여 작업이 남아 있지 않음을 host_config에게 알린다.
  6. end if

scheduler > Scheduler.js

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime
  advanceTimers(currentTime) // Task 소비 전 timeout Timer를 taskQueue로 옮겨놓는다.
  currentTask = peek(taskQueue)

  while (currentTask !== null) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // This currentTask hasn't expired, and we've reached the deadline.
      break
    }

    const callback = currentTask.callback
    if (callback !== null) {
      currentTask.callback = null
      currentPriorityLevel = currentTask.priorityLevel
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime
      // Work 진행, 잔여 작업 여부 반환(concurrent mode)
      const continuationCallback = callback(didUserCallbackTimeout)
      currentTime = getCurrentTime()

      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback
      } else {
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue)
        }
      }
      advanceTimers(currentTime)
    } else {
      pop(taskQueue)
    }
    currentTask = peek(taskQueue)
  }

  // Return whether there's additional work
  if (currentTask !== null) {
    return true
  } else {
    let firstTimer = peek(timerQueue)
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime)
    }
    return false
  }
}

추가 설명이 필요한 부분만 짚고 넘어가도록 하겠습니다.

  • timerQueue의 기아상태(starvation)를 방지하기 위해서 틈틈이 timeout Timer를 꺼내4, 32줍니다.
  • if 문9 ~ 10 조건의 뜻은 현재 Task의 만료시간이 조금이라도 여유가 있으면 꼭 지금 처리해야 하는 게 아니므로 hostconfig에게 물어shouldYieldToHost()봅니다. 작업을 계속 동기적으로 처리해도 되는지? 만약 그렇지 않다면 while 문을 중단13하고 브라우저에게 메인 스레드를 양보하기 위해 제어권을 hostconfig에게 넘깁41, 47니다.

    제어권을 전달받은 host_config는 잔여 여부에 따라 다음 프레임에 계속해서 Task를 소비할 수 있도록 준비합니다. 하지만 Task의 만료시간이 이미 지나버렸다면9 브라우저고 뭐고 바로 처리해야 하므로 제어권을 넘기지 않고 만료된 모든 Task를 동기적으로 처리해버립니다.

여기까지가 scheduler가 하는 일입니다. 다음 섹션에서는 host_config가 어떻게 비동기 api를 구현했는지 확인합니다.

6. requestHostTimeout, requestHostCallback

간단한 timeout 구현부터 확인하겠습니다.

scheduler > fork > SchedulerHostConfig.default.js

const setTimeout = window.setTimeout
const clearTimeout = window.clearTimeout

requestHostTimeout = function(callback, ms) {
  taskTimeoutID = setTimeout(() => {
    callback(getCurrentTime())
  }, ms)
}

cancelHostTimeout = function() {
  clearTimeout(taskTimeoutID)
  taskTimeoutID = -1
}

우리가 브라우저 환경에서 사용하는 일반적인 비동기 코드와 크게 다르지 않습니다.

requestHostCallback()은 requestHostTimeout()과는 다르게 setTimeout() api로 구현하지 않고 MessageChannel을 이용하여 구현했습니다.

scheduler > fork > SchedulerHostConfig.default.js

const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = performWorkUntilDeadline

requestHostCallback = function(callback) {
  scheduledHostCallback = callback
  if (!isMessageLoopRunning) {
    isMessageLoopRunning = true
    port.postMessage(null)
  }
}

cancelHostCallback = function() {
  scheduledHostCallback = null
}
  • scheduler에서 requestHostCallback()을 호출할 때 넘겨준 callbackflushWork을 전역으로 잡아9둡니다. 이 callback은 onmessage 핸들러인 performWorkUntilDeadline()4에서 사용됩니다.

requestHostCallback() 호출을 호출했으니 메시지 채널을 통해 다음 프레임에 performWorkUntilDeadline()가 호출되면서 Work가 진행될 것입니다.

8. performWorkUntilDeadline

performWorkUntilDeadline()는 callbackflushWork를 호출하는데 flushWork()의 반환 값에 따라 작업을 모두 완료했거나 또는 작업 중 host_config가 할당한 시간deadline을 넘긴 경우로 해석할 수 있습니다. 후자의 경우 다음 프레임에 계속해서 작업이 이어갈 수 있도록 해주어야 합니다.

scheduler > fork > SchedulerHostConfig.default.js

let yieldInterval = 5 // ms
let deadline = 0

const performWorkUntilDeadline = () => {
  if (scheduledHostCallback !== null) {
    const currentTime = getCurrentTime()
    deadline = currentTime + yieldInterval
    const hasTimeRemaining = true
    try {
      const hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime)
      if (!hasMoreWork) {
        isMessageLoopRunning = false
        scheduledHostCallback = null
      } else {
        port.postMessage(null)
      }
    } catch (error) {
      port.postMessage(null)
      throw error
    }
  } else {
    isMessageLoopRunning = false
  }
  needsPaint = false
}

yieldIntervaldeadline은 다음의 주석으로 설명을 대체합니다.

yieldInterval

Scheduler periodically yields in case there is other work on the main thread, like user events. By default, it yields multiple times per frame.
It does not attempt to align with frame boundaries, since most tasks don’t need to be frame aligned; for those that do, use requestAnimationFrame.

deadline

Yield after yieldInterval ms, regardless of where we are in the vsync cycle. This means there’s always time remaining at the beginning of the message event.

1초에 60프레임을 뽑으려면 약 16ms에 렌더링 파이프라인을 통과해야 합니다. 리액트는 Work를 꼭 VSync에 맞출 필요가 없다고 생각했습니다. Work는 메모리 작업이지 렌더링과 관련된 작업이 아니므로 한 프레임에 여러 번(5ms) 브라우저에 양보한다면 자바스크립트가 렌더링에 큰 영향을 미치지 않는다고 판단하는 것 같습니다.

needsPaint25와 shouldYieldToHost() 등 언급은 되었지만, 아직 확인하지 않은 부분들은 다음 섹션에서 다룹니다.

브라우저에게 양보하기 위한 시스템

host_config는 호스트 환경에 의존적인 모듈입니다. 사용하는 구현체 또한 그때그때 다르며 그중 하나가 shouldYieldToHost() 입니다. 분석은 이 함수로부터 시작합니다.

해당 함수는 제어권을 반납해야 하는지 다른 모듈들에 알려주는 역할을 합니다. 양보하는 이유는 자바스크립트가 브라우저의 사용자 이벤트 처리나 렌더링 작업을 방해하지 않게 하기 위함입니다.

scheduler 시작 부분에서 isInputPending에 대해 언급한 내용을 기억하시나요? 해당 api는 유저 이벤트가 대기 중인지 JS에서 알 수 있도록 해줍니다. shouldYieldToHost()는 이 api가 지원하는 환경인지에 따라 구현 코드가 달라집니다.

1. isInputPending api를 지원하는 환경

이 환경에서는 사용자 이벤트와 페인트 두 가지를 각각 따로 확인할 수 있습니다.
리액트는 위 두 상황이 아니라면 굳이 브라우저에 양보할 필요 없이 부지런히 Work를 진행하면 됩니다.

scheduler > fork > SchedulerHostConfig.default.js

let maxYieldInterval = 300
let needsPaint = false
let deadline = 0

if (
  enableIsInputPending &&
  navigator !== undefined &&
  navigator.scheduling !== undefined &&
  navigator.scheduling.isInputPending !== undefined
) {
  const scheduling = navigator.scheduling

  shouldYieldToHost = function() {
    const currentTime = getCurrentTime()
    if (currentTime >= deadline) {      if (needsPaint || scheduling.isInputPending()) {        return true
      }
      return currentTime >= maxYieldInterval    } else {
      return false
    }
  }

  requestPaint = function() {
    needsPaint = true  }
} else {
  // isInputPending를 지원하지 않는 환경..
}
  • 16: deadline을 넘겼다면 1차 신호입니다.
  • 17: 사용자 이벤트가 대기 중이거나 렌더링이 필요하면needsPaint 바로 브라우저를 위해 콜 스택을 비워줘야 합니다.
  • 20: 17라인에 해당하지 않는다면? maxYieldInterval까지 작업을 계속해서 진행할 수 있습니다.

사용자 이벤트 여부를 알려주는 isInputPending()의 경우 브라우저 api로 브라우저가 알려줍니다. 그렇다면 페인트가 필요한지에 대한 여부 needsPaint는 누가 알려주는 것일까요? needsPaint는 VDOM을 DOM에 모두 적용하였으니 브라우저의 렌더링이 필요하다는 여부를 reconciler가 알려주고 있는 것입니다. reconciler는 Commit phase가 모두 완료되면 requestPaint()을 호출합니다.

2. isInputPending api를 지원하지 않는 환경

isInputPending()이 지원되지 않는다면 reconciler가 알려주는 페인트 여부만 알 수 있습니다. 그래서 어쩔 수 없이 주기적으로 콜 스택을 비워줘야 합니다. 그래서 굳이 페인트 여부도 신경쓸 필요가 없습니다.

scheduler > fork > SchedulerHostConfig.default.js

let maxYieldInterval = 300
let needsPaint = false
let deadline = 0

if (isInputPending !== undefined) {
  /*isInputPending를 지원하는 환경..*/
} else {
  shouldYieldToHost = function() {
    return getCurrentTime() >= deadline
  }
  requestPaint = function() {}
}

isInputPending()을 사용하던 shouldYieldToHost()에서는 기준(needsPaint, isInputPending)이 명확하여 무리가 되지 않는 선maxYieldInterval에서 작업을 이어갈 수 있지만, 한 프레임에 짧은 기간 주기적으로 양보해야 되는 환경에서는 deadline으로 작업을 끊어주어야 합니다.

deadline은 performWorkUntilDeadline()에서 기본적으로 5ms로 계산됩니다. 리액트는 deadline을 조절하기 위해 yieldInterval를 변경할 수 있는 함수를 제공합니다.

scheduler > fork > SchedulerHostConfig.default.js

forceFrameRate = function(fps) {
  if (fps < 0 || fps > 125) {
    console.error(
      'forceFrameRate takes a positive int between 0 and 125, ' +
        'forcing framerates higher than 125 fps is not unsupported'
    )
    return
  }
  if (fps > 0) {
    yieldInterval = Math.floor(1000 / fps)
  } else {
    // reset the framerate
    yieldInterval = 5
  }
}

3. 양보를 위해 shouldYieldToHost()가 사용되는 위치

우리는 이미 한군데는 알고 있습니다. 바로 taskQueue를 소비하는 workLoop() 입니다. scheduler에는 여기 말고도 한군데 더 있습니다. scheduler 내부에서 사용하는게 아닌 reconciler에게 알려주기 위해 작성된 함수입니다.

scheduler > Scheduler.js

function unstable_shouldYield() {
  const currentTime = getCurrentTime()
  advanceTimers(currentTime) // timeout Timer를 taskQueue로 옮긴다.
  const firstTask = peek(taskQueue)
  return (
    (firstTask !== currentTask &&
      currentTask !== null &&
      firstTask !== null &&
      firstTask.callback !== null &&
      firstTask.startTime <= currentTime &&
      firstTask.expirationTime < currentTask.expirationTime) ||
    shouldYieldToHost()
  )
}

reconciler는 concurrent mode의 Work를 중간에 중지할 수 있습니다. 더 자세히는 Render phase에 국한되는 행위이며 일전에 보여드린 컴포넌트 라이프 사이클 이미지에 나와있는 “May be paused, aborted or restarted”에 대해 언급드리고 있는 것입니다.

reconcilerunstable_shouldYield()를 통해 중지를 결정하므로 unstable_shouldYield()가 어떠한 근거를 가지고 판단하는지 확인해봅시다.

  • 7: advanceTimers()를 통해 삽인 된 timeout Timer든 reconciler의 스케줄 요청을 통해 삽입된 Task든 어쨌든 새롭게 삽입된 firstTask가 현재 진행 중인 currentTask보다 우선순위가 더 높다.
  • 8 ~ 10: currentTask가 존재하고 firstTask 또한 Work를 가지고 있다.
  • 11 ~ 12: firstTask는 이미 실행되어야 할 시간이 지났다.

위 조건은 scheduler 입장에서의 기준이며 모두 만족해야 현재 진행 중인 Render phase를 중지할 수 있습니다. 또는 host_config 판단하에 브라우저에 메인 스레드를 양보해야 한다13면 Render phase를 바로 중지합니다.

마지막으로 reconciler가 unstable_shouldYield()를 어디에 사용하는지 확인하면서 이번 포스트를 마무리합니다.

reconciler > ReactFiberWorkLoop.js

import Scheduler_shouldYield as shouldYield from './SchedulerWithReactIntegration'

function workLoopSync() {
  // Already timed out, so perform work without checking if we need to yield.
  while (workInProgress !== null) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}
  • 위 함수들은 컴포넌트 단위의 재조정 작업을 진행하는 함수입니다. workLoopSync()는 legacy mode, blocking mode에서 사용하고 workLoopConcurrent()는 concurrent mode에서 사용합니다.
  • while 문의 조건을 보면 concurrent mode에서만 shouldYield()를 사용하고 있습니다. 이 모드에서는 작업을 특정 컴포넌트까지 진행하다 중지시킬 수 있음을 해당 코드를 통해 짐작할 수 있습니다. 여기서 중지된 Work는 잔여 작업이 남아 있음을 scheduler에게 알려 주어야 계속해서 작업을 이어갈 수 있습니다.

다음 포스트 reconciler에서는 Work가 하는 일이 도대체 무엇인지, 재조정 작업과 VDOM을 어떻게 DOM에 적용하는지, 함수형 컴포넌트의 라이프 사이클 격인 useEffect()와 useLayoutEffect()의 동작원리 등 리액트의 핵심 기능들을 알아볼 것입니다.


오픈소스를 톺아보며 매직 코드라 생각했던 부분들의 동작 원리와 의미, 의도를 파악해보고 서로의 생각을 나누기 위한 블로그