2020-07-29

Angular: Lightweight Injection Tokenという新しいテクニック

#angular #lightweight injection token

最近Angularチームが発見し、Angularライブラリの実装におけるパターンとして普及させようとしているのが、 Lightweight Injection Token というテクニックだ。これはこれまで不可能だった コンポーネント(ディレクティブ)のTree-Shaking を可能にする。本稿ではこの新しいテクニックの概要、そして生まれた経緯や深く知るための参考リンクをまとめる。

なお、Lightweight Injection Tokenについては公式ドキュメントでも解説される予定であるため、そちらを参照すればいい部分は省略する。

Angular - Optimizing client app size with lightweight injection tokens

Lightweight Injection Tokenの概要

ひとことでいえば、「オプショナルな機能に関連するInjection Tokenとして代替の軽量トークンを使う」ということである。AngularのDIを深く理解していればこれだけでピンと来るかもしれないが、具体例から概要をつかもう。

あるAngularライブラリが、次のような使い方ができる <lib-card> コンポーネントを提供している。

<lib-card>
  Hello World!
</lib-card>

このコンポーネントは、Contentとして <lib-card-header> コンポーネントを配置すると、カードのヘッダーとして取り扱う オプショナル な機能があることをイメージしよう。

<lib-card>
  <lib-card-header>Greeting Card</lib-card-header>
  Hello World!
</lib-card>

ライブラリ側はこのような使い方ができるコンポーネントを実装するとおおよそ次のようになるだろう。 @ContentChild() を使って CardHeaderComponent の参照を得る。ただしこのヘッダーを置くかどうかはユーザー次第なので、 CardHeaderComponent|null という形でnullを許容することになる。

@Component({
  selector: 'lib-card-header',
  ...,
})
class CardHeaderComponent {}

@Component({
  selector: 'lib-card',
  ...,
})
class CardComponent {
  @ContentChild(CardHeaderComponent)
  header: CardHeaderComponent|null = null;
}

ここで問題になるのが、 CardComponent から CardHeaderComponent への参照の持ち方である。 @ContentChild(CardHeaderComponent)header: CardHeaderComponent|null の2箇所で参照を持っているが、この2つは性質が異なる。

後者の header: CardHeaderComponent|null は、としての参照である。この参照はTypeScriptのコンパイル時型チェックにのみ用いられ、コンパイル後のJavaScriptには残らないため問題にならない。

問題は前者の @ContentChild(CardHeaderComponent) だ。これはとしての参照であり、 CardHeaderComponent というクラスオブジェクトそのものを参照している。それが直接 @ContentChild() デコレーターに渡されているのだから、ユーザーがヘッダーを使おうが使わまいが、この参照は実行時に残る

@ViewChild()@ContentChild() の走査条件として使われるコンポーネント/ディレクティブのクラス参照はどうしてもTree-Shakingできず、これがAngularライブラリを利用したときのバンドルサイズの肥大化の原因となる。

これを解決するためのアプローチが、Lightweight Injection Tokenだ。上記の例で @ContentChild() デコレーターに渡していたクラスを、次のように軽量なオブジェクトを利用したInjection Tokenに置き換える。

// Lightweight Injection Token
abstract class CardHeaderToken {}

@Component({
  selector: 'lib-card-header',
  providers: [
    {provide: CardHeaderToken, useExisting: CardHeaderComponent}
  ]
  ...,
})
class CardHeaderComponent extends CardHeaderToken {}

@Component({
  selector: 'lib-card',
  ...,
})
class CardComponent {
  @ContentChild(CardHeaderToken) header: CardHeaderToken|null = null;
}

まず CardHeaderToken 抽象クラスを作成し、 CardHeaderComponent をその具象クラスとする。そしてコンポーネントプロバイダーで CardHeaderToken に対して自身のクラスオブジェクトを提供する。 CardComponent ではトークンを @ContentChild()デコレーターの走査条件とする。

これにより、 CardComponent から直接の CardHeaderComponent への参照はなくなり、ライブラリのユーザーが <lib-card-header> コンポーネントを呼び出したときだけ CardHeaderToken に対して CardHeaderComponent クラスのインスタンスが提供されることになる。

