React source code analysis and rendering mechanism

Preparation

For the sake of explanation, suppose we have the following code:

function App(){
  const [count, setCount] = useState(0)

  useEffect(() => {
    setCount(1)
  }, [])

  const handleClick = () => setCount(count => count++)

  return (
    <div>
        Brave Niu Niu,        <span>Not afraid of difficulties</span>
        <span onClick={handleClick}>{count}</span>
    </div>
  )
}

ReactDom.render(<App />, document.querySelector('#root'))

In a React project, this jsx syntax is first compiled into:

React.createElement("App", null)
or
jsx("App", null)

The compilation method is not detailed here. If you are interested, you can refer to:

babel online compilation

new jsx transform

After the jsx syntax is converted, it will be converted to React element by creatElement or jsx api as the first parameter of ReactDom.render() for rendering.

In the last article Fiber, we mentioned that a React project will have a fiberRoot and one or more rootFiber s. fiberRoot is the root node of a project. Before starting the real rendering, we will create fiberRoot based on rootDOM, and fiberRoot.current = rootFiber, where rootFiber is the root node of the currentfiber tree.

if (!root) {
    // Initial mount
    root = container._reactRootContainer = legacyCreateRootFromDOMContainer(container, forceHydrate);
    fiberRoot = root._internalRoot;
}

After creating fiberRoot and rootFiber, we don't know what to do next, because they have nothing to do with our <App /> function component. At this time, React starts to create update, and assigns the first parameter of ReactDom.render(), which is the React element created based on <App />, to update.

var update = {
    eventTime: eventTime,
    lane: lane,
    tag: UpdateState,
    payload: null,
    callback: element,
    next: null
  };

With this update, you also need to add it to the update queue and wait for subsequent updates. It is necessary to talk about the creation process of this queue here. This creation operation is applied many times in React.

var sharedQueue = updateQueue.shared;
  var pending = sharedQueue.pending;

  if (pending === null) {   
  // There is only one update when mount, close the loop directly
    update.next = update;
  } else {   
  // When updating, point the next of the latest update to the last update, and the next of the last update points to the latest update to form a closed loop
    update.next = pending.next;
    pending.next = update;
  }
  // pending points to the latest update, so when we traverse the update list, pending.next points to the first inserted update.
  sharedQueue.pending = update;   

I abstracted the above code a bit. The update queue is a circular linked list structure. Every time an update is added to the end of the linked list, the pointer will point to this update, and this update.next will point to the first update:

As mentioned in the previous article, React will have at most two fiber trees at the same time, one is the currentfiber tree and the other is the workInProgressfiber tree. The root node of the currentfiber tree has been created above, and the root node of the workInProgressfiber tree will be created by copying fiberRoot.current.

At this point, the previous preparations are completed, and then enter the main dish, start the loop traversal, generate the fiber tree and dom tree, and finally render it to the page. Related reference videos: into learning

render stage

This stage does not refer to rendering the code on the page, but draws the corresponding fiber tree and dom tree based on our code.

workloopSync

function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

In this loop, the corresponding child will be continuously found according to workInProgress as the workInProgress of the next loop until the leaf node is traversed, that is, depth-first traversal. In performUnitOfWork, the following beginWork will be executed.

beginWork

Briefly describe the work of beginWork, which is to generate a fiber tree.

Generate the fiber node of <App /> based on the root node of workInProgress and use this node as the child of the root node, and then generate the fiber node of <div /> based on the fiber node of <App /> and use it as the fiber node of <App />. child, and so on until the bottom of the Niu Niu text.

Note that in the above flowchart, updateFunctionComponent will execute a renderWithHooks function, which will execute the App() function component, where all hooks in the function component will be initialized, which is the useState() of the above example code.

When the Niuniu text is traversed, there is no child under it. At this time, the work of beginWork will come to an end temporarily. The reason why it is said to be temporary is because when the completedWork is completed, if the traversed fiber node has sibling s, it will go to beginWork again.

completeWork

After traversing the Niuniu text, it will enter this completeWork.

Here, we briefly describe the work of completeWork, which is to generate a dom tree.

The corresponding dom node is generated based on the fiber node, and the dom node is used as the parent node, and the previously generated dom node is inserted into the currently created dom node. And will search upward based on the incomplete workInProgressfiber tree generated in beginWork until fiberRoot. In this upward process, it will judge whether there is a sibling. If there is, it will go to beginWork again, and if not, continue upward. In this way, when the root node is reached, a complete dom tree is generated.

As an extra mention, there is such a piece of code in completeWork

