bitbank techblog

Angular 17 の新要素と周辺プロジェクトの紹介

はじめまして、昨年入社した kei です。これからたくさん技術ブログを書いていきたいと思っているのでどうぞよろしくお願いします!

=====

2023 年の 11 月に Angular のバージョン 17 がリリースされました。新しい構文の導入などわかりやすいアップデート内容もありつつ、他のフロントエンドフレームワークを意識したリアクティブプログラミングのための新しいアプローチもサポートするなど、だいぶ先進的な取り組みも見られます。

今回は実際に Angular 17 でゼロからアプリをつくってみることで、それぞれの機能をざっと把握しどれくらい便利に使えそうなのかを確かめてみます。すべての機能を網羅しているわけではないし、厳密にいえばバージョン 17 からの新機能以外も扱っていますが、「なんとなく最近の Angular でよさそうな機能を使ってみた!」という趣旨の記事だとご理解いただければ幸いです。

新しい制御構文:@if や @for

テンプレート定義で最もよく使うであろう if と for の新しい書き方です。

これまでの Angular は単純な if 文や for 文の定義がほかのフロントエンドフレームワークよりも少しややこしくなっていて、とっつきにくさを助長している気がしました。

実際、初めて Angular をさわったとき if と for のテンプレート構文には(よくない意味で)驚きました。特に ngIf における else 節のつくり方と ngFor のキートラックの書き方はいまでもこれが正しいのか疑問に感じるくらいです…。

ngIf で else 節をつくりたいときの一般的な書き方がこちら。

<div *ngIf="condition; else elseBlock">
  🙆
</div>

<ng-template #elseBlock>
  🙅
</ng-template>

if に対して直接連動したものが必ずしもその直後にあるわけではない(ng-template はテンプレートファイル内のどこに定義してもよい)うえに、if 節のほうは特定の HTML タグでマークアップしているのに対し else 節の方は ng-template で囲むという不一致感も気になります。

実際、false の場合では div タグはレンダリングされません。中身のコンテンツだけを切り替えたいユースケースが最もよくありそうなはずなのに、これでは少し感覚とずれているのではないでしょうか。

こちらは ngFor のよくある書き方。

<div *ngFor="let coin of coins; trackBy: trackById">
  {{ coin.name }}
</div>

一般的なフロントエンドフレームワークにおける繰り返し構文では、主にパフォーマンスやデータ整合性の観点で、各要素にユニークなキーを振ることが推奨されています。ところがこの構文ではキーの付与作業がテンプレート内で完結せず、わざわざコンポーネントクラス側にひとつメソッドを追加しなければいけないのです。ほとんどのケースで id を返すだけのようなものになるはずなのに、これは相当煩わしいですよね。

というように、単純なことをやりたいはずなのにずいぶん複雑さがあったよねというのがこれまでの状況でした。おさらいもできたところで、新しい構文を見てみましょう。

まずは if 文です。

@if (a > b) {
  {{ a }} is greater than {{ b }}
} @else if (b > a) {
  {{ a }} is less than {{ b }}
} @else {
  {{ a }} is equal to {{ b }}
}

@ がなければ大半のプログラミング言語と全く同じ構文といえそうです。非常にわかりやすいし、else や else-if をそのままつなげて書けるのも素晴らしいですね。

ただし、私は最初これを見たとき新しい悩みも感じました。HTML というテンプレートにおいて、各要素下のルートにタグ以外のものが来るという違和感です。テンプレートを読むときは行の一番左端に < が来ることを期待していると思うのですが、{} によってインデントもひとつ内側にずれることになります。

しかしこれは従来のサーバー側 HTML 生成タイプのフレームワークではよくあるパターンで、例えば Django や Ruby (ERB テンプレート) などにも見られます。むしろ余計な文字もなくすっきりとした構文で、かつ慣れ親しんだ波括弧でブロック定義をできるわけですし、他の多くのものより優れているのかもしれません。実際、何度か書いたらすぐ慣れました。

