[Angular] InjectionTokenとinject関数を活用して、状況・状態ごとに動的な値を取得する
- ページの構成は同じだけど、扱うものの種類・類型によって表示する内容・処理を変えたい
- 認証ユーザーの設定ごとに表示する内容を変えたい
- URLのパス・クエリパラメータに応じて表示する内容を変えたい
といった状況が都度あると思う。
それを実現するのに役立つのがInjectionTokenとinject関数だ。
状態管理ライブラリを活用することで解決できるものもあるが、公式が用意している方法で済むならそれに越したことはない。
コンポーネント・モジュールの汎用性・再利用性を高めることができるので、ぜひ活用していこう。
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
タグ たぶんタイプミスしてますよ!
ほんとだ!!
めちゃくちゃしょうもないミスでした。笑
ありがとうございます。