React suspense/lazy延迟?

62

我尝试使用新的React Lazy和Suspense来创建一个回退加载组件。这很好用,但是回退只显示几毫秒。是否有一种方法可以添加额外的延迟或最短时间,以便在下一个组件被渲染之前从此组件中显示动画?

现在进行懒加载导入。

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

等待组件:

function WaitingComponent(Component) {

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

我可以做这样的事情吗?
const Home = lazy(() => {
  setTimeout(import("./home"), 300);
});

2
为什么你想要做这样的事情呢?用户想要使用你的内容和应用程序,而不是看着加载旋转器。懒加载和Suspense的引入是为了让你作为开发者提供更好的用户体验,通过减少初始应用程序加载时间并且不在每个地方显示加载旋转器。你试图做的正好相反。 - Johannes Klauß
3
你谈论的是初始加载时间。Suspense显然是为旋转器和其他懒加载组件的加载指示器而设计的,这就是为什么它有“fallback”。 - Estus Flask
11
@JohannesKlauß,我认为你没有理解我的UX问题。我的目标不是创建一个长达一分钟的旋转加载器,我的目标是创建一个更流畅、更美观的加载功能。提供给用户更好的页面过渡效果是UX中越来越流行的趋势。 - vemund
@JohannesKlauß Suspense/Lazy 的想法也是通过仅加载所需的组件来减少捆绑包大小。这里的目标不是增加加载时间,而是在路由之间实现更平滑的过渡。 - Thibault Boursier
10个回答

90

lazy函数应该返回一个{ default: ... }对象的promise,该对象是由具有默认导出的模块的import()返回的。 setTimeout不返回promise,不能像那样使用。然而,任意的promise可以:

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

如果目标是提供最小延迟,那么这不是一个好的选择,因为这会导致额外的延迟。

最小延迟应该是:

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

请问您能否解释一下为什么第一段代码不起作用?setTimeout将在300毫秒后解决Promise。谢谢。 - Adi Azarya
11
可以,但效果不会和要求的完全一样。延迟时间将是“加载时间加上额外的300毫秒”,而不是“加载时间或至少300毫秒”。 - Estus Flask
5
另一种方法是使用Promise.all([import(...), delayPromise]),它能够并行执行导入和延迟操作,并且更易于理解。 - Palle
不幸的是,这个答案(其中的第一部分)在Typescript环境中不起作用;幸运的是,有一个https://stackoverflow.com/questions/62594862/typescript-react-lazy可以与Typescript一起使用,以实现第1部分所需的额外延迟。 - TheDiveO
非常漂亮,非常适合测试我的加载动画! :) - Kim Skogsmo

17

如 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

那么只需导入该组件并将其用作备选项即可。

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

这并没有帮助。尽管如此,回退加载器只会出现很短的时间。有没有办法让它持续更长时间,因为即使加载器消失后,使用React Route加载组件仍需要相当长的时间? - sareek

9

使用 Suspenselazy 实现备用组件动画

@Akrom Sprinter 提供了一种在加载时间较短的情况下隐藏备用加载动画并避免整体延迟的好方法。以下是 OP 请求的更复杂动画的扩展:

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>

你只需为组件添加一些@keyframes动画,并通过setTimeout和状态标志或纯CSS(使用此处的animation-fill-mode-delay)延迟其显示。

2. 复杂变体:淡入淡出+延迟显示

虽然可以实现,但需要一个包装器。我们没有直接的API来让在组件卸载之前等待淡出动画。

让我们创建一个自定义的useSuspenseAnimation Hook,它会延迟传递给React.lazy的Promise足够长的时间,以便我们的结束动画完全可见:

// 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>

复杂变体的关键点

1.) useSuspenseAnimation Hook 返回三个值:

  • hasImportFinished (boolean) → 如果为true,则Fallback可以开始其淡出动画。
  • enableComponent (callback) → 当动画完成时调用它以卸载Fallback
  • DeferredComponent → 由动态import加载的扩展惰性组件。

2.) 监听animationend DOM事件,以便我们知道动画何时结束。


3

感谢@Estus Flask提供了非常有用的答案。我之前只使用了setTimeout功能,但是无法使测试正常工作。在测试中调用setTimeout会导致嵌套调用,jest.useFakeTimers()jest.runAllTimers()似乎没有任何作用,我一直卡在获取加载器上。由于寻找正确的测试方法花费了很长时间,我认为分享我如何测试它将会很有帮助。根据给定解决方案的实现:

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;

我能够成功地使用以下内容进行测试。请注意,如果您不包括 jest.useFakeTimers()jest.runAllTimers(),则会看到不稳定的测试结果。我的测试用例中有一些多余的细节,因为我还在测试历史记录推送(在其他测试中),但希望这对任何人都有所帮助!

/**
 * @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);
});

2
您需要创建一个备用组件,它本身具有超时和可见状态。最初,您将visible设置为false。 当备用组件被挂载时,应该setTimeout来打开可见状态标志。 确保您的组件仍然已挂载,或者在组件被卸载时清除超时。 最后,如果visible状态为false,则在您的备用组件中呈现null(或例如只有阻止/半透明覆盖但没有旋转/动画)。
然后使用这样的组件,例如,作为备用。

你能提供一个例子吗?我们可以告诉Suspense组件,如果fallback组件被渲染和挂载,它必须保持挂载状态,例如至少1秒钟吗? - tonix

1
为了避免加载非常快时出现闪烁的加载器,您可以使用 p-min-delay 函数,它会延迟承诺的最小时间。当您有一个可能立即解决或需要一些时间才能解决的承诺,并且希望确保它不会太快地解决时,这个函数很有用。
例如:
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;

1
如果有人正在寻找一种TypeScript、抽象化的解决方案:
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)
  );

使用方法:

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

0

目前你的回答不够清晰,请编辑并添加更多细节,以帮助其他人理解它如何回答问题。你可以在帮助中心找到有关如何编写好答案的更多信息。 - Community

0
你可以编写一个新的函数,等待组件和延迟。这个 await Promise.all 必须等待两个 promise 都解决,因此加载时间至少需要 delayMs,从而消除闪烁。
export const importDelay = (importFn: () => Promise<any>, delayMs = 500) =>
    async () => {
        const [ result ] = await Promise.all([
            importFn(),
            new Promise((resolve) => setTimeout(resolve, delayMs))
        ]);
        
        return result as { default: ComponentType<any> };
    };

像这样使用:

const Component = React.lazy(importDelay(import("./component")));

0

我遇到了类似的问题,而且我还在使用TypeScript和React。 因此,我不得不尊重TypeScript编译器,并采用了一种具有无限延迟且没有任何来自TypeScript的投诉的方法。 永远不会解决的Promise

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接