@ContentChild()@ViewChild() の引数としてDIトークンを渡せるようになるのがバージョン 10.1.0からなので、このアプローチが取れるのはバージョン 10.1.0以降になる( as any で突破する手法はあるが)。

feat(core): support injection token as predicate in queries (#37506) · angular/angular@97dc85b

なぜ今なのか、これまでの経緯

この問題は昔からずっと存在したが、実はバージョン8まではそれほど重大な問題ではなかった。なぜかというとバージョン8以前、つまりIvy以前 (ViewEngine, VE) はAOTコンパイルによってテンプレートコンパイルされた結果の生成コードが、もとのコンポーネントとは別のクラス実体をもっていたからだ。

ViewEngineでは CardComponent クラスのデコレーターとそのメタデータをもとに CardComponentNgFactory クラスが生成される。そして、JavaScriptとしてコードサイズが大きいのはほとんどの場合NgFactory側である。

つまり上記の例でいえば、 たとえ CardComponentNgFactory クラスが CardHeaderComponent への参照を持っていたとしても、CardHeaderComponent そのものが大きくないために問題にならなかったのだ。サイズが大きいのは CardHeaderComponenNgFactory のほうで、NgFactoryは テンプレート中で <lib-card-header> を使わない限り参照されないため、不完全ではあるがTree-ShakingできていたのがViewEngine方式だった。

バージョン9からデフォルトになったIvy方式のAOTコンパイルは、生成コードを もとのクラスの静的フィールドとして合成する。よって AOTコンパイルすると CardHeaderComponent そのもののサイズが大きくなり、 CardComponent に巻き込まれて一緒にバンドルされるサイズが顕著に大きくなる。いままで行なわれていた生成コードのTree-ShakingがIvyによりなくなってしまった。

つまり、Lightweight Injection TokenはViewEngine時代には顕在化していなかったがIvyによってクリティカルになった問題を解決するために編み出された、Ivy時代のAngualrライブラリ実装パターンである。

もっともポピュラーなAngularのコンポーネントライブラリであるAngular Materialではバージョン9リリース時からバンドルサイズの増加が報告されており、その解消の過程でAngularチームが辿り着いた答えである。現在Angular ComponentsチームはAngular Materialの各コンポーネントをLightweight Injection Tokenパターンに置き換える作業を進めている。

Increased initial main.js bundle size in v9 - mainly due to @angular/material packages · Issue #19610 · angular/components

Use light-weight injection pattern for optimized tree-shaking/bundle size · Issue #19576 · angular/components

コンポーネント以外のLightweight Injection Token

ところで、 @ContentChild() などの走査条件でなくとも、通常のDIの中でもオプショナルなものについてはLightweight Injection Tokenパターンを使うべきである。 @Optional() を使っていてもそのトークンの参照は残るためTree-Shakingはできない。コンストラクタDIでは型注釈部分にしか参照がないためコンパイルすれば消えそうに見えるが、コンストラクタ引数の型注釈はAOTコンパイル時に自動的に @Inject() デコレーターに変換されるため、実体参照をもつのである。つまりこれも @ContentChild() と全く同じ構造であり、同じ問題をもちうる。ライブラリ作者であればオプショナルなプロバイダーのトークンは可能な限り軽量にしておくべきだろう。

class MyComponent {
  constructor(@Optional() srv: OptionalService) {}
}

// Same
class MyComponent {
  constructor(@Optional() @Inject(OptionalService) srv: OptionalService) {}
}

ちなみにコンポーネントのLightweight Injection Tokenとして InjectionToken オブジェクトを使うこともできるはずだ。公式ドキュメントでは抽象クラスの例が紹介されているが、どちらが定着するかは今後のコミュニティでの受け入れられ方次第だろう。ただ、トークンの抽象クラスとコンポーネントクラスを継承関係にするとそのままコンポーネントのAPI定義として利用もできるため、おそらくは抽象クラスのほうが便利な場面は多そうだ。

const CardHeaderToken
  = new InjectionToken<CardHeaderComponent>("CardHeaderComponent");

https://angular.io/guide/dependency-injection-providers#non-class-dependencies

参考リンク

以下に参考リンクをまとめる。

このエントリーをはてなブックマークに追加
comments powered by Disqus