Angular基礎 #5 Service と依存性注入

読了 8分

前回は Directive と Pipe でテンプレートの表現力を広げる方法を見てきました。今回は視線をテンプレートの外に移して、コンポーネントの中にすべてのロジックを詰め込まない方法 — Service と依存性注入について調べていきます。

コンポーネントの中にすべてを詰め込んではいけない理由 #

ユーザー一覧を表示する UserListComponent があるとしましょう。その中にはデータを取ってくるロジック、加工するロジック、追加・削除のような動作、そして画面を描くテンプレートまですべて入っています。最初は問題なく見えます。ところが他のページでも同じユーザーデータが必要になったら? 同じ fetch ロジックを 2 つ目のコンポーネントにそのままコピペし始めます。次の画面、また次の画面… 同じコードがあちこちに散らばります。

それに加えてこのコンポーネントをテストしようとしてみると、画面のレンダリングとデータ取得が一塊なのでどちらか一方だけを切り離して検証するのが難しいです。

解決策は単純です。「画面を描く仕事」と「データを扱う仕事」を別のクラスに分離 すれば良いのです。Angular で後者を担当するのがまさに Service です。

Service とは何か #

Service はただの 一般的な TypeScript クラス です。特別な構文があるわけではなく、通常はビジネスロジック (割引計算、権限チェック)、データアクセス (HttpClient でバックエンド API 呼び出し)、複数のコンポーネントが共有する状態、ロギング・認証・通知のような共通機能を入れます。

コンポーネントは「何を表示するか」に集中し、Service は「データとロジックをどう扱うか」を担当します。そしてコンポーネントは自分で直接 Service を作らず、Angular に「これを 1 つください」と要求します。この要求-提供のメカニズムがまさに 依存性注入 (Dependency Injection、DI) です。

Service を作る #

CLI 1 行で作ります。

ng generate service user
# あるいは短く: ng g s user

src/app/user.service.ts とテストファイルが一緒に生成されます。生成されたファイルは次のような姿です。

src/app/user.service.ts
import { Injectable } from '@angular/core';

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

ポイントは 2 つです。

  • @Injectable() デコレータ: このクラスを Angular の DI システムが扱えるように印を付けます。「注入可能なクラス」という意味です。
  • providedIn: 'root': この Service をアプリの ルートインジェクタ に登録します。噛み砕くと、アプリのどこから要求しても 常に同じインスタンス を返すという意味です。つまり シングルトン (singleton) になります。

それでは実際のロジックを埋めていきましょう。ユーザー一覧を保持する簡単な Service です。

src/app/user.service.ts
import { Injectable, signal } from '@angular/core';

export interface User {
  id: number;
  name: string;
}

@Injectable({
  providedIn: 'root',
})
export class UserService {
  // 外部から直接修正できないように readonly で公開
  private readonly _users = signal<User[]>([
    { id: 1, name: 'キム・カーティス' },
    { id: 2, name: 'イ・アンギュラ' },
  ]);

  readonly users = this._users.asReadonly();

  addUser(name: string) {
    const id = Date.now();
    this._users.update((list) => [...list, { id, name }]);
  }

  removeUser(id: number) {
    this._users.update((list) => list.filter((u) => u.id !== id));
  }
}

状態は signal で管理し、外部から直接差し替えられないように asReadonly() で公開しました。変更は addUserremoveUser のようなメソッドを通してのみ起きます。一種の 小さな store のように動作するわけです。

コンポーネントから Service を使う #

これでコンポーネントからこの Service を受け取って使ってみましょう。モダン Angular では inject() 関数 をもっとも推奨します。

