И снова здравствуй. И добро пожаловать в четвертую часть моего ультимативного гайда. Приготовься — скоро ты станешь настоящим профи.
Содержание серии
Как сделать бесконечно ленивым: Ультимативный гайд.
Часть 4: Преждевременная загрузка || English version
Ранее мы говорили о том, как улучшить кэширование наших проектов и правильно загружать вендоры. А в этой статье мы рассмотрим следующее:
-
Как мы можем использовать стратегии предварительной загрузки, в том числе:
Что такое "магические" комментарии Webpack и как они могут нам помочь;
Что такое спекулятивная или ручная предварительная загрузка и как ее использовать;
Как мы можем запрашивать данные с сервера, не дожидаясь загрузки наших статических файлов.
А также какие сторонние или наши собственные решения могут быть использованы для этого.
Используй магические комментарии
Сейчас мы должны неплохо понимать, как эффективно разбивать наши файлы на чанки и оптимизировать размер и количество загружаемых файлов. Но означает ли это, что мы максимально оптимизировали время загрузки? Не совсем. У нас все еще есть возможности для совершенствования.
Представьте себе такой сценарий: вы максимально разделили свое приложение, оптимизировали дерево зависимостей и разделили вендоры. Вы открываете свой веб-сайт, и время начальной загрузки значительно сокращается - это здорово! Но когда вы открываете ленивую страницу, браузеру все равно требуется некоторое время, чтобы загрузить ее.
ℹ️ Ленивая загрузка просто откладывает загрузку приложения.
- Если компонент не используется, мы экономим время.
- Но если он используется, задержка возникает несколько позже.
Несмотря на этот недостаток, ленивая загрузка - это по-прежнему хорошая стратегия оптимизации по всем причинам, которые я уже упоминал в этой серии. Но мы можем сделать эту стратегию еще лучше, если устраним такую задержку. Для этого мы можем предварительно загружать наши ленивые чанки. И один из способов сделать это - использовать магические комментарии Webpack'а.
Что это такое? На протяжении всей статьи вы, возможно, замечали, что в нашем пет-проекте Webpack генерировал файлы с именами "Chapter1.chunk.js" и "Chapter2.chunk.js". Эти части "Chapter1" и "Chapter2" были сгенерированы исключительно по той причине, что я использовал специальный "магический" комментарий - webpackChunkName. Этот комментарий не влияет на реальную логику нашего приложения, но способен повлиять на имена сгенерированных файлов.
const Chapter2 = React.lazy(
() => import(/* webpackChunkName: "Chapter2" */ './pages/chapter-2/Chapter2')
);
На самом деле существует множество магических комментариев, и я рекомендую вам уделить некоторое время их изучению в документации Webpack'а. Но в рамках этой статьи мы рассмотрим только webpackPrefetch и webpackPreload. Оба этих комментария повлияют на нашу сборку так, что сгенерированные JavaScript файлы начнут автоматически добавлять <link rel="prefetch"> (or "preload") в код страницы.
const Chapter2 = React.lazy(
() => import(/* webpackPrefetch: true */ './pages/chapter-2/Chapter2')
);
Когда мы используем эти комментарии, мы говорим браузеру, что мы хотим загрузить чанки нашего приложения, даже если в данный момент они не будут выполняться. И есть некоторая разница между использованием prefetch и preload.
Особенности Prefetch
При использовании prefetch, браузер будет запрашивать наши файлы с lowest приоритетом. Он постарается запрашивать эти файлы в режиме простоя, т.е. когда другие файлы не будут скачиваться. Также браузер не будет тратить ресурсы на парсинг этих файлов. Скачав файл с помощью prefetch, браузер сохранит его в кэш.

Если мы добавим webpackPrefetch для Chapter2 и откроем страницу Chapter1, мы увидим, что браузер будет скачивать чанки для отображения обоих этих страниц. Однако, у prefetched файлов тип будетtext/javascript а не script. Это происходит из-за того, что браузер не пытается заниматься предварительным парсингом этих файлов.
Если мы уже предзагрузили файлы с помощью prefetch, и мы пытаемся открыть страницу Chapter2, браузер будет делать еще один запрос для скачивания файлов Chapter2. Однако, в этот раз файлы будут взяты из кэша. И браузер только потратит время на парсинг этих файлов.

