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

Ленивая загрузка (Lazy Loading) — это подход к загрузке ресурсов, при котором данные, модули, изображения или другие элементы загружаются не во время первоначального открытия страницы, а только в момент фактической необходимости. Главная цель такого механизма заключается в уменьшении объёма первоначально загружаемого кода и ускорении отображения интерфейса.

В современных веб-приложениях значительная часть функциональности может использоваться лишь в отдельных сценариях. Например:

  • административная панель открывается только ограниченным числом пользователей;
  • окно настроек вызывается по нажатию кнопки;
  • модуль аналитики нужен только после авторизации;
  • страницы интернет-магазина содержат десятки изображений, которые пользователь может никогда не увидеть.

Если весь этот код и все ресурсы загружать сразу, увеличивается размер начального пакета, растёт время загрузки страницы и ухудшается пользовательский опыт.

Rollup поддерживает ленивую загрузку через механизм динамического импорта и автоматическое разделение кода на чанки.


Проблема монолитного бандла

Рассмотрим типичный проект:

import './analytics.js';
import './admin.js';
import './settings.js';
import './charts.js';
import './reports.js';

startApplication();

Во время сборки Rollup объединит все зависимости в единый набор файлов.

Даже если пользователь никогда не откроет раздел отчётов, код:

import './reports.js';

всё равно окажется в начальной загрузке.

При большом количестве функциональности подобный подход приводит к следующим последствиям:

  • увеличению размера JavaScript-файлов;
  • росту времени загрузки;
  • замедлению парсинга кода браузером;
  • увеличению потребления памяти.

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


Основа ленивой загрузки — динамический импорт

Стандарт ECMAScript предоставляет специальный синтаксис:

import('./module.js');

В отличие от обычного импорта:

import { fn } from './module.js';

динамический импорт выполняется во время работы приложения.

Результатом является Promise:

import('./module.js')
    .then(module => {
        module.fn();
    });

или более современный вариант:

const module = await import('./module.js');

module.fn();

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


Пример ленивой загрузки панели администратора

Основной файл приложения:

const button = document.querySelector('#admin');

button.addEventListener('click', async () => {
    const admin = await import('./admin.js');

    admin.openPanel();
});

Файл модуля:

export function openPanel() {
    console.log('Admin panel');
}

После сборки структура может выглядеть следующим образом:

dist/
├── main.js
├── admin-8f34ab.js

При открытии страницы браузер загрузит только:

main.js

Файл:

admin-8f34ab.js

будет загружен только после нажатия на кнопку.


Как Rollup обрабатывает динамический импорт

При обнаружении конструкции:

await import('./feature.js');

Rollup выполняет несколько действий:

  1. Анализирует дерево зависимостей.
  2. Выделяет модуль в отдельную точку входа.
  3. Создаёт дополнительный чанк.
  4. Формирует код загрузки этого чанка.
  5. Подставляет корректный путь к файлу.

Исходный код:

await import('./feature.js');

после сборки остаётся динамическим импортом, но уже указывает на сгенерированный файл:

await import('./feature-a12f7b.js');

Браузер самостоятельно выполняет сетевой запрос и загружает новый модуль.


Ленивые маршруты приложения

Один из наиболее распространённых сценариев — загрузка страниц по маршрутам.

Пример простого роутера:

async function navigate(route) {
    switch (route) {
        case '/':
            const home = await import('./pages/home.js');
            home.render();
            break;

        case '/profile':
            const profile = await import('./pages/profile.js');
            profile.render();
            break;

        case '/settings':
            const settings = await import('./pages/settings.js');
            settings.render();
            break;
    }
}

Каждая страница становится отдельным чанком.

Структура результата:

main.js
home.js
profile.js
settings.js

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


Ленивые диалоговые окна

Модальные окна часто используются редко, но содержат значительный объём кода.

Без ленивой загрузки:

import { showModal } from './modal.js';

С ленивой загрузкой:

button.addEventListener('click', async () => {
    const modal = await import('./modal.js');

    modal.showModal();
});

