Главная / Статьи

Разделение кода тяжелых сервисов Angular по требованию с использованием injectAsync

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

Издатель: REACTAPP.TOP6 мин чтения1484 слов

Источники: Stop Loading Massive Services: Mastering injectAsync in Angular 22

Режим отложенной загрузки на уровне маршрутов и блоки @defer позволяют исключать редко используемые страницы и компоненты из начального пакета приложения Angular, но с сервисами всё иначе. Одна лишь функция inject() для тяжелого сервиса в компоненте, который всегда виден, приводит к тому, что этот сервис вместе со всеми импортируемыми им библиотеками попадает в основной блок кода. В этой статье объясняется, почему это происходит, почему старые решения были неудобны, и как API injectAsync, введенное в Angular 22, позволяет загружать код сервиса только тогда, когда пользователь действительно в нем нуждается.

Рассматривайте приведенные ниже детали API как временную информацию: на момент написания статьи injectAsync является новым инструментом, поэтому перед его использованием убедитесь в его точной сигнатуре, статусе стабильности и правилах применения в актуальной документации Angular.

Знакомая проблема с пакетами

Представьте панель управления предприятием с хорошей производительностью во время работы, но с медленной загрузкой при первом открытии, где размер начального JavaScript-кода достиг 2,4 МБ. Анализ с помощью инструмента source-map-explorer показывает, что основной проблемой совсем не компонент интерфейса. Это класс PdfExportService, который использует тяжелые библиотеки для отрисовки на стороне клиента с целью создания подробных многостраницных финансовых отчетов.

Интересно то, что лишь около 5% пользователей когда-либо используют опцию «Экспортировать в PDF». Однако поскольку этот сервис встраивается в компонент заголовка панели управления, каждый пользователь скачивает примерно 600 КБ кода для экспорта при каждом первом визите. Цену за это платят 95% пользователей, которые никогда не используют эту функцию.

Angular уже предоставляет @defer и разделение кода на основе маршрутов для компонентов и страниц. Однако до сих пор не было простого способа отложенной загрузки сервиса при одновременном его сохранении в системе внедрения зависимостей (DI).

Почему статический метод inject() попадает в основной пакет

Чтобы понять, что меняет injectAsync, полезно знать, как инструменты сборки кода, такие как esbuild и Vite, взаимодействуют с системой DI Angular. Рассмотрим компонент заголовка, который встраивает сервис отчетов обычным способом:

import { Component, inject } from '@angular/core';
import { HeavyReportService } from './heavy-report.service';

@Component({ ... })
export class DashboardHeaderComponent {
  // The Static Analysis Trap
  private reportService = inject(HeavyReportService);

  exportData() {
    this.reportService.generate();
  }
}

Важна именно инструкция import высшего уровня. Инструмент сборки анализирует импорты статически, видит, что DashboardHeaderComponent зависит от HeavyReportService, и помещает этот сервис вместе со всеми библиотеками сторонних разработчиков, которые он импортирует, в тот же фрагмент, что и компонент. Если компонент является частью начального интерфейса, то библиотека PDF также попадает туда же. Сам вызов inject() не является проблемой; проблема в статическом импорте, который это возможно делает.

Старый способ решения проблемы и его недостатки

Опытные разработчики уже давно избегают этой проблемы, используя динамический await import(...) внутри обработчика кликов, чтобы инструмент сборки отправлял сервис в отдельный фрагмент.

Проблема заключается в том, что динамически импортируемый класс — это просто класс. Если вы создаете его сами, он находится вне инжектора Angular, поэтому такие зависимости, как HttpClient для загрузки шаблона или сервис аутентификации для добавления токена, не предоставляются. Чтобы получить корректно настроенный экземпляр, необходимо заранее ввести Injector и через него разрешить загруженный класс, что работает, но приводит к распространению механизмов DI по коду компонентов и увеличивает вероятность ошибок. (Чтобы вспомнить, как происходит сам процесс разрешения, ознакомьтесь с темой о том, как Angular решает проблему зависимостей с помощью инжекции.)

Что меняет injectAsync

injectAsync устраняет разрыв между динамическим импортом в ECMAScript и структурой инжектора в Angular. Вы описываете зависимость с помощью функции загрузчика, тем самым сообщая бандлеру: не включайте её в основной чанк, а загружайте и инстанцируйте только по запросу.

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

Шаг 1: напишите тяжёлый сервис как обычно

В самом сервисе ничего менять не нужно. Он может по-прежнему использовать inject() и любые другие компоненты экосистемы Angular. Здесь он инжектирует HttpClient и импортирует pdfMake — крупную зависимость, которую следует исключить из первоначальной загрузки.

// heavy-report.service.ts (This will become its own separate JS chunk)
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as pdfMake from 'pdfmake/build/pdfmake'; // Massive dependency

@Injectable({ providedIn: 'root' })
export class HeavyReportService {
  private http = inject(HttpClient);

  async generate(data: any) {
    // Complex, heavy PDF generation logic
  }
}

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

Шаг 2: разрешение проблемы с сервисом на границе взаимодействия

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

// The Modern Code-Split Architecture
import { Component, injectAsync, signal } from '@angular/core';

@Component({
  selector: 'app-dashboard-header',
  template: `
    <button (click)="onExport()" [disabled]="isExporting()">
      {{ isExporting() ? 'Loading Exporter...' : 'Export PDF' }}
    </button>
  `
})

export class DashboardHeaderComponent {
  isExporting = signal(false);

  async onExport() {
    this.isExporting.set(true);
    try {

      // 1. The Magic: The browser downloads the chunk over the network right now.
      // Angular instantiates it and wires up all its DI dependencies (like HttpClient).
      const reportService = await injectAsync(
        () => import('./heavy-report.service').then(m => m.HeavyReportService)
      );

      // 2. Execute the heavy logic
      await reportService.generate({ id: 123 });

    } catch (error) {
      console.error('Failed to load or execute the report service', error);
    } finally {
      this.isExporting.set(false);
    }
  }
}

Когда запускается обработчик, браузер загружает часть сервиса, Angular создает экземпляр и предоставляет его зависимости, такие как HttpClient, после чего компонент вызывает метод generate(). Здесь важны блоки try/catch/finally: сбой сети во время загрузки части теперь может стать ошибкой во время выполнения, а блок finally гарантирует повторное включение кнопки в любом случае.

Один момент, который необходимо проверить в документации: как и функция inject(), функции инъекции обычно должны вызываться в контексте инъекции, например в конструкторе или инициализаторе поля, а не в произвольном обработчике событий. Если ваша версия Angular требует этого и для injectAsync, вызывайте её в инициализаторе поля для получения загрузчика, а затем используйте этот загрузчик из обработчика клика.

После создания версии для производства в результате формируется небольшой файл main.js и отдельный фрагмент heavy-report.service-[hash].js. Пользователи, которые никогда не используют функцию экспорта, вообще не загружают библиотеку PDF.

Условная загрузка: отправлять только тот код, который может использовать пользователь

Разделение большой библиотеки на части — это очевидное применение этого метода, но тот же механизм позволяет реализовать условную инъекцию зависимостей. Предположим, что бесплатные аккаунты используют StandardDataProcessor, в то время как премиум-корпоративные аккаунты получают AIProDataProcessor.

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

async processData(payload: any) {
  this.isProcessing.set(true);

  let processor;

  if (this.currentUser.tier === 'PRO') {
    // Only Pro users pay the network cost for the AI chunk
    processor = await injectAsync(
      () => import('./ai-pro-processor.service').then(m => m.AiProProcessorService)
    );
  } else {
    // Free users download the lightweight chunk
    processor = await injectAsync(
      () => import('./standard-processor.service').then(m => m.StandardProcessorService)
    );
  }

  await processor.execute(payload);
  this.isProcessing.set(false);
}

Профессиональные пользователи оплачивают расходы сети, связанные с использованием процессора ИИ, в то время как все остальные получают более легкий вариант. Это пример полиморфизма во время выполнения, при котором выбор также определяет то, что загружается, тем самым отделяя размер данных, загружаемых пользователем, от общего размера кодовой базы. В текущей реализации переменная isProcessing остается равной true, пока происходит загрузка, или если метод execute() выбрасывает исключение; необходимо обернуть логику выполнения в блоки try/finally, как показано в примере экспорта. Наличие общего интерфейса у обоих процессоров также позволяет сохранять правильную типизацию переменной processor, вместо того чтобы она имела неявный тип any.

Когда не стоит его использовать

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

Основные выводы

  • Сначала проведите аудит: используйте source-map-explorer или подобные инструменты, чтобы найти тяжелые сервисы, которые используются редко или условно.
  • Удалите статические вставки этих сервисов из компонентов, загружаемых сразу, поскольку именно статический импорт включает их в основной пакет.
  • Загружайте их в момент, когда пользователь собирается ими воспользоваться, с помощью injectAsync и динамического import(), сохраняя полную поддержку инжекции зависимостей.
  • Отображайте состояние загрузки и обрабатывайте ошибки, поскольку теперь каждый отложенный сервис включает сетевой запрос для своего фрагмента.
  • Используйте тот же подход для загрузки только той реализации, которую данному пользователю разрешено использовать.