Airing

Airing

哲学系学生 / 小学教师 / 程序员,个人网站: ursb.me
github
email
zhihu
medium
tg_channel
twitter_id

React Hooks Source Code Analysis (4): useEffect

  • React source code version: v16.11.0
  • Source code commentary notes: airingursb/react

1. Introduction to useEffect#

1.1 Why use useEffect#

As mentioned earlier, React Hooks allow Functional Components to have the features of Class Components. The main motivations include:

  1. Reusing state logic between components is difficult.
  2. Complex components become hard to understand.
  3. Class components are hard to comprehend.

Regarding the second point, when writing React applications with Class Components, we often have to write code in various lifecycle methods, such as sending HTTP requests in componentDidMount and componentDidUpdate, binding events, or even doing some additional logic, which causes business logic to pile up in the lifecycle methods of the component. At this point, our programming mindset is "what do we need to do when the component is mounted" and "what do we need to do when the component updates," making React development lifecycle-oriented programming, and the logic we write in the lifecycle becomes the side effects of the component lifecycle methods.

Moreover, lifecycle-oriented programming leads to business logic being scattered across various lifecycle methods. For example, if we bind an event in componentDidMount, we also need to unbind it in componentDidUnmount, which makes event management logic inconsistent and the code scattered, making it more troublesome to review:

import React from 'react';
class A extends React.Component {
  componentDidMount() {
    document.getElementById('js_button')
      .addEventListener('click', this.log);
  }
  componentDidUnmount() {
    document.getElementById('js_button')
      .removeEventListener('click', this.log);
  }
  
  log = () => {
    console.log('log');
  }
  
  render() {
    return (
      <div id="js_button">button</div>
    );
  }
}

The emergence of useEffect allows developers to shift their focus from the lifecycle back to business logic. In fact, the full name of effect is side effect, and useEffect is used to handle the side effect logic that originally resided in lifecycle methods.

Next, let's look at how to use useEffect.

1.2 How to use useEffect#

The above code rewritten with useEffect looks like this:

import React, { useEffect } from 'react';
function A() {
  const log = () => {
    console.log('log');
  }
  useEffect(() => {
    document
      .getElementById('js_button')
      .addEventListener('click', log);
    return () => {
      document
        .getElementById('js_button')
        .removeEventListener('click', log);
    }
  });
  return (<div id="js_button">button</div>);
}

useEffect accepts two parameters: the first parameter is a function that implements the bind operation and returns an unbind as a thunk function. The second parameter is an optional dependencies array. If the dependencies do not exist, the function will execute on every render; if the dependencies exist, the function will only execute when they change. Thus, we can infer that if the dependencies are an empty array, the function will execute only on the first render.

useEffect(
  () => {
    const subscription = props.source.subscribe();
    return () => {
      subscription.unsubscribe();
    };
  },
  [props.source],
);

For more usage, please read the React official documentation on the useEffect API: https://reactjs.org/docs/hooks-reference.html#useeffect

2. The Principle and Simple Implementation of useEffect#

Based on the usage of useEffect, we can implement a simple version of useEffect ourselves:

let _deps;

function useEffect(callback, dependencies) {
  const hasChanged = _deps
    && !dependencies.every((el, i) => el === _deps[i])
    || true;
  // If dependencies do not exist, or dependencies have changed, execute callback
  if (!dependencies || hasChanged) {
    callback();
    _deps = dependencies;
  }
}

3. Analysis of useEffect Source Code#

3.1 mountEffect & updateEffect#

The entry point of useEffect is the same as that of useState in the file ReactFiberHooks.js. Just like useState, during the first load, useEffect actually executes mountEffect, and then on each subsequent render, it executes updateEffect. We need to focus on what mountEffect and updateEffect actually do.

For mountEffect:

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return mountEffectImpl(
    UpdateEffect | PassiveEffect,
    UnmountPassive | MountPassive,
    create,
    deps,
  );
}

For updateEffect:

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    UnmountPassive | MountPassive,
    create,
    deps,
  );
}

The parameters of mountEffect and updateEffect are a function and an array, corresponding to the callback and deps we passed to useEffect. At the same time, we can see that mountEffect and updateEffect actually call mountEffectImpl and updateEffectImpl, which accept the same four parameters. The last two parameters are directly passed through, and the main focus is on what UpdateEffect | PassiveEffect and UnmountPassive | MountPassive are.

