とんかつ時々あんどーなつ

〜たとえ低空でも飛行していられるように〜

2023 年に入った Angular のさまざまなアップデート

はじめに

みなさん、Angular ルネサンスしてますか?

最近、ロゴが大きく刷新されたり、新しい公式ドキュメントページが公開されたりと話題になっている Angular ですが、2023 年は 15.1.0 (2023-01-10) から 17.0.4 (2023-11-20) までアップデートされました。

この記事ではこれらのバージョンからいくつかの機能をピックアップして紹介したいと思います。

これは Angular Advent Calendar 2023 1 日目の記事です。

主な変更点

Angular Signal

今年一番話題になったのはやはり Signal ではないだろうか。新しい reactive system として提供され v17 では API が安定版になっている(一部 developer preview あり)。

Signal には writable と read-only の 2 種類があり、writable な Signal を生成するには次のように signal() 関数を使う。

const count = signal(0);

Signal は getter を使って値を参照でき、set() を使って更新ができる。

console.log(count()); // 0

count.set(100);

console.log(count()); // 100

read-only な Signal は computed() 関数を使って作ることができる。これは別の Signal を元に新たな Signal を算出する関数である。

const doubleCount = computed(() => count() * 2);

たとえば、上記のように宣言することで doubleCount は count が変わったときに更新する必要があることを伝えることができる。

これらの Signal API は angular/core から提供されているため、追加のライブラリなしに使うことが可能である。 effect() など一部 developer preview の機能も含め、まだまだ改善されることが予定されているため、2024 年も目が離せない。

standalone by default

去年から提供され始めた standalone API だが、ついに v17 では新規にアプリケーションを作成するとデフォルトで standalone ベースで作られるようになった。一応 --standalone オプションを false にすることで外すことできるが、特にそういうユースケースはないと思う。

また NgModule ベースのアプリケーションを standalone ベースにマイグレーションするコマンドも v16 で提供されている。Angular の機能追加の中でも standalone を前提とした API がどんどん追加されているので、既存のアプリケーションもマイグレーションしていくことが求められている。

細かいマイグレーション手順については過去に紹介したこともあるのでリンクを貼っておく。

Angular アプリケーションを standalone にマイグレーションする - とんかつ時々あんどーなつ

Built-in control flow

v17 で新しい制御フロー構文が developer preview として提供された。これは構造ディレクティブで提供されていた *ngIf, *ngSwitch, *ngFor を置き換えるものである。 それぞれ @if, @switch, @for という構文になる。

@switch@case の間では適切に型の絞り込みがされるようになるため開発者体験が向上するだろう。また @fortrack が必須になるため、つけ忘れによるパフォーマンス劣化を防ぐことができる。

さらに、NgIf などの構造ディレクティブが不要になるためバンドルサイズが削減されるなど、書き方だけでなくユーザーへ提供できる価値としても効果がある。

すでにマイグレーションコマンドも用意されているため、さっそく置き換えることもできる。

ng generate @angular/core:control-flow

developer preview ではあるが、Angular チームも安定性には自信を持っていると言っているので積極的に使っていきたい。

Deferrable views

制御フロー構文と同じタイミングで新しく提供されたブロック構文である。これは遅延ロードの新しい仕組みであり @defer を使って次のように書くことで実現できる。

@defer {
  <hello-lazy />
}

またロードタイミングの設定も自由度が高く、on viewporton idle, on hover などトリガーと呼ばれるものを指定するだけで柔軟に設定できる。

@defer (on viewport) {
  <hello-lazy />
}

こちらもまだ developer preview でありどのくらい安定しているは不明だが、かなり体験が良さそうなので使えるところから使ってみたい。

Vite and esbuild

v17 で新しく @angular-devkit/build-angular:application という builder が提供された。今までの webpack ベースの @angular-devkit/build-angular:browser を置き換えるもので Vite, esbuild ベースになっている。

v17 で新規にアプリケーションを作成するとデフォルトで @angular-devkit/build-angular:application を使うようになっている。既存のアプリケーションからのアップデートでは今のところ自分で angular.json を書き換える必要がある。

これを書き換えるだけで build 時間が短縮されるので、積極的に置き換えていきたい。browser のオプションのうち buildOptimizervendorChunk などは application では使えないため、置き換えた場合はこれらのオプションは合わせて削除する必要があることだけ注意である。

CLI のマイナーバージョンアップでマイグレーションコマンドが提供されそうなので、この変更が入ったバージョンを待ってからでも遅くはない。

Input value transforms and Required Inputs

ここでは Input プロパティに関する変更を 2 つ紹介する。

1 つ目は Input value transforms である。これは親コンポーネントから受け取った値を自身のプロパティに代入する前に変換用の関数を通すことができる仕組みである。 例えば次の例である。

@Component({
  standalone: true,
  selector: 'my-button',
  template: `…`
})
export class MyButton {
  @Input() disabled: boolean = false;
}
<my-button disabled />

