Skip to main content

Fully Understanding React Fiber

Free2018-01-06#Front-End#JS#React Fiber介绍#React Fiber工作原理#React Fiber源码剖析#React Fiber guide#Fiber reconciler

Known-Seek-Solve-Learn by analogy

I. Goals

Fiber is a refactoring of React's core algorithm. The product of 2 years of refactoring is the Fiber reconciler

Core goal: Expand its applicability, including animation, layout, and gestures. Divided into 5 specific goals (the last 2 are bonuses):

  • Break interruptible work into small tasks

  • Adjust priority, redo, or reuse previous (half-done) results for work in progress

  • Switch smoothly between parent and child tasks (yield back and forth) to support layout refresh during React execution

  • Support render() returning multiple elements

  • Better support for error boundaries

Since the original intention was to prevent JS from executing for too long uncontrollably (wanting manual scheduling), why does long-running JS affect interaction response and animation?

Because JavaScript runs on the browser's main thread, coincidentally running alongside style calculation, layout, and in many cases painting. If JavaScript runs for too long, it blocks these other tasks, potentially causing dropped frames.

(Quoted from Optimize JavaScript Execution)

React hopes to change this uncontrollable situation through Fiber refactoring, further improving interaction experience

P.S. For more information about Fiber goals, please see Codebase Overview

II. Key Features

Fiber's key features are as follows:

  • Incremental rendering (split rendering tasks into chunks, spread across multiple frames)

  • Ability to pause, abort, or reuse rendering tasks during updates

  • Assign priority to different types of updates

  • New foundational capabilities for concurrency

Incremental rendering is used to solve the dropped frame problem. After splitting rendering tasks, only a small segment is done each time. After completing a segment, control is returned to the main thread, instead of occupying it for a long time as before. This strategy is called cooperative scheduling, one of the 3 task scheduling strategies in operating systems (Firefox also applied this technology to real DOM)

Additionally, React's own killer feature is virtual DOM, for 2 reasons:

  • Coding UI becomes simpler (no need to care about what the browser should do, but describe the next UI to React)

  • Since DOM can be virtual, others (hardware, VR, native App) can too

React implementation is divided into 2 parts:

  • reconciler Finds the difference between two versions of UI at a certain moment. Includes the previous Stack reconciler and the current Fiber reconciler

  • renderer Plugin-based, platform-related parts. Includes React DOM, React Native, React ART, ReactHardware, ReactAframe, React-pdf, ReactThreeRenderer, ReactBlessed, etc.

This wave is a complete transformation of the reconciler, an enhancement to the killer feature

III. fiber and fiber tree

There are 3 types of instances during React runtime:

DOM Real DOM nodes
-------
Instances vDOM tree nodes maintained by React
-------
Elements Describe what the UI looks like (type, props)

Instances are created based on Elements, abstract representations of components and DOM nodes. The vDOM tree maintains component state and the relationship between components and the DOM tree

The vDOM tree is built during the initial rendering process. When updates are needed subsequently (setState()), diff the vDOM tree to get DOM changes, and apply (patch) these DOM changes to the DOM tree

The reconciler before Fiber (called Stack reconciler) recursively mount/update from top to bottom, cannot be interrupted (continuously occupies the main thread), so periodic tasks like layout and animation on the main thread, as well as interaction responses, cannot be handled immediately, affecting experience

Fiber's approach to solving this problem is to split the rendering/update process (recursive diff) into a series of small tasks. Each time, check a small part of the tree. After finishing, see if there's time to continue to the next task. If yes, continue; if not, suspend itself and continue when the main thread is free

Incremental updates require more context information. The previous vDOM tree obviously couldn't satisfy this, so the fiber tree was extended (i.e., the vDOM tree in Fiber context). The update process is constructing a new fiber tree (workInProgress tree) based on input data and the existing fiber tree. Therefore, these instances were added at the Instance layer:

DOM
    Real DOM nodes