From reading the code, we can see they are imported from ReactSideEffectTags and ReactHookEffectTags.

import {
  Update as UpdateEffect,
  Passive as PassiveEffect,
} from 'shared/ReactSideEffectTags';
import {
  NoEffect as NoHookEffect,
  UnmountPassive,
  MountPassive,
} from './ReactHookEffectTags';

Let's take a look at the definitions in ReactSideEffectTags.js and ReactHookEffectTags.js:

// Don't change these two values. They're used by React Dev Tools.
export const NoEffect = /*              */ 0b0000000000000;
export const PerformedWork = /*         */ 0b0000000000001;

// You can change the rest (and add more).
export const Placement = /*             */ 0b0000000000010;
export const Update = /*                */ 0b0000000000100;
export const PlacementAndUpdate = /*    */ 0b0000000000110;
export const Deletion = /*              */ 0b0000000001000;
export const ContentReset = /*          */ 0b0000000010000;
export const Callback = /*              */ 0b0000000100000;
export const DidCapture = /*            */ 0b0000001000000;
export const Ref = /*                   */ 0b0000010000000;
export const Snapshot = /*              */ 0b0000100000000;
export const Passive = /*               */ 0b0001000000000;
export const Hydrating = /*             */ 0b0010000000000;
export const HydratingAndUpdate = /*    */ 0b0010000000100;

export const NoEffect = /*             */ 0b00000000;
export const UnmountSnapshot = /*      */ 0b00000010;
export const UnmountMutation = /*      */ 0b00000100;
export const MountMutation = /*        */ 0b00001000;
export const UnmountLayout = /*        */ 0b00010000;
export const MountLayout = /*          */ 0b00100000;
export const MountPassive = /*         */ 0b01000000;
export const UnmountPassive = /*       */ 0b10000000;

This design is intended to simplify type comparison and type composition. If you have experience designing some composite permission systems during project development, you might realize at first glance that UnmountPassive | MountPassive is 0b11000000. If the corresponding bit is non-zero, it indicates that the tag implements the specified behavior. This will be used in the future, so we will just understand it for now.

3.2 mountEffectImpl & updateEffectImpl#

Next, let's look at the specific implementation of mountEffectImpl and updateEffectImpl.

3.2.1 mountEffectImpl#

First, mountEffectImpl:

function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  const hook = mountWorkInProgressHook(); // Create a new Hook and return the current workInProgressHook
  const nextDeps = deps === undefined ? null : deps;
  sideEffectTag |= fiberEffectTag;
  hook.memoizedState = pushEffect(hookEffectTag, create, undefined, nextDeps);
}

mountWorkInProgressHook, which we analyzed in Section 3.4.3: mountWorkInProgressHook, creates a new Hook and returns the current workInProgressHook, and the specific principle will not be elaborated here.

sideEffectTag is bitwise OR'd with fiberEffectTag and then assigned, mounted on renderedWork.effectTag in renderWithHooks, and reset to 0 after each render.

renderedWork.effectTag |= sideEffectTag;
sideEffectTag = 0;

We will discuss the specific use of renderedWork.effectTag later.

renderWithHooks is analyzed in Section 3.4.3: renderWithHooks, and will not be elaborated here.

hook.memoizedState records the result returned by pushEffect, which is consistent with the principle of recording newState in useState. Now the focus shifts to what pushEffect actually does.

3.3.2 updateEffectImpl#

Next, let's see what updateEffectImpl does:

function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  const hook = updateWorkInProgressHook(); // Get the current working Hook
  const nextDeps = deps === undefined ? null : deps;
  let destroy = undefined;

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        pushEffect(NoHookEffect, create, destroy, nextDeps);
        return;
      }
    }
  }

  sideEffectTag |= fiberEffectTag;
  hook.memoizedState = pushEffect(hookEffectTag, create, destroy, nextDeps);
}

updateWorkInProgressHook, which we analyzed in Section 3.4.3: updateWorkInProgressHook, retrieves the current working Hook, and the specific principle will not be elaborated here.

It can be seen that when currentHook is null, the logic of updateEffectImpl is identical to that of mountEffectImpl. When currentHook is not null, the third parameter of pushEffect is not undefined but is destroy. Moreover, in this branch, there exists areHookInputsEqual(nextDeps, prevDeps), which means that if the current useEffect's deps are equal to the previous useEffect's deps (what areHookInputsEqual does is traverse and compare whether the two deps are equal, which will not be elaborated here), then it executes pushEffect(NoHookEffect, create, destroy, nextDeps);. It can be boldly speculated that NoHookEffect means not to execute this useEffect. Thus, the logic of this code is consistent with the useEffect we implemented earlier.

