admin管理员组

文章数量:1135195

I am trying to use the new React Lazy and Suspense to create a fallback loading component. This works great, but the fallback is showing only a few ms. Is there a way to add an additional delay or minimum time, so I can show animations from this component before the next component is rendered?

Lazy import now

const Home = lazy(() => import("./home"));
const Products = lazy(() => import("./home/products"));

Waiting component:

function WaitingComponent(Component) {

    return props => (
      <Suspense fallback={<Loading />}>
            <Component {...props} />
      </Suspense>
    );
}

Can I do something like this?

const Home = lazy(() => {
  setTimeout(import("./home"), 300);
});

I am trying to use the new React Lazy and Suspense to create a fallback loading component. This works great, but the fallback is showing only a few ms. Is there a way to add an additional delay or minimum time, so I can show animations from this component before the next component is rendered?

Lazy import now

const Home = lazy(() => import("./home"));
const Products = lazy(() => import("./home/products"));

Waiting component:

function WaitingComponent(Component) {

    return props => (
      <Suspense fallback={<Loading />}>
            <Component {...props} />
      </Suspense>
    );
}

Can I do something like this?

const Home = lazy(() => {
  setTimeout(import("./home"), 300);
});
Share Improve this question asked Jan 12, 2019 at 11:07 vemundvemund 1,8074 gold badges31 silver badges44 bronze badges 4
  • 3 Why would you want to do something like that? The user wants to use your content and app and not look at loading spinners. The whole reason lazy and Suspense was introduced is for you as a developer to give the user a better UX, by reducing the initial app loading time and not showing loading spinners everywhere. What you are trying to do is the opposite of that idea. – Johannes Klauß Commented Jan 12, 2019 at 11:27
  • 3 @JohannesKlauß You're talking about initial loading time. Suspense is obviously intended for spinners and other loading indicators for lazily loaded components. That's why it has fallback. – Estus Flask Commented Jan 12, 2019 at 11:30
  • 11 @JohannesKlauß I don't think you understand my UX problem. My goal is not to create a one minute long spinning loader, my goal is to create a better loading function that is smooth and beautiful. It's a growing trend in UX to offer the user better page transitions. – vemund Commented Jan 12, 2019 at 11:31
  • @JohannesKlauß The idea with Suspense/Lazy is also to reduce bundle size, by only loading required component. Here, the goal is not to increase loading time, but to have a smoother transition between routes. – Thibault Boursier Commented Mar 1, 2021 at 11:22
Add a comment  | 

12 Answers 12

Reset to default 99

lazy function is supposed to return a promise of { default: ... } object which is returned by import() of a module with default export. setTimeout doesn't return a promise and cannot be used like that. While arbitrary promise can:

const Home = lazy(() => {
  return new Promise(resolve => {
    setTimeout(() => resolve(import("./home")), 300);
  });
});

If an objective is to provide minimum delay, this isn't a good choice because this will result in additional delay.

A minimum delay would be:

const Home = lazy(() => {
  return Promise.all([
    import("./home"),
    new Promise(resolve => setTimeout(resolve, 300))
  ])
  .then(([moduleExports]) => moduleExports);
});

As mentioned by loopmode, component fallback should have a timeout.

import React, { useState, useEffect } from 'react'

const DelayedFallback = () => {
  const [show, setShow] = useState(false)
  useEffect(() => {
    let timeout = setTimeout(() => setShow(true), 300)
    return () => {
      clearTimeout(timeout)
    }
  }, [])

  return (
    <>
      {show && <h3>Loading ...</h3>}
    </>
  )
}
export default DelayedFallback

Then just import that component and use it as fallback.

<Suspense fallback={<DelayedFallback />}>
       <LazyComponent  />
</Suspense>

Fallback component animations with Suspense and lazy

@Akrom Sprinter has a good solution in case of fast load times, as it hides the fallback spinner and avoids overall delay. Here is an extension for more complex animations requested by OP:

1. Simple variant: fade-in + delayed display

