A previous blog talked about the update process of React, but in that blog, task scheduling uses the browser’s requestIdleCallback, and in fact React uses a task scheduler implemented by itself. We will analyze its source this time. Code, and why React implements the task scheduler itself.
This article is based on the 16.18.6 branch in the React repository.
Scheduler’s source code is not much, probably more than 700 lines, which can be divided into three parts:
Define the three functions of requestHostCallback, cancelHostCallback and shouldYieldToHost according to the actual running environment differences to implement task execution and cancellation.
Use the three functions defined above to achieve priority scheduling tasks
Expose some interfaces to the outside world to add, delete, and insert some tasks
Task execution method
About the role of this code, in the comments inside the source code said more clearly, if you do not want to know what is done, you can take a look at its comments:
1 2 3 4 5 6 7 8 9 10 11
// The remaining code is essentially a polyfill for requestIdleCallback. It // works by scheduling a requestAnimationFrame, storing the time for the start // of the frame, then scheduling a postMessage which gets scheduled after paint. // Within the postMessage handler do as much work as possible until time + frame // rate. By separating the idle call into a separate event tick we ensure that // layout, paint and other browser work is counted against the available time. // The frame rate is dynamically adjusted.
// We capture a local reference to any global, in case it gets polyfilled after // this module is initially evaluated. We want to be using a // consistent implementation.
The translation is: The rest of the code is essentially the padding of the requestIdleCallback. It works by scheduling the requestAnimationFrame, storing the time when the frame started, and then scheduling the postMessage scheduled after drawing. Do as much work as possible in the postMessage handler until time + frame rate. By separating the idle calls into a separate event marker, we ensure that layout, drawing, and other browser work are counted in the available time. The frame rate is adjusted dynamically. We capture local references to any global variable in case it is populated after the initial calculation of this module. We want to use a consistent implementation.
Next, let’s take a formal look at the code. First, several methods such as setTimeout are assigned according to the results of the current environment judgment:
// This initialization code may run even on server environments if a component // just imports ReactDOM (e.g. for findDOMNode). Some environments might not // have setTimeout or clearTimeout. However, we always expect them to be defined // on the client. https://github.com/facebook/react/pull/13088 var localSetTimeout = typeofsetTimeout = 'function' ? setTimeout : undefined; var localClearTimeout = typeofclearTimeout = 'function' ? clearTimeout : undefined;
// We don't expect either of these to necessarily be defined, but we will error // later if they are missing on the client. var localRequestAnimationFrame = typeof requestAnimationFrame = 'function' ? requestAnimationFrame : undefined; var localCancelAnimationFrame = typeof cancelAnimationFrame = 'function' ? cancelAnimationFrame : undefined;
var getCurrentTime;
// requestAnimationFrame does not run when the tab is in the background. If // we're backgrounded we prefer for that work to happen so that the page // continues to load in the background. So we also schedule a 'setTimeout' as // a fallback. // TODO: Need a better heuristic for backgrounded work. varANIMATION_FRAME_TIMEOUT = 100; var rAFID; var rAFTimeoutID; var requestAnimationFrameWithTimeout = function(callback) { // schedule rAF and also a setTimeout rAFID = localRequestAnimationFrame(function(timestamp) { // cancel the setTimeout localClearTimeout(rAFTimeoutID); callback(timestamp); }); rAFTimeoutID = localSetTimeout(function() { // cancel the requestAnimationFrame localCancelAnimationFrame(rAFID); callback(getCurrentTime()); }, ANIMATION_FRAME_TIMEOUT); };
if (globalValue && globalValue._schedMock) { // Dynamic injection, only for testing purposes. var globalImpl = globalValue._schedMock; requestHostCallback = globalImpl[0]; cancelHostCallback = globalImpl[1]; shouldYieldToHost = globalImpl[2]; getCurrentTime = globalImpl[3]; }
That is to say, when we mount the _schedMock object to the global object, we will enter the mock judgment, and then use the methods we pass in to define several functions needed for task execution.
Non-browsing environment or when MessageChannel is not supported
elseif ( // If Scheduler runs in a non-DOM environment, it falls back to a naive // implementation using setTimeout. typeofwindow = 'undefined' || // Check if MessageChannel is supported, too. typeofMessageChannel ! 'function' ) { // If this accidentally gets imported in a non-browser environment, e.g. JavaScriptCore, // fallback to a naive implementation. var _callback = null; var _flushCallback = function(didTimeout) { if (_callback ! null) { try { _callback(didTimeout); } finally { _callback = null; } } }; requestHostCallback = function(cb, ms) { if (_callback ! null) { // Protect against re-entrancy. setTimeout(requestHostCallback, 0, cb); } else { _callback = cb; setTimeout(_flushCallback, 0, false); } }; cancelHostCallback = function() { _callback = null; }; shouldYieldToHost = function() { returnfalse; }; }
This code does the following things:
Declares a new variable _callback to store the currently executing callback
_flushCallback function, when the _callback is not empty, execute the _callback, and then say _callback set to empty, indicating that there is no currently executing task, you can perform other tasks
‘requestHostCallback’ function, first determines whether _callback is null, if not null, indicating that there is currently a task being executed, then use setTimeout to add a macro task and continue to execute’requestHostCallback 'itself, although it looks like a recursion call here, but because of the use of setTimeout, the call stack does not actually grow. If _callback is null, indicating that there is no current task being executed, then assign the value _callback first, and then add a macro task to execute _flushCallback.
‘cancelHostCallback’ function is the _callback is empty, cancel the current task. In fact, if the current task has been added to the macro task queue, in this way can not be canceled, but note that our macro task queue is added to the _flushCallback function, if the _callback is empty, in fact, nothing will be done.
‘shouldYieldToHost’ must return false in this case.
In general, ‘requestHostCallback’ is to determine whether there is currently a _callback in the execution, if there is, etc. (this is also achieved by the macro task callback itself), if not, add a macro task, and eventually add a macro task to execute the _flushCallback, that is, in theory, when there is a _callback in execution, the other’requestHostCallback ‘are queued through the macro task, and the final execution order is also the order of’requestHostCallback’.
However, if you call cancelHostCallback when clearing the microtask queue before executing the added macro task after the’requestHostCallback 'successfully adds the macro task, then in fact, nothing will be done when the _flushCallback is executed. Take a look at the following figure:
Each box in the above figure represents a macro task. There are two pieces of information in the box, one is the function executed by the current macro task, and the other is the value of the current _callback.
The first line indicates the normal situation, we requested two tasks, and then executed them one by one. The second line is the special case, what happens if you cancel before executing _flushCallback
Browser environment and MessageChannel support
The first is to print the error message. If the current environment does not support requestAnimationFrame, the error message will be printed, but it is only to print the error message.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
if (typeofconsole ! 'undefined') { // TODO: Remove fb.me link if (typeof localRequestAnimationFrame ! 'function') { console.error( "This browser doesn't support requestAnimationFrame. " + 'Make sure that you load a ' + 'polyfill in older browsers. https://fb.me/react-polyfills', ); } if (typeof localCancelAnimationFrame ! 'function') { console.error( "This browser doesn't support cancelAnimationFrame. " + 'Make sure that you load a ' + 'polyfill in older browsers. https://fb.me/react-polyfills', ); } }
Then there are several variables. Understanding the role of these variables is important for understanding the following code.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Var scheduledHostCallback = null;//The currently executing task can be compared to the above _callback Var isMessageEventScheduled = false;//whether there is a MessageChannel message being processed Var timeoutTime = -1;//of course the timeout of the task
Var isAnimationFrameScheduled = false;//Is there a task added to the macro task by requestAnimationFrame
Var isFlushingHostCallback = false;//whether there is a task being executed
Var frameDeadline = 0;//Record the expiration time of the current frame, which is equal to rafTime + activeFrameTime, which is the time passed in by the requestAnimationFrame callback, plus the time of one frame
// We start out assuming that we run at 30fps but then the heuristic tracking // will adjust this value to a faster fps if we get more frequent animation // frames. var previousFrameTime = 33; var activeFrameTime = 33;
After understanding these variables, let’s take a look at several tool functions.
var animationTick = function(rafTime) { if (scheduledHostCallback ! null) { // Eagerly schedule the next animation callback at the beginning of the // frame. If the scheduler queue is not empty at the end of the frame, it // will continue flushing inside that callback. If the queue *is* empty, // then it will exit immediately. Posting the callback at the start of the // frame ensures it's fired within the earliest possible frame. If we // waited until the end of the frame to post the callback, we risk the // browser skipping a frame and not firing the callback until the frame // after that. requestAnimationFrameWithTimeout(animationTick); } else { // No pending work. Exit. isAnimationFrameScheduled = false; return; }
var nextFrameTime = rafTime - frameDeadline + activeFrameTime; if ( nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime ) { if (nextFrameTime < 8) { // Defensive coding. We don't support higher frame rates than 120hz. // If the calculated frame time gets lower than 8, it is probably a bug. nextFrameTime = 8; } // If one frame goes long, then the next one can be short to catch up. // If two frames are short in a row, then that's an indication that we // actually have a higher frame rate than what we're currently optimizing. // We adjust our heuristic dynamically accordingly. For example, if we're // running on 120hz display or 90hz VR display. // Take the max of the two in case one of them was an anomaly due to // missed frame deadlines. activeFrameTime = nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; } else { previousFrameTime = nextFrameTime; } frameDeadline = rafTime + activeFrameTime; if (!isMessageEventScheduled) { isMessageEventScheduled = true; port.postMessage(undefined); } };
This method mainly does two things:
As long as’scheduledHostCallback ‘is not empty, it means that there is currently a task being executed, and it continues to call itself through’requestAnimationFrameWithTimeout’ to recalculate the time used for each frame, that is, update’previousFrameTime ‘and’activeFrameTime’. If the current’scheduledHostCallback 'is empty, then return directly, that is, stop updating the time used for each frame.
At the same time, if’isMessageEventScheduled 'is false during each execution, a MessageChannel message will be triggered.
Then there is the MessageChannel’s method of processing messages.
var prevScheduledCallback = scheduledHostCallback; var prevTimeoutTime = timeoutTime; scheduledHostCallback = null; timeoutTime = -1;
var currentTime = getCurrentTime ();
var didTimeout = false; if (frameDeadline - currentTime <= 0) { // There's no time left in this idle period. Check if the callback has // a timeout and whether it's been exceeded. if (prevTimeoutTime ! -1 && prevTimeoutTime <= currentTime) { // Exceeded the timeout. Invoke the callback even though there's no // time left. didTimeout = true; } else { // No timeout. if (!isAnimationFrameScheduled) { // Schedule another animation callback so we retry later. isAnimationFrameScheduled = true; requestAnimationFrameWithTimeout(animationTick); } // Exit without invoking the callback. scheduledHostCallback = prevScheduledCallback; timeoutTime = prevTimeoutTime; return; } }
requestHostCallback = function(callback, absoluteTimeout) { scheduledHostCallback = callback; timeoutTime = absoluteTimeout; if (isFlushingHostCallback || absoluteTimeout < 0) { // Don't wait for the next frame. Continue working ASAP, in a new event. port.postMessage(undefined); } elseif (!isAnimationFrameScheduled) { // If rAF didn't already schedule one, we need to schedule a frame. // TODO: If this rAF doesn't materialize because the browser throttles, we // might want to still have setTimeout trigger rIC as a backup to ensure // that we keep performing work. isAnimationFrameScheduled = true; requestAnimationFrameWithTimeout(animationTick); } };
// Max 31 bit integer. The max integer size in V8 for 32-bit systems. // Math.pow(2, 30) - 1 // 0b111111111111111111111111111111 var maxSigned31BitInt = 1073741823;
// Times out immediately varIMMEDIATE_PRIORITY_TIMEOUT = -1; // Eventually times out varUSER_BLOCKING_PRIORITY = 250; varNORMAL_PRIORITY_TIMEOUT = 5000; varLOW_PRIORITY_TIMEOUT = 10000; // Never times out varIDLE_PRIORITY = maxSigned31BitInt;
// Callbacks are stored as a circular, doubly linked list. var firstCallbackNode = null;
var currentDidTimeout = false; // Pausing the scheduler is useful for debugging. var isSchedulerPaused = false;
var currentPriorityLevel = NormalPriority; var currentEventStartTime = -1; var currentExpirationTime = -1;
// This is set when a callback is being executed, to prevent re-entrancy. var isExecutingCallback = false;
First of all, understand that all callbackNodes form a bidirectional ring, that is, each node has previous and next, and the next of the last node is the first node, and the previous of the first node is the last node.
functionflushFirstCallback() { //Save firstCallbackNode as flushedNode and it will be used later var flushedNode = firstCallbackNode;
// Remove the node from the list before calling the callback. That way the // list is in a consistent state even if the callback throws. var next = firstCallbackNode.next; if (firstCallbackNode = next) { // This is the last callback in the list. firstCallbackNode = null; next = null; } else { //Remove firstCallbackNode from the task ring by making the previous of firstCallbackNode point to the next of firstCallbackNode //also make firstCallbackNode point to the next node var lastCallbackNode = firstCallbackNode.previous; firstCallbackNode = lastCallbackNode.next = next; next.previous = lastCallbackNode; }
//flushedNode is the current task node to be executed flushedNode.next = flushedNode.previous = null;
// Now it's safe to call the callback. //Execute the callback of the current node var callback = flushedNode.callback; var expirationTime = flushedNode.expirationTime; var priorityLevel = flushedNode.priorityLevel; var previousPriorityLevel = currentPriorityLevel; var previousExpirationTime = currentExpirationTime; currentPriorityLevel = priorityLevel; currentExpirationTime = expirationTime; var continuationCallback; try { continuationCallback = callback(); } finally { currentPriorityLevel = previousPriorityLevel; currentExpirationTime = previousExpirationTime; }
// A callback may return a continuation. The continuation should be scheduled // with the same priority and expiration as the just-finished callback. if (typeof continuationCallback = 'function') { varcontinuationNode: CallbackNode = { callback: continuationCallback, priorityLevel, expirationTime, next: null, previous: null, };
// Insert the new callback into the list, sorted by its expiration. This is // almost the same as the code in `scheduleCallback`, except the callback // is inserted into the list *before* callbacks of equal expiration instead // of after. if (firstCallbackNode = null) { // This is the first callback in the list. firstCallbackNode = continuationNode.next = continuationNode.previous = continuationNode; } else { var nextAfterContinuation = null; var node = firstCallbackNode; do { if (node.expirationTime >= expirationTime) { // This callback expires at or after the continuation. We will insert // the continuation *before* this callback. nextAfterContinuation = node; break; } node = node.next; } while (node ! firstCallbackNode);
if (nextAfterContinuation = null) { // No equal or lower priority callback was found, which means the new // callback is the lowest priority callback in the list. nextAfterContinuation = firstCallbackNode; } elseif (nextAfterContinuation = firstCallbackNode) { // The new callback is the highest priority callback in the list. firstCallbackNode = continuationNode; ensureHostCallbackIsScheduled(); }
Find the node pointed to by’firstCallbackNode 'from the bidirectional ring of task nodes, take it out of the ring and execute
If the result of the execution is still a function, that is, the callback returns a function, then create a callbackNode with the returned function, and then insert it into the ring. The specific location is before the’nextAfterContinuation ‘node in the code. How to get the’nextAfterContinuation’ can be seen in the above source code
One thing to note here is that if’nextAfterContinuation ‘is’firstCallbackNode’, that is to say, when the new task node created by the function returned by the current callback needs to be inserted before’firstCallbackNode ‘, it needs to execute’ensureHostCallbackIsScheduled’
ensureHostCallbackIsScheduled
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
functionensureHostCallbackIsScheduled() { if (isExecutingCallback) { // Don't schedule work yet; wait until the next time we yield. return; } // Schedule the host callback using the earliest expiration in the list. var expirationTime = firstCallbackNode.expirationTime; if (!isHostCallbackScheduled) { isHostCallbackScheduled = true; } else { // Cancel the existing host callback. cancelHostCallback(); } requestHostCallback(flushWork, expirationTime); }
This function is to do nothing if a callback is currently being executed, that is, ‘isExecutingCallback’ is true. Otherwise, cancel the current hostcallback and add flushWork to the task queue
functionflushWork(didTimeout) { // Exit right away if we're currently paused
if (enableSchedulerDebugging && isSchedulerPaused) { return; }
isExecutingCallback = true; const previousDidTimeout = currentDidTimeout; currentDidTimeout = didTimeout; try { if (didTimeout) { // Flush all the expired callbacks without yielding. while ( firstCallbackNode ! null && !(enableSchedulerDebugging && isSchedulerPaused) ) { // TODO Wrap in feature flag // Read the current time. Flush all the callbacks that expire at or // earlier than that time. Then read the current time again and repeat. // This optimizes for as few performance.now calls as possible. var currentTime = getCurrentTime (); if (firstCallbackNode.expirationTime <= currentTime) { do { flushFirstCallback(); } while ( firstCallbackNode ! null && firstCallbackNode.expirationTime <= currentTime && !(enableSchedulerDebugging && isSchedulerPaused) ); continue; } break; } } else { // Keep flushing callbacks until we run out of time in the frame. if (firstCallbackNode ! null) { do { if (enableSchedulerDebugging && isSchedulerPaused) { break; } flushFirstCallback(); } while (firstCallbackNode ! null && !shouldYieldToHost()); } } } finally { isExecutingCallback = false; currentDidTimeout = previousDidTimeout; if (firstCallbackNode ! null) { // There's still work remaining. Request another callback. ensureHostCallbackIsScheduled(); } else { isHostCallbackScheduled = false; } // Before exiting, flush all the immediate work that was scheduled. flushImmediateWork(); } }
This code directly looks at the comments, and the role of’flushImmediateWork 'is actually mentioned in the comments, running all tasks with immediate priority
// Insert the new callback into the list, ordered first by expiration, then // by insertion. So the new callback is inserted any other callback with // equal expiration. if (firstCallbackNode = null) { // This is the first callback in the list. firstCallbackNode = newNode.next = newNode.previous = newNode; ensureHostCallbackIsScheduled(); } else { var next = null; var node = firstCallbackNode; do { if (node.expirationTime > expirationTime) { // The new callback expires before this one. next = node; break; } node = node.next; } while (node ! firstCallbackNode);
if (next = null) { // No callback with a later expiration was found, which means the new // callback has the latest expiration in the list. next = firstCallbackNode; } elseif (next = firstCallbackNode) { // The new callback has the earliest expiration in the entire list. firstCallbackNode = newNode; ensureHostCallbackIsScheduled(); }
functionunstable_cancelCallback(callbackNode) { var next = callbackNode.next; if (next = null) { // Already cancelled. return; }
if (next = callbackNode) { // This is the only scheduled callback. Clear the list. firstCallbackNode = null; } else { // Remove the callback from its position in the list. if (callbackNode = firstCallbackNode) { firstCallbackNode = next; } var previous = callbackNode.previous; previous.next = next; next.previous = previous; }
This is also relatively simple, which is to find the task to cancel from the two-way loop of callbackNode, and then remove it from the loop.
The main changes in the latest version of Scheduler are: instead of using doubly linked list to store all tasks, two minimum heaps are used, called timerQueue and taskQueue respectively. The task will be placed in the timerQueue first. After the task in the taskQueue is completed, check if there are any expired tasks in the timerQueue. If there are, put them in the taskQueue, and then start traversing the tasks in the taskQueue again. If you need to yield during the traversal process, you will temporarily exit, and then open the setting macro task to continue execution next time