Skip to main content

React Suspense

Free2018-11-25#JS#React挂起#React code splitting#React Suspense内部原理#React懒加载#React动态加载组件

Starting from adding a loading for code splitting...

I. Code Splitting

When frontend applications reach a certain scale (for example, bundle size in MB units), inevitably face strong demand for code splitting:

Code-Splitting is a feature supported by bundlers like Webpack and Browserify (via factor-bundle) which can create multiple bundles that can be dynamically loaded at runtime.

Dynamically load some code blocks at runtime, such as non-first-screen business components, and heavyweight components like calendar, address selection, comments, etc.

The most convenient dynamic loading method is still at stage3, but already widely supported by major bundlers (webpack, rollup, etc.) tc39/proposal-dynamic-import:

import('../components/Hello').then(Hello => {
  console.log(<Hello />);
});

Equivalent to (setTimeout simulates asynchronous component loading):

new Promise(resolve =>
  setTimeout(() =>
    resolve({
      // Functional component from another file
      default: function render() {
        return <div>Hello</div>
      }
    }),
    3000
  )
).then(({ default: Hello }) => {
  // Got the component, then what?
  console.log(<Hello />);
});

Of course, splitting out is only the first half, how to render the obtained component is the second half

II. Conditional Rendering

Without framework support, can mount dynamic components through conditional rendering:

class MyComponent extends Component {
  constructor() {
    super();
    this.state = {};
    // Dynamic loading
    import('./OtherComponent').then(({ default: OtherComponent }) => {
      this.setState({ OtherComponent });
    });
  }

  render() {
    const { OtherComponent } = this.state;

    return (
      <div>
        {/* Conditional rendering */}
        { OtherComponent && <OtherComponent /> }
      </div>
    );
  }
}

At this point the corresponding user experience is, first screen OtherComponent hasn't returned yet, after a while layout jitters and pops out, exists several problems:

  • Invasive to parent component (state.OtherComponent)

  • Layout jitter experience is poor

If framework doesn't provide support, this invasiveness seems unavoidable (some component always has to do conditional rendering, always has to add these display logics)

For jitter, add loading to solve, but easy to appear everywhere windows (several loadings all spinning) experience problem, so loading generally doesn't target individual atomic components, but a region of component tree displays loading overall (this region may contain components that could display immediately), in this scenario, loading needs to be added to ancestor component, and display logic becomes very troublesome (may need to wait for several dynamic components to finish loading before hiding)

So, to avoid invasiveness brought by conditional rendering, only rely on framework to provide support, this is exactly the origin of React.lazy API. And to solve the latter two problems, we hope to put loading display logic onto ancestor component, which is the role of Suspense

III. React.lazy

React.lazy() moves conditional rendering details to framework layer, allows using dynamically imported components as normal components, elegantly eliminates this invasiveness:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <OtherComponent />
    </div>
  );
}

Dynamically imported OtherComponent is completely consistent with normal components in usage, only exists differences in import method (change import to import() and wrap with React.lazy()):

import OtherComponent from './OtherComponent';
// Change to dynamic loading
const OtherComponent = React.lazy(() => import('./OtherComponent'));

Requires import() must return a Promise that will resolve ES Module, and this ES Module export default a legal React component:

// ./OtherComponent.jsx
export default function render() {
  return <div>Other Component</div>
}

Similar to:

const OtherComponent = React.lazy(() => new Promise(resolve =>
  setTimeout(() =>
    resolve(
      // Simulate ES Module
      {
        // Simulate export default 
        default: function render() {
          return <div>Other Component</div>
        }
      }
    ),
    3000
  )
));

P.S. React.lazy() temporarily doesn't support SSR yet, recommend using React Loadable

IV. Suspense

React.Suspense is also a virtual component (similar to Fragment, only used as type identifier), usage as follows:

const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <OtherComponent />
      </Suspense>
    </div>
  );
}

As long as there are Lazy components that haven't returned yet in Suspense subtree, go to content specified by fallback. Isn't this exactly loading that can be lifted to any ancestor level?

You can place the Suspense component anywhere above the lazy component. You can even wrap multiple lazy components with a single Suspense component.

Suspense component can be placed at any position above (in component tree) Lazy component, and below can have multiple Lazy components. Corresponding to loading scenario, it's these two capabilities:

  • Supports loading lift

  • Supports loading aggregation

Can implement loading best practices with 4 lines of business code, quite beautiful feature

P.S. Lazy components not wrapped by Suspense will error:

Uncaught Error: A React component suspended while rendering, but no fallback UI was specified.

Considered as strong requirement for user experience from framework layer

V. Specific Implementation

function lazy<T, R>(ctor: () => Thenable<T, R>): LazyComponent<T> {
  return {
    $$typeof: REACT_LAZY_TYPE,
    _ctor: ctor,
    // Component loading status
    _status: -1,
    // Loading result, Component or Error
    _result: null,
  };
}

Record the passed-in component loader, return Lazy component description object with (loading) status:

