首页 / 文章

使用 injectAsync 按需拆分大型 Angular 服务代码

为何静态注入的服务会将其依赖的库一并纳入 Angular 主程序包中,以及 injectAsync 是如何根据用户操作意图实现服务的延迟加载,包括条件加载功能。

发布者:REACTAPP.TOP阅读约 6 分钟1484 词

来源: Stop Loading Massive Services: Mastering injectAsync in Angular 22

路由级别的懒加载和@defer块可以将不常使用的页面和组件排除在Angular应用的初始打包范围之外,但服务则有所不同。如果在始终可见的组件中通过inject()注入一个体积较大的服务,那么该服务及其导入的所有库都会被纳入主代码块中。本文将解释为何会出现这种情况,早期的解决方案为何不够理想,以及Angular 22引入的injectAsync API如何让你仅在用户真正需要时才加载服务代码。

请将以下API详情视为临时信息:在撰写本文时injectAsync仍是新功能,因此在实际使用前,请先在最新的Angular文档中确认其确切的签名、稳定性状态及使用规则。

一个常见的打包问题

想象这样一个企业控制面板:运行性能良好,但首次加载速度很慢,因为初始的 JavaScript 数据量已增至 2.4 MB。通过 source-map-explorer 进行分析后发现,问题最大的并非任何 UI 组件,而是一个名为 PdfExportService 的服务,它依赖庞大的客户端渲染库来生成详细的多页财务报告。

有趣的是,只有大约 5% 的用户会点击“导出为 PDF”。但由于该服务被注入到了控制面板的页眉组件中,因此每位用户在首次访问时都会下载约 600 KB 的导出代码。而承担这些成本的,却是那 95% 从未使用过该功能的用户。

Angular 已经为组件和页面提供了 @defer 以及基于路由的代码分割功能。但直到现在,它还缺乏一种在保持服务处于依赖注入(DI)系统内的同时,能够干净地实现懒加载的方法。

为何静态的 inject() 会出现在主代码包中

要了解 injectAsync 带来了哪些变化,首先需要明白 esbuild 和 Vite 等打包工具是如何与 Angular DI 相互作用的。以一个通过常规方式注入报告服务的头部组件为例:

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 并通过它来解析已加载的类,虽然这种方法可行,但会导致依赖注入的相关逻辑散布在组件代码中,且容易出错。(如需回顾解析机制的运作方式,请参阅 Angular 依赖注入如何通过 inject 解析服务。)

injectAsync 的变化

injectAsync 缩小了 ECMAScript 动态导入与 Angular 注入器结构之间的差距。你通过加载函数来定义依赖关系,实际上就是在告诉打包工具:不要将此内容放入主代码块中,仅在有需求时再进行获取并实例化。

依赖注入不再必须是同步的。服务代码的加载可以等到用户表现出相关需求时再进行。

第一步:像往常一样编写该服务

服务本身无需做任何更改。它仍然可以使用 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);
}

高级用户需承担 AI 处理器的网络成本,而其他用户则使用轻量级版本。这是一种运行时多态性,选择不同的处理器还会决定下载的内容,从而将用户加载的数据大小与代码库的总大小分离。按照当前实现,当正在加载或 execute() 方法抛出异常时,isProcessing 的值仍为 true;应像导出示例那样,用 try/finally 块包裹相关代码。为两种处理器提供统一的接口,还能确保 processor 的类型明确,而非隐式地为 any。

何时不应使用此方法

懒加载以牺牲初始代码包大小为代价,换取使用时的延迟。对于大多数用户在每次访问时都会需要的功能,推迟加载只会将等待时间推迟到更糟糕的时刻。而对于那些体积庞大、使用频率低或仅适用于特定层级的功能,懒加载则更为合适。你还可以通过在按钮悬停或获得焦点时再触发加载,从而减轻延迟效果。

关键要点

  • 首先进行审计:使用 source-map-explorer 或类似工具,找出那些仅偶尔被调用或条件性使用的庞大功能模块。
  • 从需要立即加载的组件中移除这些功能的静态导入,因为正是静态导入导致它们被纳入主代码包。
  • 利用 injectAsync 和动态的 import() 在用户有使用意图时再加载这些功能,同时保持完整的依赖注入支持。
  • 显示加载状态并处理错误,因为每个延迟加载的服务现在都需要进行网络请求以获取其对应部分。
  • 采用相同的模式仅下载特定用户被允许运行的实现版本。