According to Section 3.4.3: updateWorkInProgressHook, we know that currentHook is the Hook currently being processed, and under normal logic, it should not be null. Next, we need to focus on what pushEffect does and what the third parameter means.

3.3 pushEffect#

function pushEffect(tag, create, destroy, deps) {

  // Declare a new effect
  const effect: Effect = {
    tag,
    create, 
    destroy,
    deps, 
    // Circular
    next: (null: any), // Reference to the next effect defined in the function component
  };
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue(); // Initialize componentUpdateQueue
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      componentUpdateQueue.lastEffect = effect;
    }
  }
  return effect;
}
type Effect = {
  tag: HookEffectTag, // A binary number that determines the behavior of the effect
  create: () => (() => void) | void, // Callback to run after rendering
  destroy: (() => void) | void, // Determines whether to destroy and recreate the effect
  deps: Array<mixed> | null, // Deps that determine whether to execute after re-rendering
  next: Effect, // Reference to the next effect defined in the function component
};

This function first declares a new effect based on the parameters, and the data structure is also provided. It is also a circular linked list. The tag is

Next, it follows two logic paths based on whether componentUpdateQueue is empty:

  1. When componentUpdateQueue is empty: This is the logic during mountEffect, which creates an empty componentUpdateQueue, which is actually just {lastEffect: null}, and then points componentUpdateQueue.lastEffect to effect.next, essentially just storing the effect.

  2. When componentUpdateQueue is not empty: This is the logic that will be followed during updateEffect.

    1. If lastEffect is empty: This means it is the first useEffect of the new render phase, and the logic is consistent with when componentUpdateQueue is empty.
    2. If lastEffect is not empty: This means that this component has multiple useEffect, and it will follow the logic of the second and subsequent useEffect, pointing lastEffect to the next effect.

Finally, it returns an effect.

3.4 React Fiber Process Analysis#

It seems that the source code ends here, but we still have several unresolved questions:

  1. What do those binary numbers in effect.tag mean?
  2. What logic follows pushEffect?
  3. Where will componentUpdateQueue, after storing Effect, be used?

In renderWithHooks, componentUpdateQueue will be assigned to renderedWork.updateQueue, and the sideEffectTag we discussed in 3.2 will also be assigned to renderedWork.effectTag.

renderedWork.updateQueue = (componentUpdateQueue: any);
renderedWork.effectTag |= sideEffectTag;

In Section 3.4.3: renderWithHooks, we analyzed that renderWithHooks is executed during the function component update phase (updateFunctionComponent). To answer the three questions above, we must go through the entire Reconciler process. I personally believe that Fiber is the most complex logic in React 16, so in the previous articles, I only briefly mentioned it without elaborating. There is a lot of content in Fiber, and if elaborated, it would be enough to write several articles. Therefore, we will try to go through the process simply and quickly, ignoring details unrelated to this article and focusing on the implementation of some logic after we call useEffect.

Note: If you are not interested in this part, you can jump directly to 3.5 to continue reading.

There are many excellent articles about React Fiber; here are a few more articles and videos to help interested readers understand:

  1. A Cartoon Intro to Fiber - React Conf 2017
  2. A Preliminary Exploration of React Fiber
  3. This Might Be the Most Accessible Way to Open React Fiber

Let's get started!

3.4.1 ReactDOM.js#

The only entry point for rendering the page is ReactDOM.render:

ReactRoot.prototype.render = ReactSyncRoot.prototype.render = function(
  children: ReactNodeList,
  callback: ?() => mixed,
): Work {
  // ... ignore unrelated code
  updateContainer(children, root, null, work._onCommit);
  return work;
};

The core of render is calling updateContainer, which comes from the ReactFiberReconciler.js in react-reconciler.

3.4.2 ReactFiberReconciler.js#

This file is also the entry point of react-reconciler. Let's see what updateContainer is:

export function updateContainer(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  callback: ?Function,
): ExpirationTime {
  // ... ignore unrelated code
  return updateContainerAtExpirationTime(
    element,
    container,
    parentComponent,
    expirationTime,
    suspenseConfig,
    callback,
  );
}

