🦔

ビルドしない Angular アプリ開発

2023/11/27に公開

ビルド不要の Angular アプリケーションの PoC を作成しました。

(追記) 改良した続編を書きました。

デモ

コード

2023年時点ではブラウザでデコレータ構文が使えず、その対策をしていること以外は通常の Angular の書き方とほぼ変わらないコードになっています。

解説

インポートマップ

build-free-angular-17-app.html
<script type="importmap">
{
  "imports": {
    "@angular/common": "https://esm.sh/v135/@angular/common@17.0.3?dev",
    "@angular/common/": "https://esm.sh/v135/@angular/common@17.0.3&dev/"
  }
}
</script>

通常、ブラウザ上で import 文を用いる場合は読み込み元はフルパスを書く必要があります。これを簡潔に書けるようにする仕組みがインポートマップです。

// フルパスを指定した場合
import { TitleCasePipe } from "https://esm.sh/v135/@angular/common@17.0.3?dev";

// インポートマップを設定した場合
import { TitleCasePipe } from "@angular/common";

スラッシュ / で終わる指定はスラッシュまでの文字列が置き換わります。

main.js
// フルパスを指定した場合
import { HttpClient } from "https://esm.sh/v135/@angular/common@17.0.3&dev/http";

// インポートマップを設定した場合
import { HttpClient } from "@angular/common/http";

URL に含まれる ?dev&dev は指定すると minify や mangle がされていない読みやすいコードが返ってきます。

JavaScript モジュール

build-free-angular-17-app.html
<script type="module" src="./main.js"></script>

type="module" を指定することで読み込み先の JavaScript コードをモジュールとして読み込むことができます。主に import 文やトップレベル await といった構文を使用できるようになります。

@ts-check

main.js
// @ts-check

TypeScript の型チェックを JavaScript で使用することができるようになります。

@angular/compilerzone.js

main.js
import "@angular/compiler";
import "zone.js";

無いと動作しないようです。

`@angular/compiler` が無い場合のエラー
Uncaught Error: The injectable '_PlatformLocation' needs to be compiled using the JIT compiler, but '@angular/compiler' is not available.

The injectable is part of a library that has been partially compiled.
However, the Angular Linker has not processed the library such that JIT compilation is used as fallback.

Ideally, the library is processed using the Angular Linker to become fully AOT compiled.
Alternatively, the JIT compiler should be loaded by bootstrapping using '@angular/platform-browser-dynamic' or '@angular/platform-server',
or manually provide the compiler with 'import "@angular/compiler";' before bootstrapping.
`zone.js` が無い場合のエラー
Uncaught Error: NG0908: In this configuration Angular requires Zone.js

デコレータの適用

main.js
class AppService {
  static {
    Injectable({ providedIn: "root" })(this);
  }
}

2023年時点では ECMAScript デコレータ構文をサポートするブラウザが無いため、デコレータ構文を使用せずにデコレータを適用する必要があります。このデモではデコレータをクラス定義の先頭に持ってくるために静的初期化ブロックを使用しています。

静的初期化ブロック内において this はクラス(上の例では AppService)になります。

プライベートクラスメンバー

main.js
class AppService {
  static #urlBase = "https://jsonplaceholder.typicode.com";
  #httpClient = inject(HttpClient);
}

# で始まるメンバーはプライベートメンバーとなり、クラスやインスタンスの外からアクセスできなくなります。

JSDoc によるアクセス修飾子

main.js
class AppService {
  /** @readonly */
  #httpClient = inject(HttpClient);
}

class AppComponent {
  /** @protected @readonly */
  users = toSignal(this.#appService.getUsers());
}

TypeScript のアクセス修飾子である protectedreadonly は JSDoc で指定することができます。たとえば @readonly のプロパティに代入操作するコードを書くとエディタ上でエラーが表示されます。

サンプルデータの取得

main.js
class AppService {
  static #urlBase = "https://jsonplaceholder.typicode.com";
}

HTTP経由でサンプルデータを取得するのに JSONPlaceholder というサービスを使用しています。

inject() 関数

main.js
class AppService {
  #httpClient = inject(HttpClient);
}

コンストラクタフェーズで inject() 関数を呼び出すことで依存オブジェクトを注入します。

クラスインスタンス以外の依存オブジェクトをコンストラクタ引数で注入しようとすると ECMAScript 非標準の引数デコレータが必要になるため、ビルドせずに Angular DI を使用するには inject() 関数のほうが適しています。

スタンドアローンコンポーネントの定義

main.js
class AppComponent {
  static {
    Component({
      standalone: true,
    })(this);
  }
}

コンポーネントを NgModule のように扱うようになります。このコンポーネントを使用するのに NgModule を定義する必要はありません。

importsproviders@Component({}) 内で指定することになります。

スタンドアローンディレクティブの使用

main.js
class AppComponent {
  static {
    Component({
      imports: [TitleCasePipe],
    })(this);
  }
}

@angular/common のディレクティブもスタンドアローンとして定義されているため、 imports で使用することができます。

スタンドアローンコンポーネントの登場以降は NgModule を使わない流れになっているようです。

初期状態で空欄のセレクトボックス

main.js
<select (change)="selectedUserId.set($event.target.value)">
  <option hidden selected></option>
  @for (user of users(); track user.id) {
    <option value="{{user.id}}">
      {{user.username}}: {{user.name}}
    </option>
  }
</select>

<option hidden selected></option> を使用すると初期状態で空欄になります。セレクトボックスを開いても空欄は選択肢に現れません。

フロー制御構文

main.js
@for (user of users(); track user.id) {
  <option value="{{user.id}}">
    &#64;{{user.username}}: {{user.name}}
  </option>
}

Angular 17 時点では実験的な機能です。 ngForngIfngSwitch に置き換わる構文です。

@ (アットマーク)を文字として表示するには &#64; のようにエスケープする必要があります。

Angular Signals シグナルの定義

main.js
class AppComponent {
  users = toSignal(this.#appService.getUsers());
  selectedUserId = signal(undefined);
}

toSignal は RxJS Observable を Angular Signals に変換します。 async パイプと同様、コンポーネントが破棄されると同時に Observable が unsubscribe されるようです。

signal は新しい Angular Signal を生成します。 .set() で値を設定し、関数呼び出しで値を取得します。

Angular Signals エフェクト

main.js
class AppComponent {
  constructor() {
    effect((onCleanup) => {
      const selectedUserId = this.selectedUserId();
      if (selectedUserId !== undefined) {
        const subscription = this.#appService.getPosts(selectedUserId)
          .subscribe((posts) => this.posts.set(posts));
        onCleanup(() => subscription.unsubscribe());
      }
    });
  }
}

effect() のコールバック関数内で読みだされた Angular Signals の値が変わるとコールバック関数が再実行されます。

HttpClient の提供

main.js
await bootstrapApplication(AppComponent, {
  providers: [
    provideHttpClient(),
  ],
});

HttpClientModuleimports に追加する代わりに provideHttpClient()providers に追加することで HttpClient を注入できるようにします。

Discussion