iT邦幫忙

2026 iThome 鐵人賽

DAY 14
1
Modern Web

Angular 22 Signal 進化論系列 第 14 篇

Day 14:Lazy Loading 不只一種,從 Router Preloading、@defer 到 injectAsync()

  • 分享至 

  • xImage
  •  

當 Client Side Rendering 的應用越來越大時,我們通常會開始思考,能不能把原本一次載入的內容拆得更小,等到真正需要時再下載,避免初始載入過多 JavaScript,拉長畫面呈現的時間。

但如果所有內容都等到使用者實際操作後才下載,切換頁面或開啟功能時,又可能多出一段等待資源載入的時間。

除了延後載入之外,也可以在適合的時機先把之後可能會用到的內容下載下來。例如趁瀏覽器處於 Idle 時進行 Prefetch,在控制初始載入量的同時,也減少真正使用時等待下載的時間。

在 Angular 中,這類做法可以出現在不同層級,從 Route、畫面中的元件區塊,到 Angular 22 開始支援的 Service Lazy Loading,都有各自對應的處理方式。

Angular Lazy Loading 的三個層級

路由層級的 Lazy Loading 與 Preloading

路由是 Angular 中很常見的程式碼切分方式。

例如管理後台只有使用者進入 /admin 時才會用到,就可以透過 loadComponent 或 loadChildren 把這部分拆成獨立的 JavaScript chunk:

import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'admin',
    loadComponent: () =>
      import('./admin/admin.component')
        .then(m => m.AdminComponent),
  },
];

這樣 AdminComponent 不需要放進初始 Bundle,而是在第一次進入 /admin 時才下載。

不過第一次進入 /admin 時,仍然需要等待對應的 chunk 載入。如果希望 Route 維持 Lazy Loading,又想在實際 Navigation 之前先下載程式碼,就可以搭配 Router 的 Preloading Strategy。

Angular 預設使用 NoPreloading,Lazy Route 只有在真正導航時才會載入。

如果改成 PreloadAllModules,Router 會在初始 Navigation 完成後開始預先載入所有 Lazy Routes。

import { ApplicationConfig } from '@angular/core';
import {
  PreloadAllModules,
  provideRouter,
  withPreloading,
} from '@angular/router';

import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      withPreloading(PreloadAllModules)
    ),
  ],
};

Preloading 不代表 Route 會提前被啟用。真正進入該頁面仍然要等到 Navigation 發生,只是對應的 JavaScript 已經先下載完成。

Route:延後載入,也能提前下載

這部分可以參考 StackBlitz 範例

等瀏覽器 Idle 再 Preload

PreloadAllModules 會在初始 Navigation 完成後開始載入所有 Lazy Routes。

不過 Navigation 完成不代表圖片、API Request 或其他資源都已經載入完成,因此這些 Lazy Route 的下載仍可能和其他網路請求同時進行。

如果希望把預載時機延後到瀏覽器比較空閒時,可以自己實作 PreloadingStrategy。

Angular 22 提供了 onIdle(),可以等待瀏覽器進入 Idle。我們可以利用這個時機控制 Router 的 load(),延後 Lazy Route 開始預載的時間。

onIdle() 需要在 Angular 的 Injection Context 中執行,因此這裡透過 runInInjectionContext() 搭配 Injector 呼叫:

import {
  inject,
  Injectable,
  Injector,
  onIdle,
  runInInjectionContext,
} from '@angular/core';
import {
  PreloadingStrategy,
  Route,
} from '@angular/router';
import {
  from,
  Observable,
  switchMap,
} from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class IdlePreloadingStrategy
  implements PreloadingStrategy
{
  private readonly injector = inject(Injector);

  preload(
    route: Route,
    load: () => Observable<unknown>
  ): Observable<unknown> {
    const idle = runInInjectionContext(
      this.injector,
      () => onIdle()
    );

    return from(idle).pipe(
      switchMap(() => load())
    );
  }
}

load() 是 Router 提供的 Lazy Route 載入函式,這裡等 onIdle() 完成後才會執行,開始下載對應的 Lazy Route。

接著把自訂的 Strategy 交給 Router:

import {
  provideRouter,
  withPreloading,
} from '@angular/router';

export const appConfig = {
  providers: [
    provideRouter(
      routes,
      withPreloading(IdlePreloadingStrategy)
    ),
  ],
};

onIdle() 預設會透過 Angular 的 IdleService 使用 requestIdleCallback(),瀏覽器不支援時則退回 setTimeout()。

如果不希望一直等待 Idle,也可以設定 timeout:

const idle = runInInjectionContext(
  this.injector,
  () => onIdle({
    timeout: 1000,
  })
);

timeout 代表最多等待多久。如果這段時間內都沒有適合的 Idle 時機,超過設定時間後仍會繼續執行,避免 Prefetch 一直被延後。

onIdle() 原本是 Angular 22 提供給 injectAsync() 使用的 PrefetchTrigger,Router 本身並沒有內建 Idle Preloading Strategy。不過它提供了等待瀏覽器 Idle 的時機,因此也能拿來控制 Router 何時執行 load()。

這部分可以參考 StackBlitz 範例

使用者靠近連結時先 Preload

除了等待 Browser Idle,也可以把使用者操作納入預載時機。

例如:

<a routerLink="/admin">
  管理後台
</a>

平常可以等到 Browser Idle 再 Preload;如果使用者已經把游標移到 /admin 的連結上,就可以把這個操作當成預載訊號,提前載入對應的 Route。

先建立一個可以接收優先載入 Route 的 Strategy:

import {
  inject,
  Injectable,
  Injector,
  onIdle,
  runInInjectionContext,
} from '@angular/core';
import {
  PreloadingStrategy,
  Route,
} from '@angular/router';
import {
  filter,
  from,
  map,
  Observable,
  race,
  Subject,
  switchMap,
  take,
} from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class SmartPreloadingStrategy
  implements PreloadingStrategy
{
  private readonly injector = inject(Injector);

  private readonly priorityRoute$ =
    new Subject<string>();

  prioritize(path: string) {
    this.priorityRoute$.next(path);
  }

  preload(
    route: Route,
    load: () => Observable<unknown>
  ): Observable<unknown> {
    if (!route.path) {
      return from([]);
    }

    const idle$ = from(
      runInInjectionContext(
        this.injector,
        () => onIdle()
      )
    );

    const priority$ =
      this.priorityRoute$.pipe(
        filter(path => path === route.path),
        take(1),
        map(() => void 0)
      );

    return race(
      idle$,
      priority$
    ).pipe(
      take(1),
      switchMap(() => load())
    );
  }
}

priorityRoute$ 用來接收需要優先載入的 Route,而 prioritize() 提供外部通知 Strategy 的入口。

每個 Lazy Route 在 preload() 中都會等待 idle$ 或 priority$。前者維持原本的 Idle Preloading,後者則只接收和目前 route.path 相同的通知。

race() 會採用先發生的那一個。如果 Browser 先進入 Idle,就依照原本的方式 Preload;如果某個 Route 先被標記為 Priority,就直接執行 load()。

接著建立一個 Directive,在 pointerenter 時通知 Strategy:

import {
  Directive,
  inject,
  input,
} from '@angular/core';

@Directive({
  selector: '[preloadRoute]',
  host: {
    '(pointerenter)': 'preload()',
  },
})
export class PreloadRouteDirective {
  private readonly preloader =
    inject(SmartPreloadingStrategy);

  readonly preloadRoute =
    input.required<string>();

  preload() {
    this.preloader.prioritize(
      this.preloadRoute()
    );
  }
}

使用時只要把對應的 Route path 傳進 Directive:

<a
  routerLink="/admin"
  preloadRoute="admin"
>
  管理後台
</a>

如果使用者沒有碰到這個連結,/admin 就維持原本的 Idle Preloading;如果先觸發 pointerenter,則透過 prioritize('admin') 讓這個 Route 提前載入。

每個 Lazy Route 都各自等待 Idle 或 Priority,因此 /admin 提前載入後,其他尚未載入的 Route 仍然會等待 Browser Idle。

一旦某個 Route 已經開始 Preload,後續再觸發 pointerenter 也不會讓同一個 Route 重複載入。

Smart Preloading:Idle 或使用者意圖

元件層級的 Lazy Loading 與 Prefetch

有些內容和目前頁面屬於同一個 Route,但不一定需要一開始就載入。

例如報表、留言區、地圖或大型圖表,可能只有使用者捲動到特定位置或實際操作後才會看到。

這類情境可以使用 @defer:

@defer (on interaction) {
  <app-heavy-chart />
} @placeholder {
  <button>查看圖表</button>
}

放在 @defer 中符合條件的 Component、Directive 與 Pipe,可以被拆成獨立的 JavaScript chunk,等 Trigger 成立後再載入。

@defer 提供多種 Trigger:

  • idle:瀏覽器進入 Idle 時載入,也是 @defer 的預設 Trigger。
  • viewport:指定內容進入 Viewport 時載入。
  • interaction:使用者和指定元素互動時載入,例如 Click 或 Keydown。
  • hover:使用者 Hover 到指定元素時載入。
  • immediate:立即開始載入,不等待其他 Trigger。
  • timer:等待指定時間後載入。

除了控制主要內容的載入時機,也可以另外指定 Prefetch Trigger。

例如希望使用者互動後才顯示圖表,但 Browser Idle 時就先下載 JavaScript:

@defer (
  on interaction;
  prefetch on idle
) {
  <app-heavy-chart />
} @placeholder {
  <button>查看圖表</button>
}

這裡的 prefetch on idle 只負責提前下載 JavaScript,畫面仍然維持 Placeholder;等到 interaction 成立後,才真正顯示 @defer 中的內容。

@defer:下載時機與顯示時機分開

如果單純寫:

@defer {
  <app-heavy-chart />
}

預設 Trigger 就是 idle,會在 Browser Idle 後載入內容。

因此,@defer 除了能把畫面中的部分內容拆成 Lazy Chunk,也可以分開控制下載與顯示內容的時機。

Service 層級的 Lazy Loading 與 Prefetch

Route 和畫面區塊都能拆分之後,Angular 22 把相同的概念延伸到了 Dependency Injection。

如果某個 Service 依賴大型 Library,但只有少數功能會使用到,就可以透過 injectAsync() 延後載入。

例如有一個負責匯出 Excel 的 Service:

import {
  Component,
  injectAsync,
} from '@angular/core';

@Component({
  selector: 'app-report',
  template: `
    <button (click)="export()">
      匯出報表
    </button>
  `,
})
export class ReportComponent {
  private readonly exporter = injectAsync(
    () =>
      import('./report-exporter.service')
        .then(m => m.ReportExporterService)
  );

  async export() {
    const exporter = await this.exporter();

    exporter.export();
  }
}

ReportExporterService 不會因為 Component 建立就立即下載。

第一次呼叫:

await this.exporter();

才會載入對應的 chunk,再透過 Angular DI 取得 Service instance。後續再次呼叫時,則會沿用同一次載入結果。

要讓 Service 可以透過 injectAsync() Lazy Load,本身需要使用 providedIn: 'root' 或 @Service() 自動提供。

如果不希望等到使用者按下「匯出」才開始下載,可以搭配前面介紹的 onIdle():

import {
  injectAsync,
  onIdle,
} from '@angular/core';

private readonly exporter = injectAsync(
  () =>
    import('./report-exporter.service')
      .then(m => m.ReportExporterService),
  {
    prefetch: onIdle,
  }
);

injectAsync() 的 prefetch 接受 PrefetchTrigger。當 Trigger 完成後,Angular 就會提前執行 loader。

這裡直接把 onIdle 當成 PrefetchTrigger,先下載 Dependency;真正呼叫 this.exporter() 時,再透過 Angular DI 取得 Service instance。

injectAsync():Service 也能延後載入

除了 onIdle(),也可以自己建立 PrefetchTrigger。

例如希望使用者 Hover 到「匯出報表」附近時才開始下載:

import {
  PrefetchTrigger,
} from '@angular/core';

function onHover(
  target: HTMLElement
): PrefetchTrigger {
  return () =>
    new Promise<void>(resolve => {
      target.addEventListener(
        'pointerenter',
        () => resolve(),
        {
          once: true,
        }
      );
    });
}

PrefetchTrigger 本質上是一個回傳 Promise<void> 的 Function。上面的 onHover() 會等待指定元素第一次觸發 pointerenter,事件發生後 Promise resolve,Angular 就會開始 Prefetch Dependency。

因此,injectAsync() 的 Prefetch 時機不只限於 Browser Idle,也可以依照實際操作自行定義。

本日結語

從 Route、@defer 到 injectAsync(),Angular 處理的層級雖然不同,但都牽涉到同一個問題:程式碼拆出去之後,要在什麼時候下載回來。

可以從三種時機來看:

  • 應用程式的節奏:例如初始 Navigation 完成、Browser Idle,適合之後很可能會使用的內容。
  • 使用者的操作:例如 Hover、pointerenter、進入 Viewport,可以在使用者開始表現出使用意圖時提前下載。
  • 真正使用的當下:例如 Navigation、Interaction、第一次呼叫 this.exporter(),如果先前還沒有 Prefetch,就在這個時間點載入。

越確定之後會用到的內容,可以越早準備;使用機率較低的功能,則可以等到更接近實際操作時再下載。

Prefetch 也不是越早越好。如果所有 Lazy Chunk 都在一開始送出下載,只是把原本的初始載入移到背景執行,在網路較慢或需要計算流量的環境下,沒有使用到的程式碼仍然會產生成本。

無論是 PreloadingStrategy、@defer 的 Trigger,還是 injectAsync() 的 PrefetchTrigger,真正需要決定的都是載入時機。掌握這個差異後,就能依照功能的使用頻率與使用者操作,選擇適合的方式。

資料來源


上一篇
Day 13:從 Router Event 到 Signal
下一篇
Day 15:搞懂 Injection Context,從 inject()、runInInjectionContext() 到 injectAsync()
系列文
Angular 22 Signal 進化論 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言