ちなみに、prettier はもうこの新しい構文に対するフォーマットをサポートしています

for 文も見てみましょう。

@for (item of items; track item.id) {
  {{ item.name }}
}

if 構文と同様、波括弧による明示的なスコープが導入されたのにくわえ、track の後ろに続くユニークキーの定義がインラインで行えるようになったのが大きいです。これ以上必要なものも不要なものもない、かなりよい状態ではないでしょうか。

ちなみにインデックスがほしい場合はこのように書きます:

@for (item of items; track item.id) {
  {{ $index + 1 }}: {{ item.name }}
}

$index という名前でデフォルトで公開されているため、構文に何も追記しなくてもインデックスが使えます。便利ですね。名前を変えたい場合は ; let i = $index などを追加してください。

しかもなんと、イテレーブルなオブジェクトの長さが 0 だったとき、つまりリスト表示したいものがなにもないときの専用の構文までサポートしてくれました。

@for (item of items; track item.name) {
  <li> {{ item.name }} </li>
} @empty {
  <li> There are no items. </li>
}

たいていのケースにおいてリストが空のときのケアをしなければならないでしょうから、それが組み込みの機能によってカバーされるのはとても意義があると思います。積極的に使っていきたいです。

この 2 つは実際にたくさん使い始めていますが、かなり好感触です!Angular 全体の印象がよくなったような気も…?

その他、@switch という switch 構文も新登場していて、else-if が長くなるようなパターンでは使用を検討してもいいと思います。

Vite のネイティブ対応

昨今のフロントエンド開発ではビルドツールに Vite を使うのがかなり定着してきました。主要なフレームワーク/メタフレームワークがネイティブサポートするようになってきたのが大きいです。

Angular も無事この流れに乗ってくれたということで、「正式に Vite に対応したよ!」というのがバージョン 17 のウリのひとつになっています。「正式に」とは何かというと、実は Angular 16 の時点で Vite 対応はプレビュー版として既にローンチされてはいたのです。

Angular 16 リリース時のブログ:
https://blog.angular.io/angular-v16-is-here-4d7a28ec680d

このときの説明では、ng serve 時にのみ Vite が使われ、ng build 時は esbuild が直接使われているという状況だったようです。つまり dev server や HMR の恩恵は既に得られていたわけですね(angular.json を手動で変更する必要はありました)。今回のバージョン 17 ではこれらのデベロッパープレビューが外れ、ネイティブサポートされたという理解をしています。

ちなみになぜ本番ビルド時には esbuild が直接使われるような挙動になっているかというと、これはおそらく Angular 独自のコンパイル過程(主にテンプレート内の構文のパース)が必要であるからだと思います。

その証拠になるかわかりませんが、Vite の設定ファイル(vite.config.ts)はバージョン 17 の Angular にも存在しておらず、ビルドに関わる設定は相変わらず angular.json を使うしかない状況です。Vite に慣れている身からするとここがあともう一歩という感じなのですが、これはしばらく難しいのかもしれません。

Vite で開発できるようになったことによるメリットはここでは割愛しますが、まだ使ったことがない方は一刻も早く試されることをおすすめします!ビルドとホットリロードの速度が桁違いに速く、私は初めて使ったとき「あれ、思ったより時間かかるな…」などと思っていたらもうとっくにビルドが終了していたらしいことに気づいていなかったという事件があったくらいです。

リアクティブなデータを扱うための Signals API

Signals もバージョン 16 の段階で experimental でしたが、17 から本採用という形です。リアクティブなデータを扱うための手法のひとつで、パフォーマンス向上と脱 Zone.js が主眼に置かれているようです。

まず、現在の Angular でもデータの変更にはリアルタイムでレンダリングが反応することは明らかです:

@Component({ … })
class MyComponent {
  data = "Hi"

  changeData() {
    this.data = "Hello!"
  }
}
<div>{{ data }}</div>

<button (click)="changeData()">Click me!</button>