if (flags > PerformedWork) {
  if (returnFiber.lastEffect !== null) {
    returnFiber.lastEffect.nextEffect = completedWork;
  } else {
    returnFiber.firstEffect = completedWork;
  }

  returnFiber.lastEffect = completedWork;
}

Explain, flags > PerformedWork means that the current fiber node has side effects, and this fiber node needs to be added to the effectList list of the parent fiber.

commit phase

The main job at this stage is to deal with side effects. The so-called side effects are uncertain operations, such as: insert, replace, delete DOM, and the callback function of useEffect()hook will be used as side effects.

commitWork

Preparation

Before commitWork, the workInProgressfiber tree generated in workloopSync will be assigned to the finishedWork property of fiberRoot.

var finishedWork = root.current.alternate;  // workInProgress fiber tree
root.finishedWork = finishedWork;  // The root here is fiberRoot
root.finishedLanes = lanes;
commitRoot(root);

We mentioned above that if a fiber node has side effects, it will be recorded to the nextEffect of the parent fiber's lastEffect.
In the following code, if the fiber tree has side effects, the rootFiber.firstEffect node is used as the first side effect firstEffect, and the effectList is formed into a closed loop.

var firstEffect;
// Determine whether the current rootFiber tree has side effects
if (finishedWork.flags > PerformedWork) {

    // The purpose of the following code is to form a closed loop of this effectList list
    if (finishedWork.lastEffect !== null) {
      finishedWork.lastEffect.nextEffect = finishedWork;
      firstEffect = finishedWork.firstEffect;
    } else {
      firstEffect = finishedWork;
    }
} else {
// This rootFiber tree has no side effects
firstEffect = finishedWork.firstEffect;
}

before mutation

Briefly describe the work of the previous stage of mutation:

  • Handle autoFocus and blur logic after DOM node rendering/deletion;
  • Call getSnapshotBeforeUpdate, fiberRoot and ClassComponent will go here;
  • dispatch useEffect(async);
    In the stage before mutation, traverse the effectList list and execute the commitBeforeMutationEffects method.
do {  // before mutation

  invokeGuardedCallback(null, commitBeforeMutationEffects, null);

} while (nextEffect !== null);

We go to the commitBeforeMutationEffects method, I will simplify the code:

function commitBeforeMutationEffects() {
  while (nextEffect !== null) {
    var current = nextEffect.alternate;
    // Handle autoFocus and blur logic after DOM node rendering/deletion;
    if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null){...}

    var flags = nextEffect.flags;
    // Calling getSnapshotBeforeUpdate, fiberRoot and ClassComponent will go here
    if ((flags & Snapshot) !== NoFlags) {...}
    // dispatch useEffect (async)
    if ((flags & Passive) !== NoFlags) {
      // The rootDoesHavePassiveEffects variable indicates whether there are currently side effects
      if (!rootDoesHavePassiveEffects) {
        rootDoesHavePassiveEffects = true;
        // Create a task and add it to the task queue, which will be triggered after the layout stage
        scheduleCallback(NormalPriority$1, function () {
          flushPassiveEffects();
          return null;
        });
      }
    }
    // Continue to traverse the next effect
    nextEffect = nextEffect.nextEffect;
    }
}

According to our sample code, we focus on the third thing, scheduling useEffect (note that this is scheduling, and will not be executed immediately).

The main job of scheduleCallback is to create a task:

var newTask = {
    id: taskIdCounter++,
    callback: callback,  //The callback function passed in by the above code
    priorityLevel: priorityLevel,
    startTime: startTime,
    expirationTime: expirationTime,
    sortIndex: -1
};

There is a logic in it that will judge startTime and currentTime. If startTime > currentTime, the task will be added to the timed task queue timerQueue, otherwise, it will be added to the task queue taskQueue, and task.sortIndex = expirationTime.

mutation

Briefly describe the work of the mutation phase is responsible for dom rendering.

Differentiate fiber.flags and perform different operations, such as: reset text, reset ref, insert, replace, delete dom nodes.

Like the previous stage of mutation, it also traverses the effectList linked list and executes the commitMutationEffects method.

do {    // mutation dom rendering
  invokeGuardedCallback(null, commitMutationEffects, null, root, renderPriorityLevel);

} while (nextEffect !== null);

Take a look at the main work of commitMutationEffects:

function commitMutationEffects(root, renderPriorityLevel) {
  // TODO: Should probably move the bulk of this function to commitWork.
  while (nextEffect !== null) {     // Traverse EffectList
    setCurrentFiber(nextEffect);
    // Processed separately according to flags
    var flags = nextEffect.flags;
    // Reset text nodes based on ContentReset flags
    if (flags & ContentReset) {...}
    // update ref
    if (flags & Ref) {...}

    var primaryFlags = flags & (Placement | Update | Deletion | Hydrating);

    switch (primaryFlags) {
      case Placement:   // insert dom
        {...}

      case PlacementAndUpdate:    //insert dom and update dom
        {
          // Placement
          commitPlacement(nextEffect);
          nextEffect.flags &= ~Placement; // Update
          var _current = nextEffect.alternate;
          commitWork(_current, nextEffect);
          break;
        }

      case Hydrating:     //SSR
        {...}

      case HydratingAndUpdate:      // SSR
        {...}

      case Update:      // update dom
        {...}

      case Deletion:    // delete dom
        {...}
    }

    resetCurrentFiber();
    nextEffect = nextEffect.nextEffect;
  }
}

According to our sample code, PlacementAndUpdate will be used here. The first is the commitPlacement(nextEffect) method. After a series of judgments, the dom tree we generated will be inserted into the rootDOM node.

function appendChildToContainer(container, child) {
  var parentNode;

  if (container.nodeType === COMMENT_NODE) {
    parentNode = container.parentNode;
    parentNode.insertBefore(child, container);
  } else {
    parentNode = container;
    parentNode.appendChild(child);    // Insert the entire dom directly into the root as a child node
  }
}

At this point, the code is finally rendered on the page. The following commitWork method is to execute things related to useLayoutEffect(), which is not important here. The article will arrange it later. We only need to know that here is the effect unmount of the last update.

fiber tree switch

Before talking about the layout stage, let's take a look at this line of code

root.current = finishedWork  // Turn the `workInProgress`fiber tree into a `current` tree

This line of code is between the mutation and layout stages. In the mutation phase, the currentfiber tree still points to the fiber tree before the update, so the DOM acquired in the lifecycle hook is the one before the update, and the hooks similar to componentDidMount and compentDidUpdate are executed in the layout phase, so that you can get the The updated DOM is manipulated.

layout

Briefly describe the work of the layout stage:

  • Call lifecycle or hooks related operations
  • assign ref

Like the previous stage of mutation, it also traverses the effectList linked list and executes the commitLayoutEffects method.

do {   // Call life cycle and hook related operations, assign ref
   invokeGuardedCallback(null, commitLayoutEffects, null, root, lanes);
} while (nextEffect !== null);

Take a look at the commitLayoutEffects method:

function commitLayoutEffects(root, committedLanes) {
  while (nextEffect !== null) {
    setCurrentFiber(nextEffect);
    var flags = nextEffect.flags;
    // Call lifecycle or hook function
    if (flags & (Update | Callback)) {
      var current = nextEffect.alternate;
      commitLifeCycles(root, current, nextEffect);
    }

    {
      // Get dom instance, update ref
      if (flags & Ref) {
        commitAttachRef(nextEffect);
      }
    }

    resetCurrentFiber();
    nextEffect = nextEffect.nextEffect;
  }
}

It should be mentioned that the callback of useLayoutEffect() will be executed in the commitLifeCycles method, and the callback of useEffect() will be dispatched in the schedulePassiveEffects method of commitLifeCycles. From here you can see the difference between useLayoutEffect() and useEffect():

  • The last update destruction function of useLayoutEffect is destroyed in the mutation phase, and the update callback function is executed synchronously in the layout phase after dom rendering;
  • useEffect will create a scheduling task in the stage before mutation. In the layout stage, the destroy function and callback function will be added to the pendingPassiveHookEffectsUnmount and pendingPassiveHookEffectsMount queues. Finally, its last update destroy function and this update callback function are executed asynchronously after the layout stage. ; To be clear, none of their updates will block dom rendering.

after layout

Remember these lines of code in the pre-mutation stage?

// Create a task and add it to the task queue, which will be triggered after the layout stage
scheduleCallback(NormalPriority$1, function () {
  flushPassiveEffects();
  return null;
});

Here, useEffect() is scheduled, and this callback function will be executed after the layout stage. At this time, the last update destruction function of useEffect and the current update callback function will be processed.

Summarize

After reading this article, we can figure out the following questions:

  1. What is the rendering process of React?
  2. What does React's beginWork do?
  3. What does React's completeWork do?
  4. What does React's commitWork do?
  5. What is the difference between useEffect and useLayoutEffect?
  6. When to call the destruction functions and update callbacks of useEffect and useLayoutEffect?

Tags: React

Posted by adriaan on Tue, 18 Oct 2022 04:26:58 +0300