Особенно заметен эффект в случаях, когда модальное окно содержит:

  • графики;
  • визуальные редакторы;
  • сложные формы;
  • библиотеки карт;
  • инструменты экспорта.

Ленивая загрузка тяжёлых библиотек

Некоторые зависимости могут занимать сотни килобайт.

Например:

import Chart from 'chart.js';

или:

import Editor from 'some-editor';

Если функциональность используется редко, разумнее загружать её по требованию.

Пример:

async function loadChart() {
    const chartModule = await import('chart.js');

    return chartModule.default;
}

После сборки Rollup создаст отдельный чанк для библиотеки.

В результате:

  • стартовый бандл уменьшается;
  • ускоряется первый рендер;
  • сокращается объём передаваемых данных.

Ленивая загрузка по пользовательскому действию

Часто модуль требуется только после конкретного события.

Например:

button.addEventListener('click', async () => {
    const exportModule =
        await import('./export.js');

    exportModule.exportToPdf();
});

До нажатия кнопки файл:

export.js

вообще не участвует в работе страницы.


Ленивая загрузка по условию

Динамический импорт может использоваться внутри условий.

if (user.isAdmin) {
    const admin =
        await import('./admin.js');

    admin.start();
}

Загрузка происходит только при выполнении условия.

Это позволяет отделить специализированный функционал от основной части приложения.


Ленивая загрузка по типу устройства

Иногда различные устройства требуют разного набора модулей.

if (window.innerWidth < 768) {
    const mobile =
        await import('./mobile.js');

    mobile.init();
}

Для настольных устройств этот код никогда не будет загружен.


Предварительная загрузка после старта приложения

Не всегда следует ждать момента использования модуля.

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

Пример:

startApplication();

import('./analytics.js');

В этом случае:

  1. Интерфейс появляется быстро.
  2. Аналитика загружается позже.
  3. Пользователь не ждёт окончания загрузки дополнительных модулей.

Подобная стратегия часто называется отложенной загрузкой после первого рендера.


Кэширование лениво загруженных модулей

После первой загрузки модуль сохраняется браузером.

Первый вызов:

await import('./reports.js');

выполняет сетевой запрос.

Повторный вызов:

await import('./reports.js');

использует уже загруженный экземпляр.

Это означает, что загрузка происходит только один раз за жизненный цикл страницы.


Обработка ошибок загрузки

Любой сетевой запрос может завершиться ошибкой.

Поэтому рекомендуется использовать конструкцию:

try {
    const module =
        await import('./reports.js');

    module.show();
}
catch (error) {
    console.error(error);
}

Причины ошибок могут быть различными:

  • потеря соединения;
  • повреждённый файл;
  • удалённый чанк;
  • неправильный путь;
  • проблемы с сервером.

Влияние ленивой загрузки на структуру проекта

После внедрения динамического импорта структура сборки обычно меняется.

Вместо:

main.js

получается:

main.js
chunk-1.js
chunk-2.js
chunk-3.js
chunk-4.js

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

Подобное разделение позволяет браузеру загружать код постепенно.


Баланс между количеством чанков и производительностью

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

Плохой пример:

await import('./button.js');
await import('./input.js');
await import('./icon.js');
await import('./label.js');

Такой подход способен создать множество мелких запросов.

Более рационально объединять связанную функциональность:

await import('./admin-panel.js');

где внутри уже находятся:

button.js
input.js
label.js
icon.js

Это уменьшает сетевые накладные расходы.


Ленивая загрузка и пользовательский опыт

Грамотно организованная загрузка позволяет добиться нескольких важных преимуществ:

  • уменьшения размера стартового бандла;
  • ускорения отображения интерфейса;
  • сокращения времени парсинга JavaScript;
  • уменьшения потребления памяти;
  • более эффективного использования сетевого канала;
  • ускорения работы на мобильных устройствах;
  • возможности масштабировать большие приложения без постоянного роста начального пакета.

Именно поэтому динамический импорт и автоматическое разделение кода стали одной из важнейших возможностей Rollup при разработке современных браузерных приложений.