jeremygo

jeremygo

我是把下一颗珍珠串在绳子上的人

React Fiber

React enabled a brand new architecture, codenamed Fiber, starting from v16. It greatly improves performance compared to previous implementations. This article will analyze the internal architecture of Fiber in conjunction with an example.

Overview#

Let's start with an example:

class ClickCounter extends React.Component {
    constructor (props) {
        super(props)
        this.state = { count: 0 }
        this.handleClick = this.handleClick.bind(this)
    }
    handleClick () {
        this.setState(state => {
            return {
                count: state.count++
            }
        })
    }
    render () {
        return [
            <button key="1" onClick={this.handleClick}>Update counter</button>,
            <span key="2">{this.state.count}</span>
        ]
    }
}

After we click the Update counter button, React will do the following:

  • Update the count property of the ClickCounter component's state.
  • Retrieve and compare the child components of the ClickCounter component and their props.
  • Update the props of the span element.

Now let's delve into this process ~

From React Elements to Fiber Nodes#

The UI rendered by each React component is done through the render method. The JSX syntax we use is compiled into calls to the React.createElement method. For example, the above UI structure:

<button key="1" onClick={this.onClick}>Update counter</button>
<span key="2">{this.state.count}</span>

is transformed into the following code:

React.createElement(
	'button',
    {
        key: '1',
        onClick: this.onClick
    },
    'Update counter'
),
React.createElement(
	'span',
    {
        key: '2'
    },
    this.state.count
)

This will produce the following two data structures:

[
    {
        $$typeof: Symbol(react.element),
        type: 'button',
        key: "1",
        props: {
            children: 'Update counter',
            onClick: () => { ... }
        }
    },
    {
        $$typeof: Symbol(react.element),
        type: 'span',
        key: "2",
        props: {
			children: 0
        }
    }
]

A brief explanation of the above properties:

  • $$typeof: Uniquely identifies as a React element.
  • type, key, props describe the properties corresponding to the element.
  • Other properties like ref are not discussed here.

The ClickCounter component has no props or key:

{
    $$typeof: Symbol(react.element),
    key: null,
    props: {},
    ref: null,
    type: ClickCounter
}

Fiber Nodes#

During the coordination algorithm call, each render transformed React element will be merged into the Fiber node tree. Each React element has a corresponding Fiber node. Unlike React elements, fibers are not recreated with each render; they are mutable data structures.

Different types of React elements have corresponding types to define the work that needs to be done.

From this perspective, Fiber can be understood as a data structure that shows what work needs to be done. The Fiber architecture also provides a convenient way to track, schedule, pause, and stop work.

Once a Fiber node is created for the first time, subsequent updates will reuse the Fiber node and only update the necessary properties. If a key is defined, React will also choose whether to simply move the node to optimize performance.

After the transformation, we have the following tree structure:

fiber-tree

All Fiber nodes are connected through a linked list and have three properties: child, sibling, and return. (The design of Fiber nodes will be explained in detail later).

Current and workInProgress Trees#

The tree after transformation is the current tree. When React starts updating, it traverses the current tree while creating corresponding nodes to form the workInProgress tree. Once all updates and related operations are completed, React will render the contents of the workInProgress tree to the screen, and then the workInProgress tree becomes the current tree.

The workInProgress tree is also referred to as the finishedWork tree.

A key function in the source code is:

function updateHostComponent (current, workInProgress, renderExpirationTime) { ... }

Side Effect Linear List#

In addition to regular update operations, React also defines "side effect" operations: fetching data, subscription operations, or manually changing the DOM structure. These operations are encoded in the Fiber nodes as the effectTag field.

ReactSideEffectTags.js defines the operations that will be performed after instance updates:

  • For DOM components: Includes adding, updating, or removing elements.
  • For class components: Includes updating refs and calling componentDidMount, componentDidUpdate lifecycle methods.
  • ......

To quickly handle updates, React employs many efficient measures, one of which is establishing a linear list of Fiber nodes. Traversing the linear list is faster than traversing the tree. The purpose of establishing the linear list is to mark nodes with DOM updates or other side effects. It is a subset of the finishedWork tree, connected through the nextEffect property.