const App = () => {
  const [isEnabled, setEnabled] = React.useState(false);
  return (
    <div>
      <button onClick={() => setEnabled(b => !b)}>Toggle Component</button>
      <React.Suspense fallback={<Fallback />}>
        {isEnabled && <Home />}
      </React.Suspense>
    </div>
  );
};

const Fallback = () => {
  const containerRef = React.useRef();
  return (
    <p ref={containerRef} className="fallback-fadein">
      <i className="fa fa-spinner spin" style={{ fontSize: "64px" }} />
    </p>
  );
};

/*
 Technical helpers
*/

const Home = React.lazy(() => fakeDelay(2000)(import_("./routes/Home")));

// import_ is just a stub for the stack snippet; use dynamic import in real code.
function import_(path) {
  return Promise.resolve({ default: () => <p>Hello Home!</p> });
}

// add some async delay for illustration purposes
function fakeDelay(ms) {
  return promise =>
    promise.then(
      data =>
        new Promise(resolve => {
          setTimeout(() => resolve(data), ms);
        })
    );
}

ReactDOM.render(<App />, document.getElementById("root"));
/* Delay showing spinner first, then gradually let it fade in. */
.fallback-fadein {
  visibility: hidden;
  animation: fadein 1.5s;
  animation-fill-mode: forwards;
  animation-delay: 0.5s; /* no spinner flickering for fast load times */
}

@keyframes fadein {
  from {
    visibility: visible;
    opacity: 0;
  }
  to {
    visibility: visible;
    opacity: 1;
  }
}

