javascript

Ленивая загрузка — не лекарство: почему ваш main.js всё равно весит 2 МБ

  • суббота, 25 июля 2026 г. в 00:00:06
https://habr.com/ru/companies/otus/articles/1059536/

Привет, Хабр!

Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу статьи и книги.

При разработке часто бывает так, что мы вроде бы всё делаем, как говорится, «по учебнику», а результат как‑то не очень. Например, мы разбили приложение на ленивые модули, каждый маршрут грузится отдельно, настроили production‑сборку. После этого открываем Chrome DevTools на вкладке Network и видим: main.js — 1.8 МБ. Явно не то, что мы ожидали. И это только начало. Через минуту подгружаются ещё три чанка по 400 КБ. В результате пользователь на 3G ждёт 8 секунд, пока экран хоть что‑то покажет.

Проблема не в том, что вы не используете ленивую загрузку. Проблема в том, что вы лениво загружаете модули, но не загружаете лениво зависимости. Ваш main.js всё ещё толстый, потому что в него попали тяжёлые библиотеки, которые вообще‑то нужны только в одном месте, и потому что вы не видите, что реально попадает в бандл.

Как работает сборка в Angular с Ivy

Прежде чем чинить, нужно понять, как Angular вообще собирает ваше приложение. Начиная с версии 9, компилятор Ivy полностью заменил View Engine. И это не просто смена названия — это совершенно другой подход к генерации кода.

При использовании View Engine компилятор генерировал метаданные для каждого компонента и модуля в виде отдельного ngfactory‑файла. Эти фабрики ссылались друг на друга, создавая жёсткие связи между модулями. Tree‑shaking (выбрасывание неиспользуемого кода) работал плохо. Это происходило из‑за того, что при импорте компонента вы получали все его зависимости, даже те, что не использовались.

С Ivy компилятор генерирует код непосредственно в теле класса компонента через декораторы @Component и @Injectable. Вместо отдельного файла‑фабрики Ivy создаёт функцию ɵcmp (для компонентов) и ɵprov (для провайдеров), которые встраиваются прямо в JavaScript‑класс. Это делает связи между модулями более гибкими и позволяет компилятору гораздо агрессивнее выкидывать неиспользуемый код.

Но самое важное: Ivy компилирует каждый компонент локально. Это значит, что компилятор не обязан тащить весь модуль целиком, если вы используете только один его компонент. И это открывает возможности для оптимизации, о которых мы ещё поговорим.

Ленивая загрузка: что она реально делает

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

Вот как вы лениво загружаете модуль:

// app.routes.ts

const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
  },
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
  }
];

Эта конструкция — import() с динамическим импортом — говорит сборщику (Webpack или Vite): «Этот код не нужен при старте, сделай для него отдельный чанк». Когда пользователь переходит по /admin, браузер скачивает этот чанк, и только тогда выполняется код AdminModule.

Но здесь кроется первая ловушка: динамический импорт работает на уровне модулей, а не на уровне зависимостей внутри модуля. Если ваш AdminModule импортирует тяжелую библиотеку для работы с графиками, эта библиотека попадёт в чанк админки, а не в main.js. Это хорошо — админка станет тяжелее, но пользователи, которым админка не нужна, её и не увидят.

А теперь представьте: эта библиотека для графиков нужна не только в админке, но и на главной странице, и в личном кабинете, и в отчётах. Вы импортируете её в SharedModule, и она попадает в main.js, потому что SharedModule загружается сразу. И ваш main.js снова толстый.

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

Когда подводит Tree‑shaking

Tree‑shaking — это процесс удаления мёртвого кода. Сборщик анализирует ваш код, смотрит, какие функции и классы экспортируются, но нигде не вызываются, и выбрасывает их из финального бандла.

В теории это звучит идеально, но на практике — tree‑shaking работает только с ES‑модулями. (синтаксис import/export) и только если ваш код написан так, чтобы анализатор мог понять, что код не используется.

В качестве примера давайте рассмотрим антипаттерн класса монстра. Это классическая ошибка, которая убивает tree‑shaking: 