このボタンをクリックすれば間違いなくブラウザ上でも Hi! から Hello! になるはずですね。

しかしこれはコンポーネントの ChangeDetection が Default であるからこそ Angular はその変更をキャッチして反応できているということです。すなわち、本来必要ではないシーンでも変更を検出しようと躍起になっており、かなり無駄が発生しているわけですね。

ここに Angular のパフォーマンス上のボトルネックがあるらしく、要は ChangeDetection を OnPush にした状態で Signals API を併用するとレベルアップできるよということのようです。今回は小規模な API だけがいきなり登場したので「???」となってしまいますが、将来的な大きな変更のための第一歩と捉えると少し理解できそうです。

基本的な使い方は以下のようになります:

import { signal } from "@angular/core"

@Component({ … })
class MyComponent {
  data = signal("Hi!")

  myMethod() {
    // 読み取り
    console.log(this.data())

    // 更新
    this.data.set("Hello!")
  }
}

読み取り時は getter のように関数呼び出しになっている点に注意してください。これは、後述する「他に依存しているリアクティブな要素がないかを検知できる」ためのものです。更新も実際には setter ですね。

(ほかにも類似の概念は Preact や SolidJS などに存在していて、これらに共通するのは「getter で読み出して setter で更新する」ということです)

また、「signal() で宣言したものが更新された場合に自動的に更新されるようにセットしておく」という機能があります。これは従来の Observable の考え方とは大きく違うところです。

例えば以下のような感じ:

@Component(
  selector: "app",
  template: `
    <h3>Counter value {{ counter() }}</h3>
    <h3>10x counter: {{ derivedCounter() }}</h3>
    <button (click)="increment()">Increment</button>`
)
export class AppComponent {
  counter = signal(0)

  derivedCounter = computed(() => {
    return this.counter() * 10
  })

  increment() {
    this.counter.set(this.counter() + 1)
  }
}

computed() という新しいメソッドが登場してきました。これに囲われたコールバック関数は、内部で使用されている signal 値に変更があるたびに自動的に実行され、テンプレートにも反映されます(上記のコードでいうと、ボタンをクリックすると 10x カウンターのほうも勝手に増加していきます)。

この API は Vue の同名メソッド computed() にかなり似ています。内部でリアクティブな要素に依存していることが検知されると、依存している側の値も自動的に更新されて常に新しい値を扱えるようになる、という本当にそのままのものです(console.log() でデバッグログを出していると依存があるとみなされてちゃんと動くのにログを消すと動かなくなる、というやっかいな不具合も有名になりました)。

RxJS を使ったプログラミングがそうであるように、明示的にサブスクライブをするという作業がどこにもない点がポイントです。もちろんこれには賛否両論あると思いますが、私はどちらも好きです。ただし「一度依存があると評価された signal ではない場合に追跡が始まらない」などいくつか落とし穴もあり、初めて使うときは少しハードルがあるかもしれません。

それとやはり気になるのは、Observable をはじめとする RxJS との使い分けですね。Angular は RxJS に大々的に依存することでその地位を確立してきましたし、もはやアイデンティティであるとさえ言えそうですが、ここに来て新しいものが登場するとこちらも混乱してしまいそうです。

いまありそうな指標としては、Observable は非同期的な操作で使用し、Signals は同期的な操作で使用するというもの。

Observable は Promise との高い互換性があるので、それを活かす意味でもこれまでフェッチしたりファイルを読み込んだりしていた操作はそのまま RxJS で賄えそうです。反対に、「プロパティで受け取った値に応じてレンダリング内容を切り替える」のような普段よく記述する UI に関する操作などでは Signals API が台頭してきそう、と考えています。

とはいっても、私もいまのところ Signals をいつ使うのが適切なのかは掴めておらず、周辺 API がもっと成熟してくるまではしばらく様子見になる気がしています。もしかしたらそう遠くないうちに、RxJS ではなく Signals API が Angular におけるリアクティブプログラミングの中心になる日が来るかもしれませんね。