Таким образом, мы нивелировали ту самую задержку, о которой мы говорили в начале статьи.
Возможно, вы также заметили, что Chapter 2 в основном загружается одновременно с Chapter 1, что может быть воспринято как нарушение "запроса только в режиме простоя". Однако это не так. Браузер способен загружать несколько файлов одновременно. И когда он видит, что у него есть ресурсы, он может начать предварительную загрузку файла, даже если другие файлы все еще загружаются.
Особенности Preload
Ситуация с preload немного другая:
Браузер будет скачивать файлы с
highприоритетом. На предыдущих скриншотах вы можете видеть, что по умолчанию исполняемые файлы скачиваются с приоритетомlow, поэтому браузер может приоритезировать некоторыеpreloadфайлы над исполняемыми файлами.Также скаченные файлы будут скачиваться с типом
script, т.е. браузер будет их сразу парсить.Однако,
webpackPreloadне работает при использовании его в entry файлах. Но мы не будем подробно останавливаться на этом в этой статье.

Предзагрузив скрипт с помощью preload, браузер больше будет запрашивать его еще раз, когда мы попытаемся его исполнить.
Ошибки использования preload и prefetch
Использование prefetch и preload может значительно улучшить производительность нашего сайта, а вместе с тем и UX. Однако, неправильная настройка этих директив может также привести и к ухудшению производительности.
Тема использования preload и prefetch сама по себе относительно обширна. Чтобы полноценно понимать, каковы риски неправильного применения этих директив, нужно:
разбираться в разнице протоколов HTTP/1.1, HTTP/2 и HTTP/3;
понимать принцип работы ресурс менеджера в браузерах и понимать как они скачивают файлы с разными приоритетами (lowest, low, high, highest);
в высоко нагруженных проектах необходимо так же учитывать объем трафика и стоимость использования CDN сервисов.
а также разбираться в некоторых других мелких аспектах.
В рамках этой статьи мы не будем подробно углубляться в детали этих директив. Однако, я планирую написать отдельную статью покрывающую все эти вопросы позже. И я приложу ссылку на нее в этом блоке, когда сделаю это. Но все же я слегка затрону тему того, чем может быть опасно чрезмерное использование prefetch и preload.
В документации Webpack, официальной рекомендацией считается использование prefetch и preload исключительно для критических ресурсов наших проектов. Например, мы можем применять prefetch для часто используемых ленивых страниц, но не стоит использовать его для редко используемых модальных окон.
Чрезмерное использование preload
Самое страшное, что мы можем сделать - это неправильно настроить использование preload. Как я уже упомянул, preloaded скрипты загружаются с high приоритетом, в то время как исполняемые скрипты по умолчанию загружаются с low приоритетом. Это означает, что по умолчанию браузер приоритезирует загрузку preloaded файла над исполняемым.
Вот пример того, как браузер будет скачивать файлы, если мы открываем страницу Chapter1, но используем preload только для страницы Chapter2. Несмотря на то, что chapter1.chunk.js нужен для отображения страницы, браузер сначала скачает chapter2.chunk.js, и только потом начнет скачивание chapter1.chunk.js. Таким образом, мы добавили использование preload на наш сайт, но значительно ухудшили скорость загрузки для страницы Chapter1.