.spin {
  animation: spin 2s infinite linear;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(359deg);
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<link
  rel="stylesheet"
  href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<div id="root"></div>

You just add some @keyframes animations to Fallback component, and delay its display either by setTimeout and a state flag, or by pure CSS (animation-fill-mode and -delay used here).

2. Complex variant: fade-in and out + delayed display

This is possible, but needs a wrapper. We don't have a direct API for Suspense to wait for a fade out animation, before the Fallback component is unmounted.

Let's create a custom useSuspenseAnimation Hook, that delays the promise given to React.lazy long enough, so that our ending animation is fully visible:

// inside useSuspenseAnimation
const DeferredHomeComp = React.lazy(() => Promise.all([
    import("./routes/Home"), 
    deferred.promise // resolve this promise, when Fallback animation is complete
  ]).then(([imp]) => imp)
)

const App = () => {
  const { DeferredComponent, ...fallbackProps } = useSuspenseAnimation(
    "./routes/Home"
  );
  const [isEnabled, setEnabled] = React.useState(false);
  return (
    <div>
      <button onClick={() => setEnabled(b => !b)}>Toggle Component</button>
      <React.Suspense fallback={<Fallback {...fallbackProps} />}>
        {isEnabled && <DeferredComponent />}
      </React.Suspense>
    </div>
  );
};

const Fallback = ({ hasImportFinished, enableComponent }) => {
  const ref = React.useRef();
  React.useEffect(() => {
    const current = ref.current;
    current.addEventListener("animationend", handleAnimationEnd);
    return () => {
      current.removeEventListener("animationend", handleAnimationEnd);
    };

    function handleAnimationEnd(ev) {
      if (ev.animationName === "fadeout") {
        enableComponent();
      }
    }
  }, [enableComponent]);

  const classes = hasImportFinished ? "fallback-fadeout" : "fallback-fadein";

  return (
    <p ref={ref} className={classes}>
      <i className="fa fa-spinner spin" style={{ fontSize: "64px" }} />
    </p>
  );
};

/* 
Possible State transitions: LAZY -> IMPORT_FINISHED -> ENABLED
- LAZY: React suspense hasn't been triggered yet.
- IMPORT_FINISHED: dynamic import has completed, now we can trigger animations.
- ENABLED: Deferred component will now be displayed 
*/
function useSuspenseAnimation(path) {
  const [state, setState] = React.useState(init);

  const enableComponent = React.useCallback(() => {
    if (state.status === "IMPORT_FINISHED") {
      setState(prev => ({ ...prev, status: "ENABLED" }));
      state.deferred.resolve();
    }
  }, [state]);

  return {
    hasImportFinished: state.status === "IMPORT_FINISHED",
    DeferredComponent: state.DeferredComponent,
    enableComponent
  };

  function init() {
    const deferred = deferPromise();
    // component object reference  is kept stable, since it's stored in state.
    const DeferredComponent = React.lazy(() =>
      Promise.all([
        // again some fake delay for illustration
        fakeDelay(2000)(import_(path)).then(imp => {
          // triggers re-render, so containing component can react
          setState(prev => ({ ...prev, status: "IMPORT_FINISHED" }));
          return imp;
        }),
        deferred.promise
      ]).then(([imp]) => imp)
    );

    return {
      status: "LAZY",
      DeferredComponent,
      deferred
    };
  }
}

/*
technical helpers
*/

// import_ is just a stub for the stack snippet; use dynamic import in real code.
function import_(path) {
  return Promise.resolve({ default: () => <p>Hello Home!</p> });
}

// add some async delay for illustration purposes
function fakeDelay(ms) {
  return promise =>
    promise.then(
      data =>
        new Promise(resolve => {
          setTimeout(() => resolve(data), ms);
        })
    );
}

function deferPromise() {
  let resolve;
  const promise = new Promise(_resolve => {
    resolve = _resolve;
  });
  return { resolve, promise };
}

ReactDOM.render(<App />, document.getElementById("root"));
/* Delay showing spinner first, then gradually let it fade in. */
.fallback-fadein {
  visibility: hidden;
  animation: fadein 1.5s;
  animation-fill-mode: forwards;
  animation-delay: 0.5s; /* no spinner flickering for fast load times */
}

@keyframes fadein {
  from {
    visibility: visible;
    opacity: 0;
  }
  to {
    visibility: visible;
    opacity: 1;
  }
}

.fallback-fadeout {
  animation: fadeout 1s;
  animation-fill-mode: forwards;
}

@keyframes fadeout {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

.spin {
  animation: spin 2s infinite linear;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(359deg);
  }
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.0/umd/react.production.min.js" integrity="sha256-32Gmw5rBDXyMjg/73FgpukoTZdMrxuYW7tj8adbN8z4=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.0/umd/react-dom.production.min.js" integrity="sha256-bjQ42ac3EN0GqK40pC9gGi/YixvKyZ24qMP/9HiGW7w=" crossorigin="anonymous"></script>
<link
  rel="stylesheet"
  href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<div id="root"></div>

Key points for complex variant

1.) useSuspenseAnimation Hook returns three values:

  • hasImportFinished (boolean) → if true, Fallback can start its fade out animation
  • enableComponent (callback) → invoke it to unmount Fallback, when animation is done.
  • DeferredComponent → extended lazy Component loaded by dynamic import

2.) Listen to the animationend DOM event, so we know when animation has ended.

If anyone is looking for a typescript, abstracted solution:

import { ComponentType, lazy } from 'react';

export const lazyMinLoadTime = <T extends ComponentType<any>>(factory: () => Promise<{ default: T }>, minLoadTimeMs = 2000) =>
  lazy(() =>
    Promise.all([factory(), new Promise((resolve) => setTimeout(resolve, minLoadTimeMs))]).then(([moduleExports]) => moduleExports)
  );

Usage:

const ImportedComponent = lazyMinLoadTime(() => import('./component'), 2000)

props to @Estus Flask for a super helpful answer. I was using just the setTimeout functionality before, but couldn't get tests to work at all. Calling setTimeout in the tests was causing nested calls, jest.useFakeTimers() and jest.runAllTimers() didn't seem to do anything, and I was stuck getting the loader back. Since searching for the right way to test this took so long, I figured it would be helpful share how I was able to test it. With an implementation per the given solution:

import React, { ReactElement, Suspense } from 'react';
import { Outlet, Route, Routes } from 'react-router-dom';

import Loader from 'app/common/components/Loader';