inject メソッドによる依存性注入

これはバージョン 17 よりも前から存在していた機能ですが、新しく Angular に慣れていく意味も含めて今回以降この書き方へ統一しようと思って導入したものです。

従来、DI するときはよくあるコンストラクタのパターンが使われてきました:

@Component({ … })
class CoinComponent {
  constructor(private apiService: ApiService) {}
}

私がこの書き方に感じていた問題は以下です:

  • コンストラクタの引数の長さによって、フォーマッターが 1 行に含めるのか各行ごとにわけるのか変わる
  • 1 行状態だとカーソル移動がしづらくて追記や変更が面倒くさい
  • そもそも TS のパラメータプロパティ記法がわかりづらい

3 つめはもはや Angular に閉じた話ではないですが、とにかくこの書き方をどうしても好きになれなかったのでした。

inject メソッドを使うとどうなるでしょうか。

import { inject } from "@angular/core"

@Component({ … })
class CoinComponent {
  private apiService = inject(ApiService)
}

絶対にこちらのほうがわかりやすくないでしょうか…?

おそらくパフォーマンス的な差もないと思われますが、私はこれからは(プロジェクトの慣習を乱さない限り)inject メソッドのシンタックスを使っていくことにしています!

サブスクライブ解除を ngOnDestroy メソッドなしで定義できる DestroyRef

この機能は Angular 16 からのもので、前述の inject() のように「小さいけど便利なもの」のうちのひとつ。おすすめです。

コンポーネントの初期化フェーズにおいて Observable をサブスクライブしデータを取得、そのコンポーネントが破棄されるときにサブスクライブも解除する、というのは Angular でよくあるシナリオです。

ところが頻出パターンであるのにも関わらず、これを実現するためにはあれこれインポートしてライフサイクルメソッドも追加したりなどなど、「そこはフレームワークでいい感じにやってよ」と少し思ってしまうくらいの面倒さがあります。

だいたいこういう感じですよね:

import { Component, OnDestroy } from "@angular/core"
import { Subject } from "rxjs"
import { takeUntil } from "rxjs/operators"

@Comonent({ ... })
export class MyComponent implements OnDestroy {
  private onDestroyed$ = new Subject<void>()
  
  data$ = http.get("...").pipe(takeUntil(this.onDestroyed$)).subscribe(...)

  ngOnDestroy() {
    this.onDestroyed$.next()
  }
}

しかしこれが本アップデートによって、全てやってくれるところまではいかなかったものの、かなりシンプルな書き味になりました:

import { DestroyRef, inject } from "@angular/core"
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"

@Comonent({ ... })
export class MyComponent {
  private detroyRef = inject(DestroyRef)
  
  data$ = http.get("...").pipe(takeUntilDestroyed(this.detroyRef)).subscribe(...)
}

DestroyReftakeUntilDestroyed というのが新しい要素です。前者は DI でフィールドとして持ち、新しいオペレータである後者の引数として使います。

「インポートするものはあまり減ってなくない?」というのは私も改めて書いてちょっと思ってしまいました。…が、ngOnDestroy() の実装がなくなったことで、エディタを上下に動き回る必要がなくなったのが大きいのではと考えています。ngOnDestroy() はクラスの最下部に実装することが慣習として多いと思うので。

また、pipe を読んでいるときに「このサブスクライブがいつ破棄されうるのか」が確実に目に入ってくるのもよい点ではないでしょうか。旧式の書き方だと、this.onDestroyed$.next() がいつどこで実行されているかは未知数です。

これだけの機能ですが、だからこそ気軽に新規導入していきたくなりますね!

シンプルな状態管理ライブラリ:Akita

2024/4/15 追記:

本ライブラリは開発停止となり、似た API を継承した別ライブラリへと誘導されるようになりました。リポジトリには「DON'T USE IT」とはっきり記載があるため、セキュリティ的観点も含め利用は控えたほうがよさそうです。