-------
effect
    Each workInProgress tree node has an effect list
    Used to store diff results
    When the current node update is complete, merge effect list upward (queue collects diff results)
- - - -
workInProgress
    workInProgress tree is a current progress snapshot built from fiber tree during reconcile process, used for breakpoint recovery
- - - -
fiber
    fiber tree is similar to vDOM tree, used to describe context information needed for incremental updates
-------
Elements
    Describe what the UI looks like (type, props)

Note: The 2 layers on the dashed line are temporary structures, only useful during updates, not continuously maintained daily. effect refers to side effects, including DOM changes to be made

The main structure of each node on the fiber tree (each node is called a fiber) is as follows:

// fiber tree node structure
{
    stateNode,
    child,
    return,
    sibling,
    ...
}

return indicates to whom the current node should submit its results (effect list) after processing is complete

P.S. The fiber tree is actually a Singly Linked List tree structure, see react/packages/react-reconciler/src/ReactFiber.js

P.S. Note the difference between small fiber and big Fiber. The former represents nodes on the fiber tree, the latter represents React Fiber

IV. Fiber reconciler

The reconcile process is divided into 2 phases:

  1. (Interruptible) render/reconciliation Construct workInProgress tree to get changes

  2. (Uninterruptible) commit Apply these DOM changes

render/reconciliation

Based on the fiber tree, take each fiber as a work unit, construct workInProgress tree (new fiber tree under construction) node by node from top to bottom

The specific process is as follows (taking component nodes as an example):

  1. If the current node doesn't need updating, directly clone child nodes and jump to 5; if it needs updating, mark a tag

  2. Update current node state (props, state, context, etc.)

  3. Call shouldComponentUpdate(), if false, jump to 5

  4. Call render() to get new child nodes, and create fibers for child nodes (the creation process tries to reuse existing fibers as much as possible, child node additions/deletions also happen here)

  5. If no child fiber is produced, this work unit ends, merge effect list to return, and take the current node's sibling as the next work unit; otherwise take child as the next work unit

  6. If there's no remaining available time, wait until the next time the main thread is idle before starting the next work unit; otherwise, start immediately

  7. If there are no more work units (returned to the root node of workInProgress tree), phase 1 ends, enter pendingCommit state

This is actually a work loop of 1-6, with 7 as the exit. The work loop does one thing at a time, then checks if it needs to catch its breath after finishing. When the work loop ends, the effect list on the root node of workInProgress tree is all collected side effects (because each one is merged upward after completion)

So, the process of building workInProgress tree is the diff process. Schedule execution of a set of tasks through requestIdleCallback. After completing each task, check back if there are any cutting-in tasks (more urgent ones). After completing each set of tasks, return time control to the main thread, and continue building workInProgress tree until the next requestIdleCallback callback

P.S. The reconciler before Fiber was called Stack reconciler because these scheduling context information was saved by the system stack. Although completing it all at once before made emphasizing the stack meaningless, naming it was just to facilitate distinguishing from Fiber reconciler

requestIdleCallback

Notify the main thread, tell me when you're not busy, I have a few not-so-urgent things to do

Specific usage is as follows:

window.requestIdleCallback(callback[, options])
// Example
let handle = window.requestIdleCallback((idleDeadline) => {
    const {didTimeout, timeRemaining} = idleDeadline;
    console.log(`Timed out? ${didTimeout}`);
    console.log(`Remaining available time ${timeRemaining.call(idleDeadline)}ms`);
    // do some stuff
    const now = +new Date, timespent = 10;
    while (+new Date < now + timespent);
    console.log(`Spent ${timespent}ms doing stuff`);
    console.log(`Remaining available time ${timeRemaining.call(idleDeadline)}ms`);
}, {timeout: 1000});
// Output result
// Timed out? false
// Remaining available time 49.535000000000004ms
// Spent 10ms doing stuff
// Remaining available time 38.64ms