For example: When our update causes c2 to be inserted into the DOM, while d2 and c1 change their property values, and b2 calls a lifecycle method, the side effect linear list will link them together so that React can directly skip other nodes later:

effect-list

Next, let's delve into the algorithm executed by Fiber ~

Two Phases#

React performs work in two main phases: render and commit.

In the render phase, React applies updates to components planned through setState and React.render and identifies the parts that need to be updated on the UI. If it is the initial render, React will create a new Fiber node, and in subsequent updates, it will reuse existing Fiber nodes for updates. This phase ultimately produces a tree of Fiber nodes marked with side effects. The side effects describe what needs to be done in the upcoming commit phase.

The work of the render phase can be processed asynchronously. During this phase, React can handle one or more Fiber nodes based on (browser) idle time, then pause to cache completed work and respond to some events, and can continue processing from where it paused or discard completed parts and start over as needed. This can happen because processing during this phase does not lead to any user-visible changes, such as DOM updates. In simple terms, this phase is interruptible.

During the render phase, some lifecycle methods are called:

  • [Unsafe] componentWillMount (deprecated)
  • [Unsafe] componentWillReceiveProps (deprecated)
  • getDerivedStateFromProps
  • shouldComponentUpdate
  • [Unsafe] componentWillUpdate (deprecated)
  • render

Starting from v16.3, some legacy lifecycle methods have been marked as unsafe, meaning they are methods that are not recommended for use by the official documentation. They will trigger warnings in future v16.x versions and will be removed in version 17.0. For more details, see Update on Async Rendering.

Why did the official mark them as unsafe? Because the render phase does not produce side effects like DOM updates, React can update components asynchronously, but these unsafe methods are often misused by developers, leading to side-effect code being placed in these methods, causing asynchronous rendering errors.

In contrast, the commit phase is always synchronous because the processing in this phase leads to user-visible changes, such as DOM updates, so React needs to complete them in one go.

During the commit phase, these lifecycle methods are called:

  • getSnapshotBeforeUpdate
  • componentDidMount
  • componentDidUpdate
  • componentWillUnmount

Since these methods are executed in the synchronous commit phase, they can contain side-effect code.

Render Phase#

The coordination algorithm always uses the renderRoot function starting from the top HostRoot fiber node, but it will skip already processed fiber nodes until it encounters a node with unfinished work. For example, if you call setState deep in the component tree, React will quickly skip the parent components from the top until it reaches the component that called the setState method.

Main Steps of the Work Loop#

All fiber nodes are processed in the work loop function. Let's take a look at the implementation of the synchronous part of the loop:

function workLoop (isYieldy) {
    if (!isYieldy) {
        while (nextUnitOfWork !== null) {
            nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
        }
    } else {
        ...
    }
}

nextUnitOfWork contains a reference to the node from the workInProgress tree that needs to be processed. When React traverses the Fiber tree, it uses this variable to know if there are other unfinished fiber nodes. After processing the current fiber node, this variable will point to the next fiber node in the tree or null. At this point, React will exit the work loop and prepare to commit changes.

When traversing the Fiber tree, four main functions are called to initialize or complete work:

Here is a visual animation showing how they are used, with child nodes being prioritized for completion.

workloop

First, let's look at the first two functions performUnitOfWork and beginWork:

function performUnitOfWork (workInProgress) {
    let next = beginWork(workInProgress)
    if (next === null) {
        next = completeUnitOfWork(workInProgress)
    }
    return next
}
function beginWork (workInProgress) {
    console.log('work performed for ' + workInProgress.name)
    return workInProgress.child
}