Чтобы дать вам реальный кейс этой проблемы, я расскажу вам об одном из своих проектов. Я работал над микро-frontend приложением, в котором на одной странице нужно было отобразить несколько приложений. Некоторые MFE приложения были менее важны, чем другие, с точки зрения ценности для бизнеса. Но поскольку одно менее важное приложение неправильно настроило preload, браузер сначала выполнял 120 запросов (да-да, все с preload) и загружал 11 МБ файлов, прежде чем начать загрузку более важного приложения. Это привело к значительным задержкам для пользователей с плохим подключением к интернету, особенно когда они использовали рабочий VPN.
Конечно, мой случай выглядит как чересчур заоблачное преувеличение, и в большинстве проектов таких серьезных проблем не может возникнуть. Но, как вы видели, даже в крошечном пет проекте, у нас удалось понизить производительность своей неосторожностью.
Чрезмерное использование prefetch
С prefetch все далеко не так все страшно с точки зрения времени загрузки файлов. Т.к. приоритет загрузки prefetched файлов lowest, браузер будет приоритезировать исполняемые файлы над prefetch файлами. Однако, в виду наличия менеджера ресурсов в браузере, даже lowest-priority файлы могут слегка помешать скачиванию low-priority файлов.
Ключевая разница между preload и prefetch в том, что в preload браузер сначала скачает абсолютно все файлы, которые мы указали в preload. А в prefetch, если загрузка каких-то файлов уже началась, браузер сначала скачает их, затем скачает исполняемые файлы, а затем может продолжить предзагружать оставшиеся prefetch файлы.
Однако, опять же, скорость загрузки это лишь часть проблемы. Поэтому старайтесь быть осторожны при использовании и prefetch, и preload.
? Используйте prefetch для критически важных чанков вашего приложения. Но не переусердствуйте, чтобы не понизить производительность вашего сайта.
Спекулятивная/Ручная загрузка
Однако есть еще способы улучшить скорость загрузки, т.к. мы все еще имеем некоторые проблемы. Во-первых, даже если мы используем prefetch, React все равно ненадолго покажет fallback Suspense'а при парсинге файлов. Всего на мгновение. Эта задержка незначительна, но визуальное "мигание" не идеально с точки зрения UX. Кроме того, нам все еще нужна какая-то стратегия оптимизации для некритичных чанков.
Что мы можем сделать, так это попытаться предсказать (или спекулировать), когда тот или иной компонент будет использован, основываясь на действиях пользователя, и загрузить его вручную. И мы можем подойти к этому творчески. Вот что я делаю в своем текущем проекте:
-
Когда пользователь наводит курсор мыши на ссылку, мы вручную предварительно загружаем все файлы для отображения страницы, на которую ведет эта ссылка.
На самом деле, это самая базовая стратегия спекулятивной предварительной загрузки, и некоторые фреймворки (например, NextJS) предоставляют такую оптимизацию по умолчанию.
Когда пользователь наводит курсор на кнопку или фокусируется на ней с помощью клавиатуры, может загружаться модальное окно или боковая панель.
Некоторые элементы загружаются на основе скролла и наблюдения за тем, когда элементы попадают в видимую часть сайта для пользователя.
Также на моем веб-сайте есть строка поиска, которая может вести на разные страницы в зависимости от запроса пользователя. И пока пользователь указывает свой запрос, я уже пытаюсь загрузить страницу, которая может заинтересовать пользователя.
И это далеко не все. Надеюсь, вы поняли идею.
Единственным недостатком этого подхода является то, что, если мы рассматриваем возможность использования спекулятивной предварительной загрузки вручную, нам нужно предварительно загружать каждый отдельный компонент вручную. Что увеличивает время разработки. Но не пугайтесь этого. Большинство сценариев спекулятивной предварительной загрузки могут быть автоматизированы. А те, что автоматизировать нельзя, по-прежнему легко внедрять и поддерживать.
Решение простó
Вот код, который мы можем использовать вместо React.lazy для организации спекулятивной загрузки. Он очень короткий: всего 20 строк кода. В основном этот код - просто обертка для lazy, и он предоставляет только новый статический метод для загрузки кода. И этого небольшого фрагмента кода уже достаточно для того, чтобы вы начали использовать спекулятивную предварительную загрузку.
lazyWithPreloading.tsx
export type LazyPreloadableComponent<T> = NamedExoticComponent<T> & {
preload: () => Promise<void>;
};
export const lazyWithPreload = <T,>(
request: () => Promise<{ default: ComponentType<T> }>,
config: TConfig = {},
): LazyPreloadableComponent<T> => {
const ReactLazyComponent = lazy(request);
let PreloadedComponent: ComponentType<T> | undefined;
const Component = memo((props: T) => {
const ComponentToRender = useRef(PreloadedComponent ?? ReactLazyComponent).current;
return <ComponentToRender {...(props as any)} />;
}) as unknown as LazyPreloadableComponent<T>;
Component.preload = async () => {
await request().then((module) => {
PreloadedComponent = module.default;
});
};
return Component;
};В последнее время я нечасто работал с SSR-приложениями, поэтому не чувствую необходимости использовать полноценные сторонние решения для ленивой загрузки своих компонентов. Но если у вас есть SSR-приложение или вы просто не хотите копировать и вставлять этот фрагмент кода длиной в 20 строк, вы можете использовать стороннее решение. Например, @lodable/component или просто react-lazy-with-preload.
И вот как можно было бы использовать это в App.tsx в нашем пет-проекте:
App.tsx
const Chapter2 = lazyWithPreload(
() => import(
/* webpackChunkName: "Chapter2" */
/* webpackPrefetch: true */
'./pages/chapter-2/Chapter2'
)
);
const Chapter1 = lazyWithPreload(
() => import(/* webpackChunkName: "Chapter1" */ './pages/chapter-1/Chapter1')
);
export const App = () => (
<HashRouter>
<span className="loaded-at">
Loading time: {loadedAt}ms
</span>
<nav className="navigation">
<ul>
<li><Link to="/">Title</Link></li>
<li><Link to="/chapter-1" onMouseMove={() => Chapter1.preload()}>Chapter 1</Link></li>
<li><Link to="/chapter-2" onMouseMove={() => Chapter2.preload()}>Chapter 2</Link></li>
</ul>
</nav>
<Suspense fallback="Loading main...">
<div className="book-grid">
<Routes>
<Route path="/" element={<Title />} />
<Route path="/chapter-1" element={<Chapter1 />} />
<Route path="/chapter-2" element={<Chapter2 />} />
</Routes>
</div>
</Suspense>
</HashRouter>
);Теперь, когда пользователь наведет курсор на любую из этих ссылок, страница будет загружена вручную. И когда пользователь нажмет на ссылку, есть большая вероятность, что страница будет отображена мгновенно.
? Попробуйте предварительно загружать свои ленивые компоненты вручную, основываясь на действиях пользователя.
Если мы используем спекулятивную предварительную загрузку, это не значит, что мы должны отказаться от использования prefetch. Эти стратегии дополняют друг друга. Однако, учитывая как опасен может быть preload, я лично стараюсь избегать его использования.
ℹ️ Благодаря сочетанию prefetch и ручной предварительной загрузки мы можем отображать наши компоненты так, как будто они совсем не ленивы, что устранило единственный недостаток, связанный с отложенной загрузкой.
Как уменьшить количество ручной работы
Как я уже упоминал, мы можем автоматизировать некоторые из сценариев предварительной загрузки. Например, кнопки, открывающие модальные окна, или ссылки, открывающие страницы. Вместо того, чтобы вручную добавлять вызов предварительной загрузки каждый раз, когда мы используем эти компоненты, мы можем скрыть такую логику внутри этих компонентов.
export const ButtonWithModal = (props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<Button
onMouseOver={() => ModalLazy.preload()}
onFocus={() => ModalLazy.preload()}
onClick={() => setIsOpen(true)}
{...props}
>
Open window
</Button>
{isOpen && <ModalLazy {...} />)
</>
);
};
import { RoutePathToComponentMap } from '../somewhere';
export const Link = (props) => {
const page = RoutePathToComponentMap[props.href];
return (
<a
{...props}
onMouseOver={() => page?.preload()}
onFocus={() => page?.preload()}
/>
)
};
Предзагружайте данные, не только файлы
До сих пор мы обсуждали, как оптимизировать отложенную загрузку статических файлов. Но реальные приложения обычно также полагаются на серверные данные. И при компонентном подходе считается хорошей практикой вызывать API из компонента, который в нем нуждается. Например, так:
Chapter1.tsx + API request
export default () => {
const { data, isLoading } = useApi<{}, { ping: 'pong' }>('POST', '/api/data');
return (
<>
<section className="page">
<h2 style={{ margin: 'auto' }}>Chapter 1</h2>
</section>
{isLoading ? <section className="page">Loading...</section> : <Content data={data} />}
</>
);
}Это приемлемый подход, хотя у него есть серьезный недостаток. Данные будут запрошены только после загрузки чанков для отображения страницы.