// _status values
export const Pending = 0;
export const Resolved = 1;
export const Rejected = 2;

Initial value -1 becomes Pending after being touched, specifically as follows:

// beginWork()
//   mountLazyComponent()
//     readLazyComponentType()

function readLazyComponentType(lazyComponent) {
  lazyComponent._status = Pending;
  const ctor = lazyComponent._ctor;
  const thenable = ctor();
  thenable.then(
    moduleObject => {
      if (lazyComponent._status === Pending) {
        const defaultExport = moduleObject.default;
        lazyComponent._status = Resolved;
        lazyComponent._result = defaultExport;
      }
    },
    error => {
      if (lazyComponent._status === Pending) {
        lazyComponent._status = Rejected;
        lazyComponent._result = error;
      }
    },
  );
  lazyComponent._result = thenable;
  throw thenable;
}

Note the final throw, yes, to interrupt subtree rendering, directly throw error out here, path is somewhat wild:

function renderRoot(root, isYieldy) {
  do {
    try {
      workLoop(isYieldy);
    } catch (thrownValue) {
      // Handle error
      throwException(root, returnFiber, sourceFiber, thrownValue, nextRenderExpirationTime);
      // Find next work unit, Lazy parent component or sibling component
      nextUnitOfWork = completeUnitOfWork(sourceFiber);
      continue;
    }
  } while (true);
}

Finally will be caught by 230 lines long throwException:

function throwException() {
  if (
    value !== null &&
    typeof value === 'object' &&
    typeof value.then === 'function'
  ) {
    // This is a thenable.
    const thenable: Thenable = (value: any);

    // Next roughly did 4 things
    // 1. Find earliest timeout time of all ancestor Suspense components (possibly already timed out)
    // 2. Find nearest Suspense component, if not found report that error
    // 3. Listen to Pending component, immediately schedule rendering nearest Suspense component when no longer Pending
    // Attach a listener to the promise to "ping" the root and retry.
    let onResolveOrReject = retrySuspendedRoot.bind(
      null,
      root,
      workInProgress,
      sourceFiber,
      pingTime,
    );
    if (enableSchedulerTracing) {
      onResolveOrReject = Schedule_tracing_wrap(onResolveOrReject);
    }
    thenable.then(onResolveOrReject, onResolveOrReject);
    // 4. Suspend nearest Suspense component subtree, no longer render downwards
  }
}

P.S. Note, step 3 thenable.then(render, render) in React.lazy(() => resolvedImportPromise) scenario will not flash fallback content, this is related to browser task mechanism, specifically see macrotask vs microtask

(When collecting results) return to nearest Suspense component, if finds Pending descendants will render fallback:

function beginWork(
  current: Fiber | null,
  workInProgress: Fiber,
  renderExpirationTime: ExpirationTime,
): Fiber | null {
  if (
    primaryChildExpirationTime !== NoWork &&
    primaryChildExpirationTime >= renderExpirationTime
  ) {
    // The primary children have pending work. Use the normal path
    // to attempt to render the primary children again.
    return updateSuspenseComponent(
      current,
      workInProgress,
      renderExpirationTime,
    );
  }
}

function updateSuspenseComponent(
  current,
  workInProgress,
  renderExpirationTime,
) {
  // Render fallback
  const nextFallbackChildren = nextProps.fallback;
  const primaryChildFragment = createFiberFromFragment(
    null,
    mode,
    NoWork,
    null,
  );
  const fallbackChildFragment = createFiberFromFragment(
    nextFallbackChildren,
    mode,
    renderExpirationTime,
    null,
  );
  next = fallbackChildFragment;
  return next;
}

Above, roughly the entire process (omitted all details that could be omitted)

VI. Significance

We've built a generic way for components to suspend rendering while they load async data, which we call suspense. You can pause any state update until the data is ready, and you can add async loading to any component deep in the tree without plumbing all the props and state through your app and hoisting the logic. On a fast network, updates appear very fluid and instantaneous without a jarring cascade of spinners that appear and disappear. On a slow network, you can intentionally design which loading states the user should see and how granular or coarse they should be, instead of showing spinners based on how the code is written. The app stays responsive throughout.

Original intention is to provide elegant general solution for loading scenarios, allows component tree to suspend waiting (i.e., delay rendering) for async data, significance lies in:

  • Conforms to best user experience:

    • Avoid layout jitter (content pops out after data returns), of course, this is benefit of adding loading or skeleton, not much related to Suspense

    • Treat different network environments differently (if data returns fast, loading won't appear at all)

  • Elegant: No longer need to lift related state and logic for adding subtree loading, liberated from depression of state lifting and component encapsulation

  • Flexible: No strong correlation in component hierarchy relationship between loading components and async components (components depending on async data), can flexibly control loading granularity

  • General: Supports displaying degraded components when waiting for async data (loading is just one most common degradation strategy, fallback to cached data or even advertisements is also possible)

Reference Materials

Comments

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

Leave a comment