Note, requestIdleCallback scheduling just hopes to achieve smooth experience, cannot absolutely guarantee anything. For example:

// do some stuff
const now = +new Date, timespent = 300;
while (+new Date < now + timespent);

If doing stuff (corresponding to lifecycle functions in React that are not controlled by React in terms of time) takes 300ms, no mechanism can guarantee smoothness

P.S. Generally remaining available time is only 10-50ms, scheduling space is not very abundant

commit

Phase 2 is done in one breath:

  1. Process effect list (including 3 types of processing: update DOM tree, call component lifecycle functions, and update ref and other internal states)

  2. Exit correctly, phase 2 ends, all updates are committed to the DOM tree

Note, it's really done in one breath (synchronous execution, cannot be stopped). The actual workload in this phase is relatively large, so try not to do heavy work in the last 3 lifecycle functions

Lifecycle hooks

Lifecycle functions are also divided into 2 phases:

// Phase 1 render/reconciliation
componentWillMount
componentWillReceiveProps
shouldComponentUpdate
componentWillUpdate

// Phase 2 commit
componentDidMount
componentDidUpdate
componentWillUnmount

Phase 1 lifecycle functions may be called multiple times. By default, they execute at low priority (one of the 6 priorities introduced later). If interrupted by high-priority tasks, they will be re-executed later

V. fiber tree and workInProgress tree