Однако мы можем сделать лучше. Мы можем запрашивать данные, не дожидаясь ленивого чанка. И чтобы это стало возможным, код, отвечающий за запрос API, должен храниться в исходном чанке. Это просто. Вместо вызова API в Chapter1.tsx мы можем заставить этот компонент принимать данные в качестве пропса.
Chapter1.tsx + API data as a prop
type Props = {
requestData: { data?: { ping: 'pong' }, isLoading: boolean };
}
export default ({ requestData }: Props) => {
const { data, isLoading } = requestData;
return (
<>
<section className="page">
<h2 style={{ margin: 'auto' }}>Chapter 1</h2>
</section>
{isLoading ? <section className="page">Loading...</section> : <Content data={data} />}
</>
);
}Вы можете заметить, что содержание файла Chapter1.tsx осталось практически неизменным: мы изменили только 1 строку кода, так что такой подход в основном безвреден с точки зрения DX. Вопрос только в том, где нам тогда запрашивать данные.
Мы могли бы запросить это в компоненте App, но это было бы нарушением компонентного подхода, чего мы также не хотим делать. Но что мы можем сделать в этом случае?
И на самом деле есть несколько способов оптимально организовать подобные запросы. Мне нравится использовать свое собственное решение: обертку поверх lazyWithPreload, которая сама по себе является оберткой поверх React.lazy.
Own solution HOC: lazyWithPreloadAndPrefetch.tsx
export const lazyWithPreloadAndPrefetch = <T, Props>(
request: () => Promise<{ default: ComponentType<T> }>,
{
usePrefetch,
...config
}: TConfig & {
usePrefetch: (props: Props) => T;
},
): LazyPreloadableComponent<Props> => {
const LazyComponent = lazyWithPreload(request, config);
const Component = memo((props: Props) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return <LazyComponent {...(usePrefetch(props) as any)} />;
}) as unknown as LazyPreloadableComponent<unknown>;
Component.preload = LazyComponent.preload;
return Component;
};Для Client Rendered Applications найди удобного стороннего решения у меня не удалось. Однако, есть решения для загрузки данных на стороне сервера с помощью SSR или React Server Components. И я бы хотел про них поговорить тоже, они не совсем про lazy loading, да и куда серию еще больше раздувать. Но ради приличия я приведу пример, как можно было бы предзагружать данные с помощью @loadable/component.
const Chapter1Lazy = lazyWithPreloadAndPrefetch(
() => import(/* webpackChunkName: "Chapter1" */ /* webpackPrefetch: true */ './pages/chapter-1/Chapter1'), {
usePrefetch: () => {
const requestData = useApi<{}, { ping: 'pong' }>('POST', '/api/data');
return { requestData };
},
});
// or
import loadable from '@loadable/component';
const Chapter1Lazy = loadable(() =>
Promise.all([
import(/* webpackChunkName: "Chapter1" */ /* webpackPrefetch: true */ './pages/chapter-1/Chapter1'),
request<{}, { ping: 'pong' }>('POST', '/api/data'),
]).then(([Chapter1, data]) => {
const Chapter1 = Chapter1.default;
return function () {
return <ComponentA data={data} />;
};
}),
{
fallback: <div>Loading components and data...</div>,
}
);
Таким образом, нам также удалось не нарушить компонентный подход. Несмотря на то, что логика запроса не хранится в ленивом компоненте, она по-прежнему является частью Chapter1Lazy. И данные будут запрошены, только когда компонент будет отрендерен, т.е. только когда данные действительно будут нужны.
И теперь наш waterfall запросов выглядит следующим образом. Заметили, что данные запрашиваются почти одновременно с ленивыми чанками? Таким образом, мы загружаем данные с сервера во время загрузки наших ленивых чанков. Данные запрашиваются примерно на 200ms раньше, следовательно, они отображаются примерно на 200ms раньше. Успех.