新ライブラリは Elf といいます。私はまだ実際に使ってはいないのですが、設計思想やインターフェースはある程度 Akita を踏襲しているようには思えます。…が、後述する Akita を気に入ったポイントまで受け継がれているかはまだわからず、今後また紹介できるくらいにまで利用した際には再度内容も更新します!

=====

もはや Angular のアップデートとは何も関係がないのですが、状態管理ライブラリも新しいものを使い始めた結果ちゃんと気に入ったのでついでにご紹介いたします。

Akita という犬のアイコンがかわいいパッケージ。名前の通り秋田犬がモチーフらしいです。

d1409f47-c04b-4d97-9c83-02790c6db4c1-1920x1169r.png

わざわざ新しく探索を始めたモチベーションは以下の 2 つがメインでした:

  • NgRx がわかりにくすぎる(主観です…)
  • 状態の永続化(localStorage との接続)をひとつのライブラリ内でプラグインなしで完結させたい

これらを中心に据えつつ、API も理解しやすくドキュメントもしっかりしていそうなものでフィルタリングした結果、 Akita になりました。なんとなく動物系でかわいい方向に釣られた気もしなくもないです…(どうでもいいですがこのアイコン Metamask にとても似ていますよね)。

ストアの定義はこのような感じで、

import { Store, StoreConfig } from "@datorama/akita"

export interface SessionState {
  token: string
  name: string
}

export function createInitialState(): SessionState {
  return {
    token: "",
    name: "",
  };
}

@StoreConfig({ name: "Session" })
export class SessionStore extends Store<SessionState> {
  constructor() {
    super(createInitialState())
  }
}

このひとつのストアに対して

  • クエリ(読み取り)
  • サービス(書き込み)

の 2 つを定義することで操作を行います。このあたりまではわりと一般的ですが、action や reducer といった概念は登場せず、シンプルに読み取りと書き込みが存在するだけという意味で全貌はほぼこれで以上です。

session.query.ts

import { Query } from "@datorama/akita"
import { SessionState, SessionStore } from "./session.store"

export class SessionQuery extends Query<SessionState> {  
  protected store = inject(SessionStore)
  
  allState$ = this.select()
  isLoggedIn$ = this.select(state => !!state.token)
  selectName$ = this.select("name")

  constructor(store: SessionStore) {
    super(store)
  }
}

session.service.ts

import { SessionStore } from "./session.store"
 
export class SessionService {
  private sessionStore = inject(SessionStore)

  updateUserName(newName: string) {
    this.sessionStore.update({ name: newName })
  }  
}

最近は以前からある「ストアの更新は厳粛に行われるべき」という考え方がずいぶんラフになっていて、例えば事前に定義した更新メソッドがなくともストア内のプロパティは自由に変更できるようなタイプも増えてきています。

Akita は API 的にそうなってはいないですが、サービスファイルに下記のようなメソッドをひとつ置いておくだけでそれも十分叶います。いいバランスかと。

update<K extends keyof MyState>(key: K, value: MyState[K]) {
  this.store.update({ [key]: value })
}

また、ローディング管理を専用のプロパティを定義せずとも自動で行ってくれる機能がお気に入りで、下記のようにするとクエリからローディング状態も簡単に取得できるようになります。

サービス側:

import { SessionStore } from "./session.store"
 
export class SessionService {
  private sessionStore = inject(SessionStore)
  private http = inject(HttpClient)

  async updateUserName(newName: string) {
    this.sessionStore.setLoading(true) // ここ
    await this.http(...).toPromise()
    this.sessionStore.update({ name: newName })
    this.sessionStore.setLoading(false) // ここ
  }  
}

クエリ側(もしくは直接コンポーネントからでも OK):

@Component({})
export class LoginComponent {
  private sessionQuery = inject(SessionQuery)

  isLoading$ = this.sessionQuery.selectLoading()
}

service というサフィックスが Angular 側のサービスと重複するのだけ微妙に気になっていて、ほかの名前に変えることももちろん可能ではあるのですが、いいものが思いつかず結局 service で使っています。このあたり、ユーザーが増えるとライブラリとして成熟してくるポイントのひとつになるかもしれません。

