简体   繁体   English

反应悬念/懒惰延迟?

[英]React suspense/lazy delay?

I am trying to use the new React Lazy and Suspense to create a fallback loading component.我正在尝试使用新的 React Lazy 和 Suspense 来创建后备加载组件。 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);
});

lazy function is supposed to return a promise of { default: ... } object which is returned by import() of a module with default export. lazy函数应该返回{ default: ... }对象的承诺,该对象由具有默认导出的模块的import()返回。 setTimeout doesn't return a promise and cannot be used like that. setTimeout不返回承诺,不能像那样使用。 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.正如 loopmode 所提到的,组件回退应该有一个超时。

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带有Suspenselazy后备组件动画

@Akrom Sprinter has a good solution in case of fast load times, as it hides the fallback spinner and avoids overall delay. @Akrom Sprinter 在快速加载时间的情况下有一个很好的解决方案,因为它隐藏了后备微调器并避免了整体延迟。 Here is an extension for more complex animations requested by OP:这是 OP 请求的更复杂动画的扩展:

1. Simple variant: fade-in + delayed display 1.简单变体:淡入+延迟显示

 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).您只需将一些@keyframes动画添加到Fallback组件,并通过setTimeout和状态标志或纯 CSS(此处使用animation-fill-mode-delay )延迟其显示。

2. Complex variant: fade-in and out + delayed display 2.复杂的变体:淡入和出+延迟显示

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.在卸载Fallback组件之前,我们没有用于Suspense等待淡出动画的直接 API。

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:让我们创建一个自定义的useSuspenseAnimation Hook,它将给予React.lazy的承诺延迟足够长的时间,以便我们的结束动画完全可见:

// 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: 1.) useSuspenseAnimation Hook 返回三个值:

  • hasImportFinished ( boolean ) → if true , Fallback can start its fade out animation hasImportFinished ( boolean ) → 如果为trueFallback可以开始淡出动画
  • enableComponent (callback) → invoke it to unmount Fallback , when animation is done. enableComponent (callback) → 在动画完成时调用它来卸载Fallback
  • DeferredComponent → extended lazy Component loaded by dynamic import DeferredComponent → 动态import加载的扩展惰性组件

2.) Listen to the animationend DOM event, so we know when animation has ended. 2.) 监听animationend DOM 事件,所以我们知道动画什么时候结束。

You should create a fallback component that itself has a timeout and a visible state.您应该创建一个回退组件,它本身具有超时和可见状态。 Initially you set visible false.最初您设置了visible false。 When fallback component gets mounted, it should setTimeout to turn visible state flag on.当回退组件被挂载时,它应该 setTimeout 来打开可见状态标志。 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 eg just blocking/semi-transparent overlay but no spinner/animation)最后,如果可见状态为 false,则在您的后备组件中渲染 null(或者例如只是阻塞/半透明覆盖但没有微调器/动画)

Then use such component, eg <Loading overlay/>, as fallback.然后使用这样的组件,例如 <Loading overlay/>,作为后备。

props to @Estus Flask for a super helpful answer.支持@Estus Flask 以获得超级有用的答案。 I was using just the setTimeout functionality before, but couldn't get tests to work at all.我之前只使用 setTimeout 功能,但根本无法进行测试。 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.在测试中调用setTimeout导致嵌套调用, jest.useFakeTimers()jest.runAllTimers()似乎没有做任何事情,我被困在取回加载器的过程中。 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.请注意,如果您不包含jest.useFakeTimers()jest.runAllTimers() ,您将看到不稳定的测试。 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);
});

I faced similar problem moreover I was using TypeScript along with React.此外,我在使用 TypeScript 和 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 😆从未解决的承诺😆

const LazyRoute = lazy(() => {
  return new Promise(resolve => () =>
    import(
      '../../abc'
    ).then(x => x e => null as never),
  );
});

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.为了避免在加载非常快时闪烁加载器,您可以使用p-min-delay函数,将承诺延迟最少的时间。 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 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)

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM