Angular 22のinjectAsyncで遅延させてよい依存の線引き

PDF の生成、表計算の書き出し、地図の描画。こうした処理を持つサービスは、画面を開いただけでは使われないのに、inject() で受けると初回の読み込みに含まれる。22 で入った injectAsync は、この依存を別のチャンクへ送り出す。

操作されたときだけ読み込む
export class ReportPage {
  private readonly exporter = injectAsync(() => import('./report-exporter'));

  async onExport(): Promise<void> {
    (await this.exporter()).export(this.rows());
  }
}

戻るのは () => Promise<T> である。値を取り出す口が Promise しかないので、遅延させてよいかどうかは「待てるか」だけでは決まらない。@angular/core 22.2.1 の実装を読むと、失敗の扱いと prefetch の起動条件に、置く場所を選ぶ理由がある。

読み込みを1回だけ覚える

実体は20行に収まる。

22.2.1 の injectAsync
function injectAsync(loader, options) {
  if (ngDevMode) {
    assertInInjectionContext(injectAsync);
  }
  const injector = inject(Injector);
  let loadedPromise = null;
  const load = () => {
    if (!loadedPromise) {
      loadedPromise = loader();
    }
    return loadedPromise;
  };
  if (options?.prefetch) {
    options.prefetch().then(() => load()).catch(() => {});
  }
  return () => load().then(loadedToken => injector.get(maybeUnwrapDefaultExport(loadedToken)));
}

覚えているのは loader() が返した Promise そのものである。2度目の呼び出しでは loadedPromise が真なので loader() は呼ばれない。動的 import() は何度呼んでもモジュールレジストリーが同じものを返すので、ここで覚える意味は呼び出しの節約にとどまる。

injector.get が走るのは読み込みが終わってからである。渡した token が自動で登録される側になっていないと、ここで初めて失敗する。この条件はAngular 22の@Service()とprovidedIn: 'root’の混在をどう整理するかで扱った。

maybeUnwrapDefaultExport が中身を取り出すのは、default を持つオブジェクトだけである。

22.2.1 の maybeUnwrapDefaultExport
function maybeUnwrapDefaultExport(input) {
  return isWrappedDefaultExport(input) ? input['default'] : input;
}
function isWrappedDefaultExport(value) {
  return value && typeof value === 'object' && 'default' in value;
}

import('./report-exporter') が名前付きの export しか持たない場合、解決した値はモジュールの名前空間オブジェクトである。default が無いため値はそのまま injector.get へ渡り、登録が見つからない。型は2つのオーバーロードで分かれているので、default export を持たないモジュールを素で渡した差分はビルドで止まる。止まらないのは as で型を合わせた箇所である。

読み込みに失敗したあとの呼び直し

load の判定は if (!loadedPromise) である。拒否された Promise も値としては真なので、以後の呼び出しはすべて同じ拒否を返す。

チャンクの取得が失敗する契機は実際にある。デプロイで古いチャンクが消えたあと、まだ開いたままのタブから操作したときがそれである。このとき利用者の画面では、1回目の操作でエラーが出て、2回目以降も同じエラーが即座に出る。通信は起きていないので、ネットワークのログには1回しか残らない。

再試行にならない
async onExport(): Promise<void> {
  try {
    (await this.exporter()).export(this.rows());
  } catch {
    this.error.set('出力に失敗しました。もう一度お試しください。');
  }
}

案内している「もう一度」で直ることはない。injectAsync が返した関数を呼び直しても、覚えられた拒否が返るだけである。読み直す手段は、別の injectAsync を作ることか、ページを読み込み直すことになる。

したがって再試行を用意するなら、失敗の案内は再読み込みを促すものにする。あるいは injectAsync を使わず、loader を自分で書いて拒否のときに覚えないようにする。

Comment
@Reviewer: `injectAsync` の失敗を捕まえて「もう一度お試しください」と出していますが、`injectAsync` は拒否された Promise も覚えるため、呼び直しても通信は起きず同じ拒否が返ります。デプロイ直後の古いタブで起きる経路なので、案内はページの再読み込みを促す文にしてください。

prefetch の失敗が残すもの

prefetch を渡したときの行は1行である。

22.2.1 の該当部
if (options?.prefetch) {
  options.prefetch().then(() => load()).catch(() => {});
}

catch が付いているのは load() まで含んだ鎖に対してである。prefetch で始めた読み込みが失敗しても、例外にもならず ErrorHandler にも渡らない。

残るのは前節の loadedPromise である。prefetch で失敗した時点で拒否が覚えられるので、利用者が最初に操作した時点では、通信を試さずに起動時の失敗が返る。prefetch を付けていなければその操作で読み込みが始まり、通ったはずの経路である。prefetch は読み込みを早める設定であって、失敗の確率を下げる設定ではない。

起動直後は、ほかのリクエストと帯域を分け合っているうえ、回線が切り替わる途中であることもある。prefetch を付ける判断には、この時点の失敗が操作時まで残ることを含める。

onIdle が期限を持つ条件

prefetch に渡す onIdle も @angular/core にある。

22.2.1 の onIdle
function onIdle(options) {
  if (ngDevMode) {
    assertInInjectionContext(onIdle);
  }
  const idleService = inject(IDLE_SERVICE);
  const {
    promise,
    resolve
  } = promiseWithResolvers();
  idleService.requestOnIdle(() => resolve(), options);
  return promise;
}

既定の実装は requestIdleCallback へそのまま渡す。

22.2.1 の RequestIdleCallbackService と取得部
const _requestIdleCallback = () => (typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : cb => setTimeout(cb)).bind(globalThis);

class RequestIdleCallbackService {
  requestIdleCallback = _requestIdleCallback();
  cancelIdleCallback = _cancelIdleCallback();
  requestOnIdle(callback, options) {
    return this.requestIdleCallback(callback, options);
  }
  cancelOnIdle(id) {
    return this.cancelIdleCallback(id);
  }
}

requestIdleCallback は timeout を渡さない限り期限を持たない。ブラウザは空き時間が来るまで呼ばず、空き時間が来なければ呼ばない。アニメーションや動画、周期の短いポーリングが続く画面では、{prefetch: onIdle} と書いた依存が読み込まれないまま残る。

期限を付ける
private readonly exporter = injectAsync(
  () => import('./report-exporter'),
  { prefetch: () => onIdle({ timeout: 2000 }) },
);

timeout を渡せば、その時刻までに呼ばれることが保証される。前節のとおり失敗は操作時まで残るので、期限は短すぎない値にする。

代替の経路は requestIdleCallback が無い環境で使われる。cb => setTimeout(cb) は第2引数を落としているので、timeout も空き時間の判定も無く、次のマクロタスクで呼ばれる。サーバーで描画するときにこちらへ落ちるため、同じ onIdle が、サーバーでは即座に走り、ブラウザでは空き時間を待つ。SSR の実行時間を見るときはこの差を踏まえる。

取り消す経路は使われていない。RequestIdleCallbackService は cancelOnIdle を持つが、onIdle はそれを呼ばない。コンポーネントが壊れたあとも空き時間が来ればチャンクは読み込まれる。一覧の行のように何度も作られるコンポーネントに prefetch を置くと、インスタンスごとに1つずつ空き時間の予約が積まれる。

Comment
@Reviewer: 一覧の行コンポーネントに `{prefetch: onIdle}` を置いていますが、prefetch はフィールドの初期化で1インスタンスにつき1回起動し、取り消されません。行数ぶんの `requestIdleCallback` が積まれます。prefetch は行ではなく一覧側の1箇所に移してください。

捕まえた注入器が壊れたあと

injectAsync は宣言した時点の Injector を捕まえ、読み込みが終わったあとにそれを使う。捕まえたものが環境注入器なら、破棄の検査が入る。

22.2.1 の R3Injector.get(先頭)
get(token, notFoundValue = THROW_IF_NOT_FOUND, options) {
  assertNotDestroyed(this);
  // ...
}

function assertNotDestroyed(injector) {
  if (injector.destroyed) {
    throw new RuntimeError(-205, ngDevMode && 'Injector has already been destroyed.');
  }
}

ルートの providers に置いたサービスの中で injectAsync を宣言した場合、捕まえるのはそのルートの環境注入器である。破棄されたあとに Promise が解決すると、token がルート注入器から解決できるものであっても NG0205 になる。

ルートの注入器は既定では破棄されないが、22.2 で入った withAutoCleanupInjectors() を provideRouter に渡し、RouteReuseStrategy.shouldDestroyInjector が真を返す構成では破棄される。遷移してから読み込みが終わる順序は、回線が遅いときに実際に起きる。

コンポーネントの中で宣言した場合は NodeInjector を捕まえる。こちらに破棄の検査は無く、解決はノードの注入器からたどって上へ進む。自動で登録されたサービスは生きているルート注入器から解決されるので、壊れたコンポーネントから await しても例外にはならない。返るのは使われない値なので、await の後に DOM を触る処理を書いているなら、そこで止める判断が要る。

遅延させる依存の選び方

ここまでの機構から、injectAsync を選べる条件は次のように引ける。

利用者の操作を起点に使う依存なら合う。出力、印刷、画像の加工のように、押されてから走る処理である。押してから読み込みが始まるぶんの待ちは、進捗の表示で埋められる。

初回の描画で何を出すか決めるのに要る依存には使えない。戻るのは Promise だけで、同期で読む口も signal も無い。描画の前に値が要るなら、その依存は候補から外れる。表示を決める値を Promise から取るために @defer やプレースホルダーを足すなら、injectAsync ではなくコンポーネントごと遅延させるほうが、待っている状態を画面で表せる。

prefetch を付けるのは、使われる確率が高く、かつ読み込みに時間がかかる依存に限る。前の2節のとおり、prefetch は失敗を起動時に固定し、取り消されない予約を積む。確率が低い依存なら、付けないほうが失敗の時期と回数が利用者の操作と一致する。

分割の効果そのものも確かめる。token が providers の配列に名指しされていれば、クラスへの参照が最初のチャンクに残り、動的 import() は分割を作らない。injectAsync を足した差分では、ビルドの出力でチャンクが増えたかどうかまで見る。

レビュー観点チェックリスト

injectAsync の線引きを確かめる項目
  • 遅延させた依存が、初回の描画で出すものを決めるのに要るものになっていないか
  • 読み込みの失敗の案内が、呼び直しではなく再読み込みを促す文になっているか
  • injectAsync の失敗を捕まえた箇所で、再試行の実装になっていないか
  • prefetch を付けた依存が、実際に使われる確率の高いものか
  • onIdle に timeout を渡しているか(渡さないと期限を持たない)
  • 周期の短い処理が続く画面で、onIdle の prefetch を前提にしていないか
  • 何度も作られるコンポーネントに prefetch を置いていないか
  • 捕まえる注入器が、読み込みの完了より先に壊れうる位置にないか
  • await の後の処理が、コンポーネントが壊れている場合を想定しているか
  • token が providers に名指しされていて、分割そのものが起きていない箇所は無いか
  • loader が default export を持たないモジュールを、型を合わせて渡していないか

おわりに

injectAsync は loader() が返した Promise を1つ覚え、解決した token を捕まえた注入器から引く。覚える対象に拒否も含まれるので、一度失敗した依存は読み直されない。prefetch を付けると、その失敗が起動時に固定される。

onIdle は requestIdleCallback をそのまま使う。timeout を渡さなければ期限が無く、空き時間が来ない画面では読み込まれない。取り消す経路も使われていないので、インスタンスごとの予約は残る。

したがって遅延させてよい依存は、操作を起点に使い、待ちを画面で表せ、失敗したときに再読み込みを促せるものである。描画の前に値が要るものと、使われる確率が低いまま prefetch を付けたものは、どちらも injectAsync の性質と合わない。

DIとサービス境界の観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「DIとサービス境界」の群に並べてある。