Хотя кто-то может возразить, что такой подход помещает код для запроса данных в исходный чанк, что увеличивает его размер и вредит UX, замедляя начальную загрузку. И это справедливо. Но если вы сохраните логику запроса максимально простой, дополнительный вес будет незначительным, в то время как общее время загрузки значительно улучшится.
И этот подход также не ухудшит кэшируемость, потому что исходные файлы всегда теряют свой кэш, независимо от того, какие изменения мы вносим.
? Попробуйте запросить данные с сервера, не дожидаясь загрузки ленивого чанка.
Пример из реальной жизни
Этот подход не ограничивается предварительной загрузкой данных только для отображения страниц. Мы можем подойти к нему творчески. Позвольте мне показать вам, как я использовал этот подход в своем текущем проекте.
Это упрощенный код одной из страниц:
export default ({ roomID }: { roomID: string }) => {
const { data, isLoading } = useApi('POST', '/api/room', {
params: { roomID },
});
return (
<>
<section className="left">
<VideoPlayer data={data} isLoading={isLoading} />
</section>
<div className="right">
{data && (
<>
<Card1Lazy room={data} roomID={roomID} />
<Card2Lazy room={data} roomID={roomID} />
<Card3Lazy room={data} roomID={roomID} />
</>
)}
</div>
</>
);
}
И каждая карточка раньше выглядела вот так:
export default ({ room, roomID }: { room: RoomData, roomID: string }) => {
const { data, isLoading } = useApi('POST', '/api/card/1', {
params: { roomID },
});
return (...)
};
И таймлайн рендеринга этих карточек выглядел следующим образом:

Браузер загружает исходные фрагменты JavaScript
Браузер загружает отложенные фрагменты для этой конкретной страницы
-
Браузер запрашивает данные о комнате для прямой трансляции и ожидает, пока не получит ответ.
Каждой карточке нужны данные о комнате для отображения интерфейса, но не для запроса данных. Но поскольку компонент не может отрисоваться без данных о комнате, данные не запрашиваются.
Затем, получив данные о комнате, карточки начинают отрисовываться, и мы загружаем их их ленивый чанки.
Затем для каждой из карточек мы запрашиваем данные.
Чтобы отобразить каждую из карточек, пользователям приходилось ждать от 2,8 до 4,1 секунды, в зависимости от задержки API.
Но вот что я сделал:
Начал запрашивать данные о комнате одновременно с загрузкой ленивых чанков страницы;
Начал запрашивать данные о карточках сразу после загрузки ленивых чанков страницы. Даже если мы не можем отобразить эти карточки, мы должны иметь возможность запрашивать их данные.

Теперь каждая из карт ожидает 3 параллельных процесса: данные с сервера о стриме, данные с сервера для каждой из карточек и статические файлы карточки. Если какой-либо процесс из этих трех не завершен, отображается fallback. Я максимально распараллелил время загрузки, и мне удалось сократить время отображения каждой карточки примерно на 1,4 секунды. Кроме того, другие компоненты страницы, например, видеоплеер, теперь отображается на 0,2 секунды быстрее.
? Будьте креативны.
Заключение
Ладно, это было долго, но на сегодня хватит. Спасибо, что снова присоединились ко мне в нашем стремлении сделать наши веб-приложения бесконечно ленивыми. Если у вас есть какие-либо вопросы, не стесняйтесь задавать их в комментариях.
И, чтобы подвести итог этой статье, давайте перечислим правила, которые мы узнали сегодня:
? Используйте prefetch для критически важных чанков вашего приложения. Но не переусердствуйте, чтобы не понизить производительность вашего сайта.
? Попробуйте предварительно загружать свои ленивые компоненты вручную, основываясь на действиях пользователя.
? Попробуйте запросить данные с сервера, не дожидаясь загрузки отложенного фрагмента.
? И самое главное: Будьте креативны.
И-и-и на этом все. Я надеюсь, что хоть кто-то из вас прочитали все статьи этой серии. И я надеюсь, что некоторые из вас даже почерпнут из них что-то новое. Итак, что вы думаете? Я слишком все усложняю, или отложенная загрузка действительно сложнее, чем вы думали? Дайте мне знать в комментариях.