The performUnitOfWork function receives a fiber node from the workInProgress tree and starts work by calling the beginWork function. This function performs all necessary operations for a fiber node (simplified here by just printing the fiber node's name to indicate completion). The beginWork function always returns a pointer to the next child node or null.

If there is a next child node, it will be assigned to the variable nextUnitOfWork in the workLoop function. If not, React knows it has reached the end of this node branch and can complete the current node. Once a node is completed, it will process the work of sibling nodes and then backtrack to the parent node. Let's look at the completeUnitOfWork function:

function completeUnitOfWork (workInProgress) {
    while (true) {
        let returnFiber = workInProgress.return
        let siblingFiber = workInProgress.sibling
        
        nextUnitOfWork = completeWork(workInProgress)
        
        if (siblingFiber !== null) {
            return siblingFiber
        } else if (returnFiber !== null) {
            workInProgress = returnFiber
            continue
        } else {
            return null
        }
    }
}

function completeWork (workInProgress) {
    console.log('work completed for ' + workInProgress.name)
    return null
}

Commit Phase#

This phase starts with the call to the completeRoot function. Here, React updates the DOM and calls mutable lifecycle methods.

In this phase, React has the current and finishedWork (workInProgress) trees, as well as the side effect linear list.

The side effect linear list tells us which nodes need to be inserted, updated, or deleted, or which components need to call their lifecycle methods. This is what the entire commit phase will traverse and process.

The main function running in the commit phase is commitRoot, which does the following:

  • Calls the getSnapshotBeforeUpdate method of nodes marked with the Snapshot side effect.
  • Calls the componentWillUnmount method of nodes marked with the Deletion side effect.
  • Executes all DOM insertion, updating, and deletion operations.
  • Sets the finishedWork tree as current.
  • Calls the componentDidMount method of nodes marked with the Placement side effect.
  • Calls the componentDidUpdate method of nodes marked with the Update side effect.

Here is a simplified version of the function describing the above process:

function commitRoot (root, finishedWork) {
    commitBeforeMutationLifecycles()
    commitAllHostEffects()
    root.current = finishedWork
    commitAllLifeCycles()
}

Each sub-function implements a loop to traverse the side effect linear list and check the type of side effect to update:

  • commitBeforeMutationLifecycles: Traverses and checks if nodes have the Snapshot side effect mark.

    function commitBeforeMutationLifecycles () {
        while (nextEffect !== null) {
            const effectTag = nextEffect.effectTag
            if (effectTag & Snapshot) {
                const current = nextEffect.alternate
                commitBeforeMutationLifeCycles(current, nextEffect)
            }
            nextEffect = nextEffect.nextEffect
        }
    }
    
  • commitAllHostEffects: The place where DOM updates are executed, defining the operations that need to be performed on the nodes and executing them.

    function commitAllHostEffects () {
        switch (primaryEffectTag) {
            case Placement: {
                commitPlacement(nextEffect)
                ...
            }
            case PlacementAndUpdate: {
                commitPlacement(nextEffect)
                commitWork(current, nextEffect)
                ...
            }
            case Update: {
                commitWork(current, nextEffect)
                ...
            }
            case Deletion: {
                commitDeletion(nextEffect)
                ...
            }
        }
    }
    

    Interestingly, in the commitDeletion function, React calls the componentWillUnmount method.

  • commitAllLifecycles: React calls the remaining lifecycle methods componentDidMount and componentDidUpdate.

Now that we understand the main execution process of the coordination algorithm, let's focus on the design of Fiber nodes: they are all connected through a linked list and have three properties: child, sibling, and return. Why is this design chosen?

Design Principles#

We already know that the Fiber architecture has two main phases: render and commit.

In the render phase, React traverses the entire component tree and performs a series of operations, all of which are executed internally in Fiber. Different element types have different work to handle. As Andrew said:

When dealing with UI, a significant issue is that if a large amount of work is executed simultaneously, it can cause frame drops in animations.

If React synchronously traverses the entire component tree and processes work for each component, it can easily run over 16ms, leading to visual stuttering. However, there is no need to adopt a synchronous approach. Some key points in React's design principles are:

  • Not all UI updates need to take effect immediately (which may cause frame drops).
  • Different types of updates have different priorities (animation responses are much higher than data fetching).
  • A push-based scheme is determined by the developer on how to schedule; while a pull-based scheme is determined by React on how to schedule.

Based on these principles, the architecture we need to implement should:

  • Pause tasks and be able to resume later.
  • Set different priorities for different tasks.
  • Reuse already completed tasks.
  • Terminate tasks that are no longer needed.

So what can help us achieve this?

Modern browsers (and React Native) have implemented APIs that help solve this problem: requestIdleCallback.

This global function can be used to queue functions to be called during the browser's idle time. Here's a simple look at its usage:

requestIdleCallback((deadline) => {
    console.log(deadline.timeRemaining(), deadline.didTimeout)
})

deadline.timeRemaining() shows how much time is available to do any work, and deadline.didTimeout indicates whether all allocated time has been used up. timeRemaining changes immediately after the browser completes certain tasks, so it must be checked continuously.

requestIdleCallback is actually a bit too strict, often leading to insufficient smooth UI rendering, so the React team had to reimplement their own version.

React calls requestIdleCallback to schedule work, placing everything to be executed into the performWork function:

requestIdleCallback((deadline) => {
    while ((deadlne.timeRemaining() > 0 || deadline.didTimeout) && nextComponent) {
        nextComponent = performWork(nextComponent)
    }
})

To make good use of the API for processing work, we need to break down rendering work into multiple incremental units. To solve this problem, React reimplemented the algorithm, shifting from a synchronous recursive model relying on the built-in stack to an asynchronous model with linked lists and pointers. As Andrew wrote:

If we only rely on the built-in stack, it will keep working until the stack is empty. Wouldn't it be better if we could freely interrupt the stack and manually manipulate stack frames? This is the purpose of React Fiber. Fiber is a stack specifically reimplemented for React components, and you can think of a single fiber as a virtual stack frame.

Recursive Traversal#

React's official documentation describes the previous recursive process:

By default, when recursively iterating over the child nodes of a DOM node, React will iterate over both child lists simultaneously and generate a change when differences occur.

Each recursive call adds a frame to the stack, which is executed synchronously. We would have a component tree like this:

recursivetree

The recursive approach is very intuitive, but as we mentioned, it has limitations. The biggest issue is that it cannot split tasks and cannot flexibly control tasks. Therefore, React proposed a new linked list tree traversal algorithm, making it possible to pause traversal and terminate the growing stack.

Linked List Traversal#

Sebastian Markbage describes this algorithm here. To implement this algorithm, we need a data structure with these three properties:

  • child: Points to the first child node
  • sibling: Points to the first sibling node
  • return: Points to the parent node

Based on this data structure, we have such a component structure:

linkedlistfiber

With this structure, we can replace the browser's stack implementation with our own.

If you want to see the detailed code implementations of the two traversals, you can refer to The how and why on React’s usage of linked list in Fiber to walk the component’s tree.

Finally, let's take a look at the detailed structure of a Fiber node, using ClickCounter and span as examples:

{
    stateNode: new ClickCounter,
    type: ClickCounter,
    alternate: null,
    key: null,
    updateQueue: null,
    memoizedState: { count: 0 },
    pendingProps: {},
    memoizedProps: {},
    tag: 1,
    effectTag: 0,
    nextEffect: null
}
{
    stateNode: new HTMLSpanElement,
    type: "span",
    alternate: null,
    key: "2",
    updateQueue: null,
    memoizedState: null,
    pendingProps: {children: 0},
    memoizedProps: {children: 0},
    tag: 5,
    effectTag: 0,
    nextEffect: null
}

alternate, effectTag, and nextEffect have been explained earlier. Let's look at the other properties:

  • stateNode: Refers to class components, DOM nodes, or other React element types related to the fiber node.
  • type: Describes the component corresponding to this fiber.
  • tag: Defines the type of fiber, determining what work needs to be processed. (The processing function is createFiberFromTypeAndProps)
  • updateQueue: A queue of state updates, callback functions, and DOM updates.
  • memoizedState: Used to create the output state of the fiber. When updating, it reflects the state currently rendered on the screen.
  • pendingProps: In contrast to memoizedProps, the former is set when execution begins, while the latter is set when it ends. If the incoming pendingProps equals memoizedProps, it indicates that this fiber's previous output can be reused, avoiding unnecessary work.
  • key: As a unique identifier, it helps React determine which items have changed, been added, or removed. (See lists and keys)

There is also a video worth watching about the operation of Fiber: Lin Clark - A Cartoon Intro to Fiber - React Conf 2017.

References:

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.