src/app/user-list/user-list.component.ts
import { Component, inject } from '@angular/core';
import { UserService } from '../user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    <h2>ユーザー一覧</h2>
    <ul>
      @for (user of userService.users(); track user.id) {
        <li>
          {{ user.name }}
          <button (click)="userService.removeUser(user.id)">削除</button>
        </li>
      }
    </ul>

    <input #nameInput placeholder="名前" />
    <button (click)="add(nameInput.value); nameInput.value = ''">追加</button>
  `,
})
export class UserListComponent {
  protected readonly userService = inject(UserService);

  add(name: string) {
    if (name.trim()) {
      this.userService.addUser(name.trim());
    }
  }
}

inject(UserService) 1 行で完了です。Angular はルートインジェクタから UserService のインスタンスを探して (なければ作って) 返します。私たちはそれをそのまま受け取って使えば構いません。

テンプレートでは userService.users() で signal の値を読んでいますが、signal は 関数呼び出し で現在の値を取り出すという点だけ覚えておけば構いません。signal の値が変わると Angular がその部分だけ自動で再描画してくれます。

注記
inject() 関数は Angular 14 で導入され、v16 からはコンポーネントクラスのフィールド初期化でも自由に使えるようになりました。新しいプロジェクトでは特に理由がなければ inject() を基本として使ってください。関数型のルートガードやインターセプタのようなクラス以外のところでもそのまま使えるのでコードに一貫性が出ます。

コンストラクタ注入 — 古い方式 #

inject() が標準になる前、長らく標準だった方式が コンストラクタ注入 です。今でも動作し、既存のコードベースではより多く目にするでしょう。

コンストラクタ注入 (legacy)
export class UserListComponent {
  constructor(private readonly userService: UserService) {}
}

型注釈 (UserService) を見て Angular が自動でインスタンスを入れてくれます。動作は同じです。ただし継承やデコレータ使用時に微妙な制約があり、クラスの外 (ルートガード関数など) では使えないという限界があるので、新しく書くときは inject() を推奨します。

複数のコンポーネントが同じ Service を共有 #

providedIn: 'root' で登録された Service は アプリ全体で 1 つの同じインスタンス です。あるコンポーネントでデータを追加すると、同じ Service を見ている 他のコンポーネントもすぐにその変更を見ることができます。上の UserListComponent の隣にユーザー数だけを表示する小さなコンポーネントを置いてみましょう。

src/app/user-count/user-count.component.ts
import { Component, computed, inject } from '@angular/core';
import { UserService } from '../user.service';

@Component({
  selector: 'app-user-count',
  standalone: true,
  template: `<p>現在のユーザー: {{ count() }}人</p>`,
})
export class UserCountComponent {
  private readonly userService = inject(UserService);
  protected readonly count = computed(() => this.userService.users().length);
}

1 つのページに <app-user-list><app-user-count> を一緒に置くと、一覧でユーザーを追加したときカウントコンポーネントの数字もすぐに上がります。2 つのコンポーネントが 同じ UserService インスタンス、同じ signal を見ているからです。コンポーネント同士で直接通信する必要なく、Service 1 箇所を拠点として状態が流れます。

これが React の Context、Vue の store と似た役割を Angular がもっとも自然にこなす方式です。別途のライブラリなしに Service + Signals だけで小さなアプリでは十分にきれいな状態管理になります。

Service の Lifetime — providedIn オプション #

Service をどこに登録するかによってインスタンスの 寿命と共有範囲 が変わります。もっともよくある 2 つを比較してみます。

1. providedIn: 'root' (推奨デフォルト値)

アプリ全体で ただ 1 つのインスタンス が生きています。どこで inject(UserService) をしても同じオブジェクトを受け取ります。ほとんどの場合これが正解です。

2. コンポーネント単位の provider

コンポーネントの providers 配列に登録すると、そのコンポーネントと子コンポーネント だけで新しいインスタンスを共有します。コンポーネントが消えると Service も一緒に消えます。

コンポーネントスコープの Service
@Component({
  selector: 'app-editor',
  standalone: true,
  providers: [EditorStateService],
  template: `...`,
})
export class EditorComponent {
  private readonly state = inject(EditorStateService);
}

「この画面の中だけで意味のある状態」を扱うときに便利です。例えば編集フォーム 1 つの一時状態のように、ページを離れたら一緒に整理されるべきデータによく合います。

判断基準は単純です。「アプリ全体で 1 つで足りるか?」'root'「このコンポーネントが生きている間だけ必要な別途のインスタンスか?」 → コンポーネント providers

依存性注入の本当の価値 — テスト #

DI が光を放つもっとも大きな舞台は テスト です。コンポーネントが new UserService() で直接オブジェクトを作っていたら、テストでも本物の HTTP を呼ぶ Service をそのまま使わなければなりません。遅くて不安定です。DI を使えば 偽 (mock) の実装に差し替えることができます

src/app/user-list/user-list.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UserService } from '../user.service';

it('Service のユーザーを画面に表示する', () => {
  const fakeUsers = signal([{ id: 1, name: 'テストユーザー' }]);
  const fakeUserService = {
    users: fakeUsers.asReadonly(),
    addUser: jasmine.createSpy('addUser'),
    removeUser: jasmine.createSpy('removeUser'),
  };

  TestBed.configureTestingModule({
    imports: [UserListComponent],
    providers: [{ provide: UserService, useValue: fakeUserService }],
  });

  const fixture = TestBed.createComponent(UserListComponent);
  fixture.detectChanges();
  expect(fixture.nativeElement.textContent).toContain('テストユーザー');
});

{ provide: UserService, useValue: fakeUserService } 1 行で、コンポーネントが UserService を要求したら本物の代わりに私たちが作った偽物を受け取るようになります。コンポーネントのコードは 1 文字も変えずに、です。コンポーネントがクラスそのものに依存せず、「注入を受ける」という事実だけに依存しているからこそ可能なこと です。これが依存性注入の本当の価値です — 自由な差し替え可能性、そしてその上に自然についてくるテストの容易性。

ヒント
バックエンドの Spring や NestJS を扱ったことがある方ならこのパターンが非常に馴染み深いはずです。Angular の DI は事実上同じ哲学をフロントエンドに持ってきたものです。そのためフルスタックで働くチームで Angular の採用率がとりわけ高い理由の 1 つになっています。

まとめ #

今回の記事では Service と依存性注入について見てきました。整理すると:

  • Service はビジネスロジック・データ・共有状態を入れる平凡なクラス
  • @Injectable({ providedIn: 'root' }) で登録するとアプリのどこからでも 同じインスタンス (シングルトン)
  • コンポーネントでは inject(UserService) で受け取って使うのがモダン Angular の推奨方式
  • providedIn: 'root' vs コンポーネントの providers — 共有範囲によって選ぶ
  • DI のおかげでテストで mock に差し替えるのが 簡単になる

これでコンポーネントとロジックを分離する方法が分かったので、画面が複数あるアプリを作る番です。次回の「Angular基礎 #6 Routing」では、Angular の Router で複数ページのアプリを構成する方法 — ルート定義、RouterLinkRouterOutlet、そしてガードとパラメータまで — をじっくり見ていきます。

X