Double buffering technology (double buffering), like [nextListeners in redux](/articles/redux 源码解读/#articleHeader7), with fiber tree as main, workInProgress tree as auxiliary

Double buffering specifically means that after workInProgress tree is constructed, what you get is the new fiber tree. Then prefer the new over the old (point current pointer to workInProgress tree, discard the old fiber tree)

Benefits of doing this:

  • Can reuse internal objects (fiber)

  • Save memory allocation and GC time overhead

Each fiber has an alternate attribute, also pointing to a fiber. When creating workInProgress nodes,优先 take alternate. If not available, create one:

let workInProgress = current.alternate;
if (workInProgress === null) {
  //...this is very interesting
  workInProgress.alternate = current;
  current.alternate = workInProgress;
} else {
  // We already have an alternate.
  // Reset the effect tag.
  workInProgress.effectTag = NoEffect;

  // The effect list is no longer valid.
  workInProgress.nextEffect = null;
  workInProgress.firstEffect = null;
  workInProgress.lastEffect = null;
}

As the comments indicate, fiber and workInProgress hold references to each other. After "preferring the new over the old", the old fiber becomes the reserved space for new fiber updates, achieving the purpose of reusing fiber instances

P.S. There are some interesting tricks in the source code, such as bitwise operations on tags

VI. Priority Strategy

Each work unit has 6 priorities when running:

  • synchronous Same operation as the previous Stack reconciler, execute synchronously

  • task Execute before next tick

  • animation Execute before the next frame

  • high Execute immediately in the near future

  • low Slight delay (100-200ms) is also okay

  • offscreen Execute only during the next render or scroll

synchronous is used for first screen (initial rendering), requires as fast as possible, regardless of whether it blocks the UI thread. animation is scheduled through requestAnimationFrame, so the animation process can start immediately in the next frame; the latter 3 are all executed by requestIdleCallback callbacks; offscreen refers to currently hidden, off-screen (invisible) elements

High priority examples include keyboard input (hope to get immediate feedback), low priority examples include network requests, displaying comments, etc. Additionally, urgent events are allowed to cut in line

This priority mechanism has 2 problems:

  • How to execute lifecycle functions (may be frequently interrupted): No guarantee on trigger order and count

  • starvation (low priority starves): If there are many high-priority tasks, then low-priority tasks have no chance to execute at all (they starve to death)

There's an official example for the lifecycle function problem:

low A
componentWillUpdate()
---
high B
componentWillUpdate()
componentDidUpdate()
---
restart low A
componentWillUpdate()
componentDidUpdate()

The first problem is being solved (not yet solved). The lifecycle problem will break some existing Apps, bringing difficulties to smooth upgrades. The Fiber team is working hard to find an elegant upgrade path

The second problem is alleviated by reusing completed operations as much as possible (reusing work where it can). Sounds like they're also trying to find a solution

These two problems themselves are not easy to solve, it's just a matter of to what extent. For example, the first problem, if component lifecycle functions mix in too many side effects, there's no way to solve it without damage. These problems will bring some resistance to upgrading Fiber, but they are absolutely not unsolvable (to take a step back, if the new features have enough attraction, everyone will find their own way to solve the first problem)

VII. Summary

Known

React is not suitable for some scenarios requiring high response experience, such as animation, layout, and gestures

The root cause is that once rendering/updating process starts, it cannot be interrupted, continuously occupying the main thread. The main thread is busy executing JS, has no time for other tasks (layout, animation), causing dropped frames, delayed responses (even no response), and other poor experiences

Seek

A mechanism that can thoroughly solve the problem of long-term main thread occupation, not only able to deal with immediate problems, but also have long-term significance

The "fiber" reconciler is a new effort aiming to resolve the problems inherent in the stack reconciler and fix a few long-standing issues.

Solve

Split the rendering/updating process into small chunks of tasks, control time through reasonable scheduling mechanisms (finer granularity, stronger control)

Then, face 5 sub-problems:

1. What to split? What cannot be split?

Divide the rendering/updating process into 2 phases (diff + patch):

1.diff ~ render/reconciliation
2.patch ~ commit

The actual work of diff is comparing the state of prevInstance and nextInstance, finding differences and corresponding DOM changes. Diff is essentially some calculations (traversal, comparison), which is splittable (calculate half, continue later)

The patch phase applies all DOM changes in this update to the DOM tree, which is a series of DOM operations. Although these DOM operations can also appear to be splittable (do change list segment by segment), doing so may on one hand cause inconsistency between actual DOM state and maintained internal state, and also affect experience. Moreover, in general scenarios, DOM update time consumption is nothing compared to diff and lifecycle function time consumption, so splitting has little significance

Therefore, the work of render/reconciliation phase (diff) can be split, the work of commit phase (patch) cannot be split

P.S. diff and reconciliation are just corresponding relationships, not equivalent. If you must distinguish, reconciliation includes diff:

This is a part of the process that React calls reconciliation which starts when you call ReactDOM.render() or setState(). By the end of the reconciliation, React knows the result DOM tree, and a renderer like react-dom or react-native applies the minimal set of changes necessary to update the DOM nodes (or the platform-specific views in case of React Native).

(Quoted from Top-Down Reconciliation)

2. How to split?

First, randomly come up with several diff work splitting schemes:

  • Split by component structure. Hard to divide, cannot predict the workload of each component update

  • Split by actual procedures. For example, divide into getNextState(), shouldUpdate(), updateState(), checkChildren() and intersperse some lifecycle functions

Splitting by component is too coarse, obviously unfair to large components. Splitting by procedure is too fine, too many tasks, frequent scheduling is not cost-effective. So is there a suitable splitting unit?

Yes. Fiber's splitting unit is fiber (a node on the fiber tree), actually it's split by virtual DOM node, because the fiber tree is constructed based on the vDOM tree, the tree structure is exactly the same, only the information carried by nodes differs

So, it's actually vDOM node granularity splitting (with fiber as work unit). Each component instance and each DOM node abstractly represented instance is a work unit. In the work loop, each time one fiber is processed. After processing, the entire work loop can be interrupted/suspended

3. How to schedule tasks?

Divided into 2 parts:

  • Work loop

  • Priority mechanism

The work loop is the basic task scheduling mechanism. Each time in the work loop, one task (work unit) is processed. After processing, there's a chance to catch breath:

// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
  nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}

