Angular v17 で新しい制御フロー構文がやってきた！マイグレーションコマンドを試してみたよ

この記事は [Angular Advent Calender 2023](https://qiita.com/advent-calendar/2023/angular) の6日目の記事です。
5日目の記事はrch850さんの[Angular 17 の View Transitions API 対応で遊んでみた](https://zenn.dev/rch850/articles/3c214564567e5b)でした。

## Angular v17 で新しい制御フロー構文がやってきた！

テンプレートの機能として必ず必要になる「分岐」や「繰り返し」は、これまでのAngularでは構造ディレクティブの `ngIf` `ngFor` `ngSwitch` で実現されてきました。

Angular v17からは新しい制御フロー構文 `@if` `@for` `@switch` が追加され、これまでの構造ディレクティブは全て新しい制御フロー構文で書き換え可能になります。

詳細は公式ドキュメントや、

https://angular.dev/guide/templates/control-flow

Angular Advent Calender 2023 2日目の記事、carimaticsさんの[Angularにおける組み込み制御フローの導入とその背景](https://zenn.dev/carimatics/articles/angular_2023_builtin_control_flow)などを読んでもらえると書き換えるモチベーションも理解できて良いでしょう。

## 新しい制御フロー構文に書き換えるマイグレーションコマンド

Angularチームの方で既存の構造ディレクティブを新しい制御フロー構文に書き換えるマイグレーションコマンドがすでに準備されています。

```
ng generate @angular/core:control-flow
```

コマンド1発で簡単ですね！

さっそく試してみましょう。例えば以下の実装に対して、

```html
<ng-container *ngIf="greeting">
  <span>hello</span>
</ng-container>
<ng-container *ngFor="let number of numbers">
  <span>{{ number }}</span>
</ng-container>
<ng-container [ngSwitch]="num">
  <span *ngSwitchCase="1">one</span>
  <span *ngSwitchCase="2">two</span>
  <span *ngSwitchCase="3">three</span>
</ng-container>
```

```ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss'
})
export class AppComponent {
  greeting: boolean = true;
  numbers: number[] = [1,2,3,4,5];
  num: number = 2;
}
```

コマンドを実行します。

```
$ ng generate @angular/core:control-flow
? Which path in your project should be migrated? ./
? Should the migration reformat your templates? Yes
    IMPORTANT! This migration is in developer preview. Use with caution.
UPDATE src/app/app.component.html (248 bytes)
UPDATE src/app/app.component.ts (366 bytes)
```

パス単位の指定、reformatを対話的に選べます。実際のdiffは以下の通り。

```diff
$ git diff -w src
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 781f3e5..2aef72f 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -1,11 +1,17 @@
-<ng-container *ngIf="greeting">
+@if (greeting) {
   <span>hello</span>
-</ng-container>
-<ng-container *ngFor="let number of numbers">
+}
+@for (number of numbers; track number) {
   <span>{{ number }}</span>
-</ng-container>
-<ng-container [ngSwitch]="num">
-  <span *ngSwitchCase="1">one</span>
-  <span *ngSwitchCase="2">two</span>
-  <span *ngSwitchCase="3">three</span>
-</ng-container>
+}
+@switch (num) {
+  @case (1) {
+    <span>one</span>
+  }
+  @case (2) {
+    <span>two</span>
+  }
+  @case (3) {
+    <span>three</span>
+  }
+}
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index 9a57e6b..4bfd92f 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -1,11 +1,11 @@
 import { Component } from '@angular/core';
-import { CommonModule } from '@angular/common';
+
 import { RouterOutlet } from '@angular/router';
 
 @Component({
   selector: 'app-root',
   standalone: true,
-  imports: [CommonModule, RouterOutlet],
+  imports: [RouterOutlet],
   templateUrl: './app.component.html',
   styleUrl: './app.component.scss'
 })
```

いい感じに変更してくれてますね。

`@for` で指定が必須になった `track` はループ変数そのものが指定されるよう。ここは適宜ユニークなプロパティになるよう修正が必要そうです。

```diff
+@for (number of numbers; track number) {
```

CommonModule の import も削除してくれています。バンドルサイズを小さくするという意志を感じますね。

```diff
-  imports: [CommonModule, RouterOutlet],
+  imports: [RouterOutlet],
```

## 実際の生きている業務コードで試してみた

さて、ここからが本題です。

> IMPORTANT! This migration is in developer preview. Use with caution.

実行時にこう出力されるぐらいなので、実際の生きている業務コードではどれくらいの精度で置き換えてくれるのかが気になりました。さっそく試してみましょう。

とは言え業務コード自体は公開できないので、規模感だけでもお伝えします。

```sh
# コンポーネント数
grep -r '@Component' src | wc -l
     156

# テンプレートファイルの行数
find src -name "*.html" -type f | xargs cat | wc -l
   22403

# ngIfの総数
grep -r '*ngIf' src | wc -l
     360

# ngForの総数
grep -r '*ngFor' src | wc -l
     106

# ngSwitchの総数
grep -r 'ngSwitch' src | wc -l
      50
```

ファーストコミットから3年以上経過して今なお現役、それなりに年季が入ったプロジェクトになっています。

## マイグレーション実行した結果の差分を眺めてみる

実行は特段のエラーもなく完了しました。

`ngFor` の `trackBy` を指定していると書き換えがどうなるのか気になっていたので確認してみると、

```diff
-<ng-container *ngFor="let option of options; trackBy: trackByOption">
+@for (option of options; track trackByOption($index, option)) {
```

いい感じに変換されてます。いいですね。
一通り眺めてみましたが、特筆する内容もなく書き換えがなされていました。


## ビルドでエラーになったケース

ビルドしてみたところビルドエラーになるケースが2つ見つかりました。

### エラーケース1

> error TS2348: Value of type 'typeof of' is not callable. Did you mean to include 'new'?

以下のように `*ngFor` の変数に `class` という予約語を使っていることが原因でした。

```html
<ng-container *ngFor="let class of classes">
  <span>{{ class }}</span>
</ng-container>
```

上記はビルドが通りますが、書き換え後の以下はビルドエラーが発生します。

```html
@for (class of classes; track class) {
  <span>{{ class }}</span>
}
```

構造ディレクティブの構文と制御フロー構文とで評価のされ方は変わっているようですね（型チェックをより厳密に適用していくという話を踏まえると納得感はある）。 `ngFor` のループ変数に予約語を使うのが悪い！と言われればそれは本当にそう。 `ngFor` のループ変数にうっかり予約語を使っていると出くわす可能性がありそうです。

### エラーケース2

> error NG5002: @switch block can only contain @case and @default blocks

`ngSwitch` のコンテナ直下に `ngIf` があるケースで壊れました。

ちょっと自分でも何書いているか意味分からないし、なんでそんな実装になったのかも意味不明なんですが、生きている業務コードにはいろいろあるのですよ...

具体的なコードはこうです。

```html
<ng-container [ngSwitch]="num">
  <ng-container *ngIf="greeting">
    <span>hello</span>
  </ng-container>
</ng-container>
```

実装意図はさておき、上記は実際にビルドが通ります。これをマイグレーションすると以下のようになります。

```html
@switch (num) {
  @if (greeting) {
    <span>hello</span>
  }
} 
```

どうやら `@switch` ブロックの中に `@if` ブロックは置けないみたいですね。

とはいえそもそもこんなコード書くな！だし、普通は書かないし、一般的には気にする必要がないケースと思います。トリビア的に覚えてもらえれば幸いです。

## まとめ

マイグレーションコマンドで書き換えて、上記エラーケースを直したアプリケーションは無事ビルドもでき、現在は検証環境で何事もなく動作しています。やったぜ！

人間が見ても頭を抱えるレベルの破茶滅茶なコードを書いていればその限りでもありませんが、自動テストがそれなりに整備されているプロジェクトにおいてはマイグレーションコマンドで機械的にエイヤ！と書き換えてあとは自動テストで確認としてしまえば良さそうな感触を持ちました。

新しい制御フロー構文に書き換えることは開発体験の向上だけでなくアプリケーションのパフォーマンス向上も見込めます。積極的に書き換えていきたいですね。

明日は beltway7 さんです。よろしくお願いします！