Input プロパティは boolean を期待しているが、文字列の 'false' や undefined が来てしまうことがある。この差異を次のように書くことで吸収できる仕組みになっている。

@Component({
  standalone: true,
  selector: 'my-button',
  template: `…`
})
export class MyButton {
  @Input({ transform: booleanAttribute }) disabled: boolean = false;
}

色々なことがやりたくなってしまうところではあるが小さい純粋関数を通す、くらいに留めておいたほうがよさそうである。

もう 1 つは Required Inputs である。その名の通り Input プロパティに必須のフラグを付けることができる。

@Component(...)
export class MyHeading {
  @Input({ required: true }) text: string = '';
}

required が true の Input プロパティはテンプレート側で指定していないとコンパイルエラーになる。 v16 で導入されたため、個人的にはすでに積極的に使っている機能の一つである。実行時ではなくコンパイル時に気付けることが増えるのはとても嬉しい。

takeUntilDestroyed

コンポーネントやディレクティブが破棄されたときに Subscription の購読を解除する必要があるが、この処理についておそらく誰しもが同じようなコードを書いているはずである。これを簡単におこなうための API@angular/core/rxjs-interop から提供されている。現時点では developer preview ではあるが、この API に置き換えることで多くのボイラープレートが削除できると思う。

例えば次のように書くことができる。

data$ = this.http.get('...').pipe(takeUntilDestroyed());

デフォルトでは inject context でしか呼び出すことができない。それ以外の場所で呼び出す場合は、明示的に DestroyRef を渡す必要がある。

readonly #destroyRef = inject(DestroyRef);

ngOnInit(): void {
    this.http.get('...').pipe(takeUntilDestroyed(this.#destroyRef)).subscribe((data) => this.data = data);
  }

ちなみに inject context 以外で呼び出した場合は NG0203 のエラーが発生するのでその時は DestroyRef を思い出すとよい。

Jest の実験的サポート

v16 で angular-devkit から Jest 用の builder が提供されている。experimental だが使い方は簡単で builder を @angular-devkit/build-angular:jest に置き換え、使用できないオプションを消していく。

diff --git a/angular.json b/angular.json
index f9f85de..9fe2129 100644
--- a/angular.json
+++ b/angular.json
@@ -75,14 +75,10 @@
           }
         },
         "test": {
-          "builder": "@angular-devkit/build-angular:karma",
+          "builder": "@angular-devkit/build-angular:jest",
           "options": {
             "polyfills": ["zone.js", "zone.js/testing"],
-            "tsConfig": "tsconfig.spec.json",
-            "inlineStyleLanguage": "scss",
-            "assets": ["src/favicon.ico", "src/assets"],
-            "styles": ["src/styles.scss"],
-            "scripts": []
+            "tsConfig": "tsconfig.spec.json"
           }
         }
       }

いくつかパッケージのインストールは必要だが、この状態で npm run test を実行すると足りていないパッケージを教えてくれる。最終的に必要になるのは jest と jest-environment-jsdom になる。

しかしまだ NOTE: The Jest builder is currently EXPERIMENTAL and not ready for production use. のログが出るため、本格的に使用できる段階ではなさそうである。

Reading the route parameters

パスパラメータやクエリパラメータなどのルーティングパラメータをコンポーネントの Input プロパティとして参照できるようにする機能が v16 で提供されている。 この機能を使うには withComponentInputBinding() を有効にする必要がある。設定方法は provideRoute() の第 2 引数に渡すだけである。

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes, withComponentInputBinding())],
})

この設定をすることで次のようなルーティングがあったときに id を Input プロパティとして受け取ることができる。

// routes.ts
const routes: Routes = [
  ....
  { path: 'detail/:id', component: HeroDetailComponent },
];

// hero-detail.component.ts
@Component({...})
export class HeroDetailComponent implements OnInit {
  @Input() id!: string;
}

これによりルーティングパラメータを取得するために ActivatedRoute を使う必要がなくなる。

アプリケーションが大きくなっていき、コンポーネントの中でも責務を分割していくと ActivatedRoute を inject できるのは routed component だけというルールを作ることもあった。しかし withComponentInputBinding を有効にすることでこのルールが強制されるため、個人的には積極的に置き換えていきたいと思っている。

まとめ

15.1.0 から 17.0.4 までの更新で記憶に残っているもの、積極的に使っていきたいものを中心にピックアップしてみました。

その他 SSR / SSG 周りも大きく改善された 1 年でしたが、今回は取り上げていないのでこのあとの Advent Calendar で誰かが書く記事を待ちたいと思います(笑)

また、今年の変更の多くは Angular 日本ユーザー会が配信している ng-japan OnAir でも触れられているので、下記リンクの動画を見ることで deep dive できるかもしれません。 www.youtube.com

明日は carimatics さんです!

参考