shouldYield checks if time is used up (idleDeadline.timeRemaining()). If not used up, continue processing the next task. If used up, end and return time control to the main thread, wait for the next requestIdleCallback callback to continue:

// If there's work left over, schedule a new callback.
if (nextFlushedExpirationTime !== NoWork) {
  scheduleCallbackWithExpiration(nextFlushedExpirationTime);
}

That is to say, (not considering sudden events) normal scheduling is completed by the work loop. The basic rule is: at the end of each work unit, check if there's time to do the next one. If no time, "suspend" first

The priority mechanism is used to handle sudden events and optimize order, for example:

  • Reached commit phase, increase priority

  • High-priority task encountered an error halfway, decrease priority a bit

  • Pay attention to low-priority tasks in spare time, don't let them starve

  • If the corresponding DOM node is invisible at this moment, decrease to lowest priority

These strategies are used to dynamically adjust task scheduling, they are auxiliary mechanisms for the work loop, doing the most important things first

4. How to interrupt/resume from breakpoint?

Interrupt: Check the work unit currently being processed, save current results (firstEffect, lastEffect), modify tag to mark it, wrap up quickly and open another requestIdleCallback, do it again next time there's a chance

Resume from breakpoint: Next time processing this work unit, check if tag is an interrupted task, continue with unfinished parts or redo

P.S. Whether it's "natural" interruption due to time running out, or rudely interrupted by high-priority tasks, it's the same for the interrupt mechanism

5. How to collect task results?

Fiber reconciliation work loop is specifically as follows:

  1. Find the highest priority workInProgress tree root node, take its pending node (representing component or DOM node)

  2. Check if the current node needs updating. If not, directly go to 4

  3. Mark it (add a tag), update self (component updates props, context, etc., DOM node records DOM changes), and generate workInProgress node for children

  4. If no child nodes are produced, merge effect list (containing DOM changes) to parent

  5. Take child or sibling as pending node, prepare to enter the next work loop. If there are no pending nodes (returned to workInProgress tree root node), work loop ends

Through upward merging of effect list at the end of each node update to collect task results. After reconciliation ends, the root node's effect list records all side effects including DOM changes

Learn by analogy

Since tasks are splittable (as long as the complete effect list is obtained in the end), this allows parallel execution (multiple Fiber reconcilers + multiple workers). First screen is also easier to load/render in chunks (vDOM forest)

For parallel rendering, it's said that Firefox test results show that a 130ms page only needs 30ms to complete. So this aspect is worth looking forward to, and React is already prepared. This is one of the features often heard about waiting to be unlocked in the React Fiber context

VIII. Source Code Brief Analysis

From 15 to 16, the source code structure changed greatly:

  • Can no longer see mountComponent/updateComponent(), split and reorganized into (beginWork/completeWork/commitWork())

  • ReactDOMComponent was also removed. In the Fiber system, DOM node abstraction is represented by ReactDOMFiberComponent, components are represented by ReactFiberClassComponent, previously it was ReactCompositeComponent

  • The core mechanism of the Fiber system is ReactFiberScheduler responsible for task scheduling, equivalent to the previous ReactReconciler

  • vDOM tree became fiber tree. Previously it was a simple top-down tree structure, now it's a tree structure based on singly linked list, maintaining more node relationships

Let's feel the fiber tree with a picture:

[caption id="attachment_1628" align="alignnone" width="970"]fiber-tree fiber-tree[/caption]

Actually, upon careful thought, from Stack reconciler to Fiber reconciler, at the source code level, it's just doing one thing: changing recursion to loop (of course, the actual work done is far more than just changing recursion to loop, but this is the first step)

In short, source code changes are huge. If you don't have prior understanding of Fiber ideas, reading the source code will be quite difficult (if you've read React [15-] source code, it's easier to get confused)

P.S. This Qingming flowchart is about to officially retire

Reference Materials

Comments

No comments yet. Be the first to share your thoughts.

Leave a comment