// utils.ts

export class Utils {
  static formatDate(date: Date): string { /*... */ }
  static validateEmail(email: string): boolean { /*... */ }
  static transformData(data: any): any { /*... */ }
  static heavyComputation(data: any): any { /*... */ }
  // ещё 20 методов
}

// component.ts
import { Utils } from './utils';

const formatted = Utils.formatDate(new Date());

Здесь мы использовали только formatDate, но весь класс Utils попадёт в бандл, потому что класс — это единая сущность. Сборщик не может выбросить отдельные методы класса, если класс импортирован целиком.

Соответственно, более правильным будет следующий подход:

// utils.ts
export function formatDate(date: Date): string { /*... */ }
export function validateEmail(email: string): boolean { /*... */ }
export function transformData(data: any): any { /*... */ }
export function heavyComputation(data: any): any { /*... */ }

// component.ts
import { formatDate } from './utils';

const formatted = formatDate(new Date());

Теперь сборщик видит, что импортирована только formatDate, и остальные функции не попадут в бандл. Это называется экспорт отдельных функций вместо классов.

Побочные эффекты

Tree‑shaking останавливается, когда видит код с побочными эффектами (side effects) — вызовы функций, которые что‑то меняют за пределами своей области.

// config.ts
console.log('Config module loaded!'); // Побочный эффект

export const API_URL = 'https://api.example.com';

Даже если вы не импортируете API_URL, файл останется в бандле из‑за console.log. Сборщик не может гарантировать, что лог не нужен для работы приложения.

Здесь на помощь приходит пометка /*#__PURE__*/:

/*#__PURE__*/ console.log('Config module loaded!');

Эта директива говорит сборщику: «У этого вызова нет побочных эффектов, его можно удалить». Но проще не писать такой код в модулях, которые могут быть выкинуты.

Баррельные файлы (index.ts)

Многие разработчики используют в своих проектах файлы index.ts для удобного импорта. Например, что‑то подобное:

// utils/index.ts
export * from './format-date';
export * from './validate-email';
export * from './transform-data';
export * from './heavy-computation';

// component.ts
import { formatDate } from './utils';

Однако, здесь кроется еще одна проблема, которая заключается в том, что сборщик может не понять, что из файла index.ts экспортируется только formatDate, и может затащить весь модуль целиком. В Angular это особенно критично, потому что index.ts создаёт дополнительные зависимости.

Здесь в качестве решения можно предложить импортировать напрямую из конкретных файлов:

import { formatDate } from './utils/format-date';

Как найти виновника

Очевидно, что мы не можем починить то, чего не видим. Поэтому, первым шагом к оптимизации будет честный анализ того, что на самом деле попадает в ваш бандл. Angular CLI генерирует статистику сборки, которую можно визуализировать с помощью webpack‑bundle‑analyzer.

  • Для начала установите анализатор:

npm install --save-dev webpack-bundle-analyzer

  • Соберите проект с генерацией JSON‑статистики:

ng build --configuration production --stats-json

  • Запустите анализатор:

npx webpack-bundle-analyzer dist/your-project-name/stats.json

В браузере откроется интерактивный график, где каждый блок — это модуль или библиотека. Размер блока пропорционален его вкладу в общий бандл.

Теперь вы видите всё. Возможно, окажется, что @angular/material занимает 400 КБ, хотя вы используете только кнопки и поля ввода. Или что moment.js занимает 200 КБ, хотя вам нужен только один метод форматирования дат.

Оптимизация импорта из Angular Material

Angular Material — одна из самых частых причин раздувания бандла. И в этом нет ничего удивительного — Material включает десятки компонентов, каждый со своим CSS, логикой и зависимостями.

Здесь классическая ошибка — создать один общий модуль для Material и импортировать его везде. Например, вот так:

// material.module.ts
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatTableModule } from '@angular/material/table';
//... ещё 20 импортов

@NgModule({
  exports: [
    MatButtonModule,
    MatInputModule,
    MatSelectModule,
    MatDatepickerModule,
    MatTableModule,
    //... все 20 модулей
  ]
})

