👀

[Angular] InjectionTokenとinject関数を活用して、状況・状態ごとに動的な値を取得する

2022/09/18に公開約6,900字2件のコメント
  • ページの構成は同じだけど、扱うものの種類・類型によって表示する内容・処理を変えたい
  • 認証ユーザーの設定ごとに表示する内容を変えたい
  • URLのパス・クエリパラメータに応じて表示する内容を変えたい

といった状況が都度あると思う。
それを実現するのに役立つのがInjectionTokeninject関数だ。
状態管理ライブラリを活用することで解決できるものもあるが、公式が用意している方法で済むならそれに越したことはない。
コンポーネント・モジュールの汎用性・再利用性を高めることができるので、ぜひ活用していこう。

InjectionTokenとは?

各モジュール・コンポーネントごとに、同じ概念を表すオブジェクトや値を提供するもの。
身近に例えるとネット通販。「Am◯zonから届いたダンボール」という概念は同じだが、その中身は注文した人によって異なる。
これをコンポーネントに当てはめると、とある共通のページを表すコンポーネントにPRODUCT_CATEGORYというInjectionTokenを渡したとき、BookModule配下ならBOOKとして、FoodModule配下ならFOODという値として扱いたい。そのようなコンポーネントを再利用したい場面で活躍する。
詳しくは公式を確認しよう。具体例は後述。
公式ドキュメント - InjectionToken オブジェクトを使用する
APIリファレンス - InjectionToken

inject関数とは?

関数やサービスクラスが依存するクラスのインスタンスを注入できる関数。

Angular v14からは@Component@Directive,@Pipeのクラス中でも使用することが可能になった。
公式や下記記事が参考になる。
APIリファレンス - inject
Unleash the Power of DI Functions in Angular

便利な機能ではあるが、inject関数単体をコンポーネントクラス中で使う場合、あくまでDIの文脈に縛られるため、使いどころが難しいなと感じた。
詳しくは後述。

InjectionTokenをプロバイダーに登録して、目的ごとに値を切り替える

まずは単純な使い方から。先に挙げたPRODUCT_CATEGORYを例に取る。
ページの構成は下の図の通り。

  • トップページ
    • 商品カテゴリ「本」のページ
      • 本の詳細ページ
    • 商品カテゴリ「食べ物」のページ
      • 食べ物の詳細ページ

という構成。トップページ以外はNgModuleとし、すべて遅延ローディングしている。
ここで、商品詳細ページを共通コンポーネントとして再利用したい。
具体的には、カテゴリーによって表示する内容を切り替えたり、依存しているサービスクラスのメソッド引数に渡したり、といったケースが想定できる。
今回は簡便のため、テンプレートでそのままトークンの内容を表示するに留める。

さっそくコードをみてみよう。
まずはInjectionTokenを、その型定義とともにTopComponentのクラス外に定義する。
(このあと行うプロバイダー登録時に型の補完が効かなかったので、まわりくどいが商品カテゴリー定数を変数から生み出す方式を取った。タイポするよりマシなので。)

export const ProductCategory = {
  BOOK: 'BOOK',
  FOOD: 'FOOD',
} as const

export type ProductCategory =
  typeof ProductCategory[keyof typeof ProductCategory]

export const PRODUCT_CATEGORY =
  new InjectionToken<ProductCategory>('商品のカテゴリーです。')

次に、各カテゴリーのNgModuleでプロパイダー登録する。
これにより、その配下のコンポーネント・モジュールでは、登録された値がPRODUCT_CATEGORYとして扱われる。

@NgModule({
  declarations: [
    BookComponent
  ],
  imports: [
    CommonModule,
    BookRoutingModule,
    ProductDetailModule,
  ],
  providers: [
    {
      provide: PRODUCT_CATEGORY,
      useValue: ProductCategory.BOOK
      // FoodModuleではProductCategory.FOODを与える
    },
  ],
  exports: [
    BookComponent
  ],
})
export class BookModule { }

最後に、InjectionTokenを使用したいコンポーネント(今回はProductDetaiComponent)に注入する。

@Component({
  selector: 'app-product-detail',
  template: `
    <p>商品カテゴリー「{{ category }}」の詳細ページです。</p>
  `,
})
export class ProductDetailComponent {

  constructor(
    @Inject(PRODUCT_CATEGORY)
    public readonly category: ProductCategory,
  ) {}
}

結果、本の詳細ページでは「商品カテゴリー「BOOK」の詳細ページです。」 と表示される。
同様のケースを実現する方法は他にもあるが、共通モジュールで扱う値を、インポート先のモジュールによって切り替えたいという場面での使用が適している。
一方で、共通コンポーネントをテンプレートで呼び出すだけならInputで渡せば済む。
何がなんでもInjectionTokenを使う必要はなく、場面に応じて適切な方法を選択しよう。

InjectionTokenとinject関数の合わせ技で、汎用性の高い依存オブジェクトを提供する

次は「モジュールを読み込むタイミングで変わる、動的かつ共通の値を扱いたい」ケース。
先のケースでは、モジュールごとに静的な値を与えていた。
今回は「認証ユーザーごとに異なる設定値をアプリケーション中で扱う」例を考える。
このケースでは、

  • ユーザー認証の有無により、設定値を取得できるか変わる
  • ユーザー認証済みでも、設定値がユーザーごとに異なる

ことが考えられる。
典型的にはユーザー名。ECサイトなんかだと認証前は「ゲスト」になるし、認証後は個別名になり、さらに変更も可能だろう。