Ignoring unrelated code, we find that it is just a layer of encapsulation for updateContainerAtExpirationTime. Let's see what this is:

export function updateContainerAtExpirationTime(
  element: ReactNodeList,
  container: OpaqueRoot,
  parentComponent: ?React$Component<any, any>,
  expirationTime: ExpirationTime,
  suspenseConfig: null | SuspenseConfig,
  callback: ?Function,
) {
  // ... ignore unrelated code
  return scheduleRootUpdate(
    current,
    element,
    expirationTime,
    suspenseConfig,
    callback,
  );
}

Ignoring some unrelated code, we find that its core does two things: enqueueUpdate, which we will temporarily ignore, and the task scheduling scheduleWork, which is defined in ReactFiberWorkLoop.js.

3.4.3 ReactFiberWorkLoop.js - render#

The content of ReactFiberWorkLoop.js is very long, with 2900 lines of code, containing the main logic of the task loop. However, since we just clarified that we need to start from scheduleWork, let's sort it out slowly:

export function scheduleUpdateOnFiber(
  fiber: Fiber,
  expirationTime: ExpirationTime,
) {
  // ... ignore unrelated code
  const priorityLevel = getCurrentPriorityLevel();

  if (expirationTime === Sync) {
    if (
      (executionContext & LegacyUnbatchedContext) !== NoContext &&
      (executionContext & (RenderContext | CommitContext)) === NoContext
    ) {
      schedulePendingInteractions(root, expirationTime);
      let callback = renderRoot(root, Sync, true);
      while (callback !== null) {
        callback = callback(true);
      }
    } else {
      scheduleCallbackForRoot(root, ImmediatePriority, Sync);
      if (executionContext === NoContext) {
        flushSyncCallbackQueue();
      }
    }
  } else {
    scheduleCallbackForRoot(root, priorityLevel, expirationTime);
  }
  // ... ignore special case handling
}
export const scheduleWork = scheduleUpdateOnFiber;

Most branches of this code will return to renderRoot, so rather than saying scheduleWork is the entry point of Fiber logic, it is more accurate to say renderRoot is the entry point. renderRoot is the famous render phase in the two phases of Fiber.

image

Image source: A Cartoon Intro to Fiber - React Conf 2017

Debugging also makes it easy to see these two phases:

image

The code in renderRoot is also very complex; we will focus on the logic related to this article:

function renderRoot(
  root: FiberRoot,
  expirationTime: ExpirationTime,
  isSync: boolean,
): SchedulerCallback | null {
  if (isSync && root.finishedExpirationTime === expirationTime) {
    // There's already a pending commit at this expiration time.
    return commitRoot.bind(null, root); // Enter the commit phase
  }
  // ...
  do {
    try {
      if (isSync) {
        workLoopSync();
      } else {
        workLoop(); // Core logic
      }
      break;
    } catch (thrownValue) {
      // ...
  } while (true);
  // ...
}

After omitting some extraneous code, we focus on two important points:

  1. workLoop is the core part of the code, implementing the task loop with a loop.
  2. In the case of timeout, it will enter the commit phase.

First, let's look at the logic of workLoop:

function workLoop() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

It seems we need to focus on performUnitOfWork:

function performUnitOfWork(unitOfWork: Fiber): Fiber | null {
  const current = unitOfWork.alternate;

  // ... ignore timing logic

  let next;
  if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
    next = beginWork(current, unitOfWork, renderExpirationTime);
  } else {
    next = beginWork(current, unitOfWork, renderExpirationTime);
  }
  
  // ... ignore special logic

  ReactCurrentOwner.current = null;
  return next;
}

Ignoring timing logic, we find that this code essentially calls beginWork twice (this answers the question we left in Section 3.4.1). This beginWork is imported from ReactFiberBeginWork.js.

3.4.4 ReactFiberBeginWork.js#

The analysis of this section is similar to Section 3.4.1: renderWithHooks and will not be elaborated here.

Thus, the updateQueue (remember it? Its content is the Effect linked list) and effectTag we have on renderedWork are mounted on Fiber. Skipping this part of the logic, let's see how Fiber processes them.

3.4.5 ReactFiberWorkLoop.js - commit#

In the process of analyzing renderRoot, we noticed that after a task times out, it directly enters the commit phase. Let's first look at the code for commitRoot:

function commitRoot(root) {
  const renderPriorityLevel = getCurrentPriorityLevel();
  runWithPriority(
    ImmediatePriority,
    commitRootImpl.bind(null, root, renderPriorityLevel),
  );
  return null;
}

Here, we find that we should focus on commitRootImpl, so let's take a look:

function commitRootImpl(root, renderPriorityLevel) {
  
  // ...
  startCommitTimer();

   // Get the list of effects.
  let firstEffect;
  if (finishedWork.effectTag > PerformedWork) {
    if (finishedWork.lastEffect !== null) {
      finishedWork.lastEffect.nextEffect = finishedWork;
      firstEffect = finishedWork.firstEffect;
    } else {
      firstEffect = finishedWork;
    }
  } else {
    firstEffect = finishedWork.firstEffect;
  }

  if (firstEffect !== null) {
    do {
      try {
        commitBeforeMutationEffects();
      } catch (error) {
        invariant(nextEffect !== null, 'Should be working on an effect.');
        captureCommitPhaseError(nextEffect, error);
        nextEffect = nextEffect.nextEffect;
      }
    } while (nextEffect !== null);
    stopCommitSnapshotEffectsTimer();

    if (enableProfilerTimer) {
      // Mark the current commit time to be shared by all Profilers in this
      // batch. This enables them to be grouped later.
      recordCommitTime();
    }

    // The next phase is the mutation phase, where we mutate the host tree.
    startCommitHostEffectsTimer();
    nextEffect = firstEffect;
    do {
      try {
        commitMutationEffects(root, renderPriorityLevel);
      } catch (error) {
        invariant(nextEffect !== null, 'Should be working on an effect.');
        captureCommitPhaseError(nextEffect, error);
        nextEffect = nextEffect.nextEffect;
      }
    } while (nextEffect !== null);
    stopCommitHostEffectsTimer();
    resetAfterCommit(root.containerInfo);

    // The work-in-progress tree is now the current tree. This must come after
    // the mutation phase, so that the previous tree is still current during
    // componentWillUnmount, but before the layout phase, so that the finished
    // work is current during componentDidMount/Update.
    root.current = finishedWork;

    // The next phase is the layout phase, where we call effects that read
    // the host tree after it's been mutated. The idiomatic use case for this is
    // layout, but class component lifecycles also fire here for legacy reasons.
    startCommitLifeCyclesTimer();
    nextEffect = firstEffect;
    do {
      try {
        commitLayoutEffects(root, expirationTime);
      } catch (error) {
        invariant(nextEffect !== null, 'Should be working on an effect.');
        captureCommitPhaseError(nextEffect, error);
        nextEffect = nextEffect.nextEffect;
      }
    } while (nextEffect !== null);
    stopCommitLifeCyclesTimer();

    nextEffect = null;

    // Tell Scheduler to yield at the end of the frame, so the browser has an
    // opportunity to paint.
    requestPaint();

    if (enableSchedulerTracing) {
      __interactionsRef.current = ((prevInteractions: any): Set<Interaction>);
    }
    executionContext = prevExecutionContext;
  } else {
    // No effects.
    // ...
  }

  stopCommitTimer();

  nextEffect = firstEffect;
  while (nextEffect !== null) {
    const nextNextEffect = nextEffect.nextEffect;
    nextEffect.nextEffect = null;
    nextEffect = nextNextEffect;
  }

  // ...
  
  return null;
}

The code for commitRootImpl is indeed very long. I have omitted some code unrelated to effect processing. After reading the remaining code, we find that when effects exist, there are three segments of logic to process, which essentially loop through the effect linked list and pass them to three different functions:

  • commitBeforeMutationEffects
  • commitMutationEffects
  • commitLayoutEffects

Finally, it loops through the effects, setting nextEffect to nextNextEffect.

Due to space limitations, and since the third function is related to useLayoutEffect, we will not elaborate on these three functions here. We will analyze useLayoutEffect in the next article in detail. Therefore, the question we left in Section 3.4 regarding what those binary numbers in effect.tag mean will also be explained in the next article.

Here, we have clarified the source code of useEffect, but one question remains: what is the purpose of the effect.tag parameter? Currently, we only know that when it is NoHookEffect, it means not to execute the content of useEffect, but we have not analyzed the meanings of other values. Their analysis logic is mainly in the three functions we skipped in Section 3.4.5. In the next article, we will take a closer look at useLayoutEffect.

See you all next time.

Finally, here is the flowchart from Section 3.4 analysis:

image

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