export class MaterialModule { }

// app.module.ts
import { MaterialModule } from './material.module';

@NgModule({
  imports: [MaterialModule,...],
  //...
})

export class AppModule { }

Теперь каждый компонент Material, который вы когда‑либо экспортировали из MaterialModule, попадёт в ваш бандл. Даже если вы используете только кнопки и поля ввода, компилятор не может выкинуть MatTableModule или MatDatepickerModule, потому что они явно перечислены в exports и считаются используемыми.

Здесь правильным подходом является импорт только тех модулей Material, которые реально используются. И это необходимо делать только в том модуле/компоненте, где они нужны.

// feature.module.ts (ленивый модуль)
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';

@NgModule({
  imports: [MatButtonModule, MatInputModule],
  //...
})
export class FeatureModule { }

Если один и тот же набор Material‑модулей используется в нескольких ленивых модулях — создайте небольшой Material‑модуль только с необходимыми импортами и импортируйте его в те модули, где он нужен. Но не в SharedModule, если его используют во всём приложении.

Lightweight Injection Tokens: фича, о которой вы не знали

В Angular Material есть скрытая оптимизация, о которой мало кто знает. Если вы используете только часть компонентов Material, некоторые зависимости не должны подтягивать весь модуль.

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

С версии Ivy в Material появились легковесные токены — специальные DI‑токены, которые позволяют внедрять зависимости без необходимости иметь сам компонент в бандле:

// Внутри Angular Material (упрощённо)

export const MATRADIO_GROUP = new InjectionToken<MatRadioGroup>('MAT_RADIO_GROUP', {
  providedIn: 'root',
  factory: () => null
});

// Компонент не ссылается на MatRadioGroup напрямую,
// а использует легковесный токен

Этот подход применяется для MatMenu и MatMenuContent, для MatFormField и его частей (MatError, MatHint, MatSuffix), для MatChip и его опциональных частей.

Что это значит для нашего проекта? Мы не должны ничего делать — Angular Material уже оптимизирован. Но если вы пишете свои собственные библиотеки компонентов с опциональными частями, используйте InjectionToken для слабых связей. Это позволит вашим пользователям не тащить лишний код.

Пошаговый план оптимизации

Вот алгоритм, который можно использовать для оптимизации Angular‑приложений.

  1. Для начала запустите анализ бандла через webpack‑bundle‑analyzer.

  2. Найдите самые большие блоки. Обычно это Material, тяжелые библиотеки (chart.js, moment, lodash) и ваш собственный код.

  3. Затем проверьте импорты в app.module.ts и в общих модулях.

  4. Перенесите всё, что не нужно при старте, в ленивые модули.

  5. Замените тяжелые библиотеки на лёгкие альтернативы или используйте динамический импорт для них.

  6. Также перепишите экспорты. с классов на отдельные функции, чтобы включить tree‑shaking.

  7. И настройте angular.json включив buildOptimizer и отключив vendorChunk при необходимости.

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

Подводим итоги

Ленивая загрузка — это мощный инструмент, но она не является панацеей. Основной принцип оптимизации звучит так: не импортируйте то, что не используете, и не загружайте то, что не нужно сейчас.

Не забывайте проверять свои бандлы. Tree‑shaking работает только в том случае, если вы пишете код, который он может проанализировать. Angular Material уже достаточно оптимизирован, но только тогда, когда вы импортируете только то, что используете. Тяжёлые библиотеки можно загружать динамически.

И самое главное — измеряйте загрузку. Без использования webpack‑bundle‑analyzer вы просто гадаете. С ним — точно знаете, что какие ресурсы потребляет. Это единственный способ превратить «мой бандл кажется слишком большим» в «мой бандл весит 400 КБ, и я знаю, из чего он состоит».

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

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

  • 29 июля, 20:00. «Собери себя сам: пишем трекер привычек на чистом JavaScript». Записаться

  • 5 августа, 20:00. «Влияние нефункциональных требований на архитектуру». Записаться

  • 13 августа, 20:00. «Минимум для старта: как провести своё первое нагрузочное тестирование». Записаться

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