このような要求をInjectionToken + Inject関数の組み合わせで解決しよう。

まずはユーザー情報を取得するサービスクラスを用意する。

import { Injectable } from '@angular/core';

export type UserInfo = {
  id: string,
  name: string,
}

@Injectable({
  providedIn: 'root'
})
export class UserService {

  constructor() {}

  getUserInfo(): UserInfo | undefined {
    return // ローカルストレージ等から取得
  }
}

認証情報はローカルストレージ等に保存されているものを取得すると想定。

ユーザー名を表示したいコンポーネントごとに、サービスクラスをDIして取得してくればいいが、使いたい箇所が多いとやっかい。
そこで、inject関数をInjectionTokenのfactoryプロパティで呼び出し、依存性を解決する。
今回は先ほどのTopComponentに定義する。

export const USER_NAME =
  new InjectionToken<string>('ユーザー情報', {
    providedIn: 'root',
    factory: () => {
      const userInfo = inject(UserService).getUserInfo()
      return userInfo === undefined
        ? 'ゲスト'
        : userInfo.name
    },
  })

inject関数を利用すれば、特定のコンポーネントのDIにとらわれることなく、しかもInjectionTokenを注入するコンポーネント(モジュール)の読み込み時の状態に基づいて値を取得できる。これがミソ。
あとはこれを使用したいコンポーネントで呼び出すだけ。

export class FoodComponent {

  constructor(
    @Inject(USER_NAME)
    public readonly userName: string
  ) {}
}

これが何を意味するか。

  • 認証の前後で取得する値(ユーザー名)を(自動的に)切り替えられる
  • 当然、登録ユーザーごとにユーザー名を切り替えられる

これにより、認証前は「ゲスト」、認証後は「個別名」といった表示の切り替えが可能になる。
これはかなり便利なので、使えるシーンは多いのではないだろうか。
ちなみに、inject関数の説明の項で書いたとおり、v14からはコンポーネントクラスでもinject関数が使用できるため、先程の@Inject()は直接プロパティに代入する形に書き換えられる。

  private readonly userName = inject(USER_NAME)

好みの方を採用すればいいと思う。

(おまけ)コンポーネントクラスでのinject関数の使用余地

先に示したとおり、inject関数はInjectionToken以外にも、単体で使用できる。
参考に紹介した記事では、ActivatedRouteをinject関数で呼び出し、クエリパラメータから値を返す関数を作成し、コンポーネントクラスのプロパティに代入していた。

当然といえば当然なのだが、inject関数を使ってActevatedRouteやサービスクラスの非同期処理を呼び出す関数を作成した場合、コンポーネントクラスでの使用は「injection context」、つまり依存性解決の文脈でしか使用できないことに注意が必要。
たとえば、上の記事で紹介されていたクエリパラメータの取得を、ライフサイクルメソッドのngOnInitで行った場合、
NG0203: 'inject()' must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with 'EnvironmentInjector#runInContext'.
の実行時エラーになる。

export function getRouteParam(key: string) {
  return inject(ActivatedRoute).snapshot.params[key];
}

@Component({
  selector: 'app-todo-page',
  templateUrl: './todo-page.component.html',
})
export class TodoPageComponent {
  // id = getRouteParam('id');

  ngOnInit() {
    // これはエラー
    console.log(getRouteParam('id'))
  }
}

公式エラーリファレンス

公式エラーリファレンスで紹介されているとおり、コンポーネントの初期化のタイミング、つまり

  • コンストラクタの中
  • プロパティの初期値
  • プロバイダーのファクトリ

であれば使用できる。

初期化の時点で値が定まっている必要があるため、DOMの描画に関わる非同期処理には向かない。
(DOMの描画に関わる処理をコンストラクタで行うと、予期しない動作になることがあるため)

さきほどのUserServiceのユーザー取得をバックエンドAPIからの取得に変更し、その取得処理をinject関数による別関数へ切り出してみた。
下記のような使用はできない、もしくはすべきでない。
(そもそもこんな使い方しないと思う。「複数の依存性の処理をまとめたい」という場面はあるかも。)

@Injectable({
  providedIn: 'root'
})
export class UserService {

  constructor(
    private readonly http: HttpClient,
  ) {}

  getUserInfo(): Observable<UserInfo | undefined>{
    return this.http.get<UserInfo | undefined>('/api/user/...')
  }
}

// ineject関数に切り出し
export const getUser = (): Observable<UserInfo | undefined> => {
  return inject(UserService).getUserInfo()
}

// コンポーネントクラスでのNGな使用例
export class FoodComponent implements OnInit {

  user: UserInfo | undefined

  constructor() {
    // これはすべきでない
    getUser().subscribe(v => this.user = v)
  }

  ngOnInit(): void {
    // これは実行時エラーになる
    getUser().subscribe(v => console.log(v))
  }
}

正直、最初は「inject関数を使えば、DIすることなくどこでも呼び出せて便利やん!」と思っていた。
考えてみれば当然なのだが、DIの文脈が崩壊するのでそれは無理だった。
コンポーネントインスタンスの作成・初期化のタイミングで使用できるもの、と理解しておこう。

InjectionToken自体と、inject関数との組み合わせは便利な機能なので、適した場面で使用していこう。

Discussion

タグ たぶんタイプミスしてますよ!

ほんとだ!!
めちゃくちゃしょうもないミスでした。笑
ありがとうございます。

ログインするとコメントできます