ストアを永続化したい場合は、main.ts などで下記のように 1 行書くだけで特定のストアのみを localStorage にリンクできます:

persistState({
  include: ["User", "Coin"],
})

Redux DevTools にも対応しているので、デベロッパーツールで状態の追跡を行ったり時間を遡ったりも可能です。

もし状態管理ライブラリにお困りでしたらぜひご検討ください!

Angular 用のメタフレームワーク:Analog

最後はパブリックリリースを目前に控えた Angular 用のメタフレームワークをご紹介して終わりにしたいと思います。

5b6343be-94ce-4504-839a-0dda9b879cb5-1920x1035r.png
https://analogjs.org

メタフレームワークとはいわゆる React における Next.js、Vue における Nuxt.js のあのポジションのやつのことですね。Angular にも一応 Angular Universal という同種のものがありますが、なんというか少し期待しているものと違う感というのがあり、この「多くの開発者が望んでいそうなやつ」をつくろうとしているのが Analog です。

では「多くの開発者が望んでいそうなもの」とは何かという話になるのですが、最も大きいものは API Routes ではないでしょうか。サーバー側の REST エンドポイントも生成できる機能のことですね。

Angular Universal はあくまでサーバー側でフロントエンド用のコンポーネントないしは HTML をレンダリングするような機能に留まっていて、いわゆる「クライアントとサーバー」と言ったときにサーバーに該当する部分の機能を持たせるのはメインシナリオではありませんでした。

こうなるとサーバーを 2 つ以上管理しないといけなくなり、なんのためにメタフレームワークを使っているのか意義を見失いそうです。

Analog は、このプロジェクトひとつをデプロイするだけでフロントエンドの配信とサーバー側の API 生成とを両立できることになるわけです。

API のルーティングはよくあるファイルベースのもので、ディレクトリ構造も柔軟に設定できるようになっています。イメージは以下のような感じ:

// /server/routes/v1/users/[id].get.ts

import { defineEventHandler, getRouterParam } from "h3"

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id")
  return await fetchUser(id)
})
// /server/routes/v1/users.post.ts

import { defineEventHandler, readBody } from "h3"

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  await createUser(body)
  return { updated: true }
})

私がどうしても Analog を使ってみたかったのも API Routes がほしかったからです。特に個人開発ではリポジトリをひとつにしてなるべくプロジェクトをまとめたいシーンが多いと思います。複雑さへの対処より管理のしやすさを優先したいですよね。Analog のようなプロジェクトが流行れば、Angular もまた十分に市民権を得られるかもしれません!笑

それと、Analog はベータ版のリリース時点から Vite 対応であることを謳っていました。しかも、Angular 17 の Vite 対応のところで「Angular では Vite の設定を直接制御することはできない」旨を書きましたが、Analog では一応 vite.config.ts が存在しており、もう一歩踏み込んだ設定ができそうにはなっています。

ただし、いま私がさわった範囲でいえば vite.config.ts がどのくらいワークしているのかいまいちわからないというのと、Analog 自体に関する設定ファイルなども存在しておらず少し散らかった印象はあります。とはいえまだリリース前段階ですし、これから集中的に改善されていくのかもしれません。

ちなみにこのプロジェクトは GitHub が選ぶ今後最も伸びるであろう新進気鋭プロジェクト xx 選 のようなやつにも選ばれていて、成長が見込めるリポジトリとしてかなりのお墨付きがあります。

まだまだ本番採用は遠そうですが、リリースされたらぜひみんなで応援したいですね!

おわりに

Angular 17 の新機能をはじめとして、自分としても快適に開発できる環境をだいぶ整えられてきたと思います。これからの新規プロジェクトではどれも積極的に使っていきたいところ。

これからも Angular コミュニティをみんなで盛り上げていきましょう!

お読みいただきありがとうございました。

Author image
About kei
expand_less