const Navigation = React.lazy(() => {
  return Promise.all([
    import("./Navigation"),
    new Promise(resolve => setTimeout(resolve, 300))
  ])
  .then(([moduleExports]) => moduleExports);
});
const Home = React.lazy(() => {
  return Promise.all([
    import("./Home"),
    new Promise(resolve => setTimeout(resolve, 300))
  ])
  .then(([moduleExports]) => moduleExports);
});

interface PagesProps {
  toggleTheme: () => void;
}

const Pages = (props: PagesProps): ReactElement => (
  <Suspense fallback={<Loader />}>
    <Routes>
      <Route path="/" element={
        <>
          <Navigation toggleTheme={props.toggleTheme}/>
          <Outlet />
        </>
      }>
        <Route index element={<Home />} />
      </Route>
    </Routes>
  </Suspense>
);

export default Pages;

I was able to successfully test it with the following. Note that if you don't include jest.useFakeTimers() and jest.runAllTimers() you'll see flaky tests. There's a little excess detail in my tests because I'm also testing pushing the history (in other tests), but hopefully this helps anyone else!

/**
 * @jest-environment jsdom
 */
import { render, screen, cleanup, waitFor } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';

import Pages from './';

describe('Pages component', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  })
  const history = createMemoryHistory();

  it('displays loader when lazy', async () => {
    render(
      <Router location={history.location} navigator={history} navigationType={history.action}>
        <Pages toggleTheme={function (): void { return null; } } />
      </Router>,
    );

    const lazyElement = await screen.findByText(/please wait/i);
    expect(lazyElement).toBeInTheDocument();
  });

  it('displays "Welcome!" on Home page lazily', async () => {
    render(
      <Router location={history.location} navigator={history} navigationType={history.action}>
        <Pages toggleTheme={function (): void { return null; } } />
      </Router>,
    );

    const fallbackLoader = await screen.findByText(/please wait/i);
    expect(fallbackLoader).toBeInTheDocument();
    jest.runAllTimers();

    const lazyElement = await screen.findByText('Welcome!');
    expect(lazyElement).toBeInTheDocument();
  });

  afterEach(cleanup);
});

You should create a fallback component that itself has a timeout and a visible state. Initially you set visible false. When fallback component gets mounted, it should setTimeout to turn visible state flag on. Either make sure your component is still mounted, or clear the timeout when the component gets unmounted. Finally, if visible state is false, render null in your fallback component (or e.g. just blocking/semi-transparent overlay but no spinner/animation)

Then use such component, e.g. <Loading overlay/>, as fallback.

To avoid flashing a loader if the loading is very fast, you could use a p-min-delay function, that delays a promise a minimum amount of time. Useful when you have a promise that may settle immediately or may take some time, and you want to ensure it doesn't settle too fast.

For example:

import { Suspense, lazy } from 'react';
import { PageLoadingIndicator } from 'components';
import pMinDelay from 'p-min-delay';

const HomePage = lazy(() => pMinDelay(import('./pages/Home'), 500));

function App() {
  return (
    <Suspense fallback={<PageLoadingIndicator />}>
      <HomePage />
    </Suspense>
  );
}

export default App;

If you are experiencing a screen flickering issue (Fallback screen gets visible and gets disappeared in split of second due to the fast loading of the next component) you can solve it by adding a startTransition.
Eg:- If you have lazy loaded a modal component, and if you are making it appear by calling an state action named 'setOpenModal', you can call it inside a startTransition function like below to avoid the flickering issue.

import { startTransition } from 'react';

function ComponentA() {
  const [openModal, setOpenModal] = useState(false);

  const modalHandler = () => {
     startTransition(() => {
       setOpenModal(!openModal)
     });
  }
  
}

You can find more details about this using below react documentation.
https://react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding

I faced similar problem moreover I was using TypeScript along with React. So, I had to respect typescript compiler as well & I went ahead with an approach having an infinite delay along with no complain from typescript as well. Promise that never resolved

本文标签: javascriptReact suspenselazy delayStack Overflow