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

Оглавление

  • Разделение кода
  • Ленивая загрузка
  • Проблема с React.lazy
  • Решение
  • Заключение
  • Ресурсы

Разделение кода

В мире одностраничных приложений обычно имеется один JS-файл, который управляет всем вашим приложением. Это означает, что пользователи всегда загружают один JS-файл, который может содержать ненужный код. Например, когда пользователь открывает страницу /about, он также загружает код для страницы /dashboard. Вот пример SPA без отложенной загрузки.

const router = createBrowserRouter([
  {
    path: "/",
    element: <App />,
    children: [
      {
        path: "about",
        element: <About />,
      },
      {
        path: "dashboard",
        element: <Dashboard />,
      },
      {
        path: "settings",
        element: <Settings />,
      },
    ],
  },
]);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

У нас есть приложение с тремя страницами. Давайте создадим наше приложение и запустим его, чтобы посмотреть, что произойдет.

index-[chunk-id] — именно этот один JS-файл состоит из всех наших страниц.

Давайте выполним нашу сборку, запустив команду предварительного просмотра пряжи, а затем открыв вкладку сети и посмотрим, что произойдет.

Если вы посмотрите на URL-адрес, вы увидите, что находитесь на домашней странице, но загружается код для всех наших страниц. Это может подойти для небольших приложений, но подумайте о сценарии с большим приложением, имеющим тысячи строк кода. Итак, что вы можете с этим поделать?

Ленивая загрузка

Отложенная загрузка предполагает разбиение большого фрагмента на более мелкие части и загрузку их только тогда, когда они нужны пользователю. Для реализации отложенной загрузки вам потребуются две вещи:

  1. React.lazyпозволяет отложить загрузку кода компонента до его первой отрисовки.
  2. Приостановкапозволяет отображать резервный вариант до тех пор, пока его дочерние элементы не завершат загрузку.

Первый шаг: вам нужно обновить импорт, чтобы он выглядел следующим образом:

const About = React.lazy(() => import("./pages/About"));
const Dashboard = React.lazy(() => import("./pages/Dashboard"));
const Contact = React.lazy(() => import("./pages/Contact"));

Следующий шаг — обернуть ваши компоненты в компонент Suspense, который имеет резервное свойство для отображения загрузчика до тех пор, пока ваш компонент не будет готов к рендерингу.

// App.tsx

const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <Suspense fallback={<div>Loading...</div>}>
        <Root />
      </Suspense>
    ),
    children: [
      {
        path: "about",
        element: <About />,
      },
      {
        path: "dashboard",
        element: <Dashboard />,
      },
      {
        path: "contact",
        element: <Contact />,
      },
    ],
  },
]);

Вы можете обернуть в Suspense только родительский компонент, поскольку остальные маршруты вложены. Поэтому нет необходимости заключать каждый из них по отдельности в Suspense.

Давайте еще раз пересоберем ваше приложение и посмотрим, что изменилось.

Вы можете заметить, что теперь на каждой странице есть три фрагмента. Запустите приложение и проверьте вкладку «Сеть», чтобы выявить изменения.

Как вы можете видеть ниже, каждый фрагмент загружается только тогда, когда пользователь нажимает на соответствующую страницу.

Как вы можете видеть выше, каждый чанк загружается только тогда, когда пользователь нажимает на соответствующую страницу.

Проблема с React.Lazy

Но что произойдет, если пользователь не сможет загрузить необходимый чанк? Приложение выйдет из строя, потому что реакции не удается импортировать чанк.

Когда это может произойти?

Это может произойти из-за проблем с сетевым подключением. Это также может произойти, если вы выпустили новую сборку, но у пользователя в браузере кэширована более старая версия. Если пользователь попытается загрузить чанк, используя старый id, это приведет к ошибке.

Решение

Решение довольно простое. Вам нужно создать обертку вокруг React.lazy, и в случае возникновения ошибки вы попытаетесь загрузить чанк еще раз. Я наткнулся на полезную статью, в которой предлагалось решение, поэтому не буду вдаваться в подробности; Я просто предоставлю код:

export const lazyWithRetries: typeof React.lazy = (importer) => {
  const retryImport = async () => {
    try {
      return await importer();
    } catch (error: any) {
      // retry 5 times with 2 second delay and backoff factor of 2 (2, 4, 8, 16, 32 seconds)
      for (let i = 0; i < 5; i++) {
        await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** i));
        // this assumes that the exception will contain this specific text with the url of the module
        // if not, the url will not be able to parse and we'll get an error on that
        // eg. "Failed to fetch dynamically imported module: https://example.com/assets/Home.tsx"
        const url = new URL(
          error.message
            .replace("Failed to fetch dynamically imported module: ", "")
            .trim()
        );
        // add a timestamp to the url to force a reload the module (and not use the cached version - cache busting)
        url.searchParams.set("t", `${+new Date()}`);

        try {
          return await import(url.href);
        } catch (e) {
          console.log("retrying import");
        }
      }
      throw error;
    }
  };
  return React.lazy(retryImport);
};

Если вы используете строгий режим в TypeScript, вы можете столкнуться с такой ошибкой:

Неожиданно. Укажите другой тип. eslint(@typescript-eslint/no-explicit-any)

Эта ситуация может оказаться сложной, поскольку объект ошибки может различаться в зависимости от контекста. Поэтому необходимо создать вспомогательную функцию, которую можно будет повторно использовать в других блоках try catch.

Вот что я нашел:

type ErrorWithMessage = {
  message: string;
};

function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
  return (
    typeof error === "object" &&
    error !== null &&
    "message" in error &&
    typeof (error as Record<string, unknown>).message === "string"
  );
}

function toErrorWithMessage(maybeError: unknown): ErrorWithMessage {
  if (isErrorWithMessage(maybeError)) return maybeError;

  try {
    return new Error(JSON.stringify(maybeError));
  } catch {
    // fallback in case there's an error stringifying the maybeError
    // like with circular references for example.
    return new Error(String(maybeError));
  }
}

export function getErrorMessage(error: unknown) {
  return toErrorWithMessage(error).message;
}

не вдавайтесь здесь в подробности. Если вам интересно, как это работает, рекомендую прочитать эту статью.

Вот переработанная версия lazyWithRetries функции:

export const lazyWithRetries: typeof React.lazy = (importer) => {
  const retryImport = async () => {
    try {
      return await importer();
    } catch (error) {
      // retry 5 times with 2 second delay and backoff factor of 2 (2, 4, 8, 16, 32 seconds)
      for (let i = 0; i < 5; i++) {
        await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** i));
        const errorMessage = getErrorMessage(error);
        // this assumes that the exception will contain this specific text with the url of the module
        // if not, the url will not be able to parse and we'll get an error on that
        // eg. "Failed to fetch dynamically imported module: https://example.com/assets/Home.tsx"
        const url = new URL(
          errorMessage
            .replace("Failed to fetch dynamically imported module: ", "")
            .trim()
        );
        // add a timestamp to the url to force a reload the module (and not use the cached version - cache busting)
        url.searchParams.set("t", `${+new Date()}`);

        try {
          return await import(url.href);
        } catch (e) {
          console.warn("retrying import");
        }
      }
      throw error;
    }
  };
  return React.lazy(retryImport);
};

И теперь вы можете использовать его для ленивого импорта:

const About = lazyWithRetries(() => import("./pages/About"));
const Dashboard = lazyWithRetries(() => import("./pages/Dashboard"));
const Contact = lazyWithRetries(() => import("./pages/Contact"));

На этом все, поздравляю! 🥳

Я надеюсь, что это руководство поможет вам создавать безошибочные приложения!

Заключение

Подводя итоги, вот что нам удалось сделать:

  • Мы разделили наши маршруты, используя React.lazy и Suspense.
  • Мы создаем оболочку lazyWithRetries вокруг React.lazy, чтобы избежать ошибок, когда пользователь не может загрузить необходимый чанк.

Ресурсы

  • Спасибо, Алон Мизрахи за содержательную статью о lazyWithRetries; Я очень рекомендую прочитать ее.
  • Отличная статья, посвященная сообщению об ошибке блока catch с помощью TypeScript.
  • Репозиторий GitHub с кодом, относящимся к этой статье.