Реакция ожидания / отложенная задержка?

Я пытаюсь использовать новые 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);
});

10 ответов

Решение

lazy функция должна вернуть обещание { default: ... } объект, который возвращается import() модуля с экспортом по умолчанию. setTimeout не возвращает обещание и не может быть использовано таким образом. Пока произвольное обещание может:

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);
});

Как упоминалось в 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>

Анимация резервного компонента с Suspense а также lazy

У @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 анимации для Fallback компонент, и отложить его отображение на setTimeout и государственный флаг, или чистым CSS (animation-fill-mode а также -delay используется здесь).

2. Сложный вариант: нарастание и затухание + отображение с задержкой.

Это возможно, но нужна обертка. У нас нет прямого API дляSuspenseчтобы ждать в исчезать из анимации, прежде чемFallback компонент отключен.

Создадим кастом useSuspenseAnimation Крюк, который откладывает обещание, данное 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>

Ключевые моменты для сложного варианта

1.) useSuspenseAnimation Хук возвращает три значения:

  • hasImportFinished (boolean) → если true, Fallback может начать анимацию затухания
  • enableComponent (обратный вызов) → вызвать его, чтобы размонтировать Fallback, когда анимация завершена.
  • DeferredComponent → расширенный ленивый компонент, загружаемый динамическим import

2.) Слушайте animationend Событие DOM, поэтому мы знаем, когда анимация закончилась.

спасибо @Estus Flask за очень полезный ответ. Раньше я использовал только функциональность setTimeout, но вообще не мог заставить тесты работать. Вызов в тестах вызывал вложенные вызовы и, похоже, ничего не делал, и я застрял, возвращая загрузчик. Поскольку поиск правильного способа проверить это занял так много времени, я подумал, что было бы полезно поделиться тем, как я смог это проверить. С реализацией в соответствии с данным решением:

      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-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);
});

Вы должны создать резервный компонент, который сам имеет тайм-аут и видимое состояние. Изначально вы устанавливаете visible false. Когда резервный компонент монтируется, он должен setTimeout включить флаг видимого состояния. Либо убедитесь, что ваш компонент все еще смонтирован, либо сбросьте тайм-аут, когда компонент будет отключен. Наконец, если видимое состояние равно false, отобразите null в резервном компоненте (или, например, просто блокирующее / полупрозрачное наложение, но без счетчика / анимации)

Затем используйте такой компонент, например, , как резервный.

Если кто-то ищет машинописное, абстрактное решение:

      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)

Чтобы избежать перепрошивки загрузчика при очень быстрой загрузке, вы можете использовать функцию 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;
      let shouldNotDelay = false;

export const DelayLoading = () => {
  if (shouldNotDelay) {
    return null;
  }
  throw new Promise((resolve) => {
    setTimeout(() => {
      shouldNotDelay = true;
      resolve(1);
    }, 2000);
  });
};

Вот полная реализация:https://codesandbox.io/s/suspense-delay-7i5b34?file=/src/index.js .

Я столкнулся с подобной проблемой, к тому же я использовал TypeScript вместе с React. Итак, я должен был уважать компилятор машинописного текста, и я пошел дальше с подходом, имеющим бесконечную задержку, а также без жалоб со стороны машинописного текста. Обещание, которое никогда не решалось

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

Вы можете написать новую функцию, которая ожидает как компонента, так и задержки. Этотawait Promise.allнеобходимо дождаться разрешения обоих промисов, поэтому время загрузки занимает не менее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")));
Другие вопросы по тегам