ここ最近の Angular
リリースで、変更検知の改善とシンプルなシンタックスを提供する Signals
や Control flow
が導入されました。早く使ってみたくなりますね。とはいえ手元にはそれ以前のバージョンのプロジェクトが多く、ようやく最近そのひとつをv17にアップデートしたところです。
当記事ではアップデート作業の順を追いながら詰まったところなどを紹介します。サードパーティのパッケージの作業も含みますが、作業の全体像の参考になればと思います。
Before & After
アップデートによる主なバージョン変更は以下のとおりです。
パッケージ | Before | After |
---|---|---|
@angular/core |
v15.2 | v17.3 |
@angular/cdk |
v14.2 | v17.3 |
typescript |
v4.8 | v5.4 |
msw |
v1.3 | v2.2 |
@testing-library/angular |
v14.3 | v15.2 |
マイグレーションガイドをチェック
最初にマイグレーションガイドで内容をチェックします。
https://update.angular.io/?locale=ja-JP&v=15.0-17.0
ガイドを見ると Node.js
や TypeScript
のバージョン、手作業で必要なマイグレーションが分かります。事前にさらっと読んで準備できるところはリファクタリングしておくとアップデート作業が楽になります。
ng update
コマンドを実行
Angular
関連パッケージのアップデートをチェックします。
$ ng update
We analyzed your package.json, there are some packages to update:
Name Version Command to update
---------------------------------------------------------------------
@angular-eslint/schematics 15.2.1 -> 17.3.0 ng update @angular-eslint/schematics
@angular/cdk 14.2.7 -> 15.2.9 ng update @angular/cdk@15
@angular/cli 15.2.10 -> 16.2.9 ng update @angular/cli@16
表示されたパッケージをアップデートします。
$ ng update \
@angular-eslint/schematics \
@angular/cdk@15 \
@angular/cli@16
エラーが出力されました。依存にひっかかるパッケージがあるようです。🙄
Collecting installed dependencies...
Fetching dependency metadata from registry...
Package "@testing-library/angular" has an incompatible peer dependency to "@angular/common" (requires ">= 17.0.0", would install "16.2.12")
✖ Migration failed: Incompatible peer dependencies found.
このようなパッケージはアップデート後の Angular
と共存させるために、適切なバージョンにアップデートする必要があります。
プロジェクトが依存するパッケージが多くなると依存関係が複雑になり、一度に依存解決できないことがあります。そんな時は --force
オプションで主要なものを先に強制アップデートし、すんなり npm install
できるようになるまでバージョンの調整を繰り返しています。
コードの差分をチェック
ng update
コマンドの実行が終わったらコードに加えられた更新内容をチェックします。今回は polyfills.ts
の読み込みが廃止されたようです。
// angular.json
"projects": {
"app": {
"architect": {
"build": {
"options": {
- "polyfills": "src/polyfills.ts",
+ "polyfills": [ "zone.js" ],
polyfills.ts
にはオリジナルのコードを加えておらずプロジェクト内からの参照箇所もなかったので、ファイルを削除しました。
マイグレーションコマンドを実行
今回のアップデートは、スタンドアロンコンポーネントへの移行が大きなモチベーションでした。
600個を超えるコンポーネントとモジュールの参照関係が部分的な移行を難しくしていたため、コマンドを使って一括でコンポーネント群を書き換えました。
$ ng generate @angular/core:standalone
シンタックス変更をともなうマイグレーションは、このように @angular/core
のスキーマティクスが用意されているものがあります。アップデート作業と同時でもよし、別作業でもよし、任意のタイミングで実行できるのが嬉しいですね。
Lintを実行
スタンドアロンコンポーネントへの一括変換コマンドはLintを実行しません。git
コマンドのフックやCIでLintを走らせている場合、そのタイミングで大量の差分が発生します。手動でLintを走らせて差分だけでコミットまたはプルリクエストすると、後から調整を入れたコードが見やすくなるのでそうしています。
$ ng lint --fix
@deprecated
に対応
Lintを走らせた時に、廃止予定の機能が使用されていないかチェックします(検出するかどうかはLintの設定によります)。
今回は ComponentFactoryResolver
が @deprecated
でマークされるようになりました。
// Before
import { ComponentFactoryResolver, ApplicationRef } from "@angular/core";
export class PopupMessageService {
private readonly resolver: inject(ComponentFactoryResolver); // 🙄 @deprecated
private readonly appRef: inject(ApplicationRef);
attach() {
const factory = this.resolver.resolveComponentFactory(PopupMessageComponent);
const ref = factory.create(this.injector);
document.body.appendChild(ref.location.nativeElement);
this.appRef.attachView(ref.hostView);
}
エディターでサジェストされた新しいシンタックスは ViewContainerRef.createComponent
ですが、使用箇所はポップアップ表示サービスでコンテナーを所有していません。代替として @angular/cdk
の Overlay
を使ったコードに書き換えました。
// After
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
import { ComponentPortal } from "@angular/cdk/portal";
export class PopupMessageService {
private readonly overlay = inject(Overlay); // 😊
attach() {
const ref = this.overlay.create();
const component = new ComponentPortal(PopupMessageComponent);
ref.attach(component);
}
}
サードパーティパッケージのアップデート
今回アップデートしたプロジェクトでは、HTTP通信をモックする msw
を使用しています。 TypeScript
のバージョンアップにより1系から2系へのアップデートが必須で、まあまあ大きな破壊的変更が含まれました。
https://mswjs.io/docs/migrations/1.x-to-2.x
破壊的変更のひとつは、モック対象が XMLHttpRequest
から Fetch API
に変わったことです。この変更はアプリケーションの HTTP
リクエストにも影響します。
XMLHttpRequest
にこだわる理由はなかったのでアプリケーション側で HTTP
リクエストを変更することにしました。Angular
では withFetch()
オプションを指定するだけで XMLHttpRequest
から Fetch API
にサクッと切り替えできます。
// main.ts
+ import { provideHttpClient, withFetch } from "@angular/common/http";
bootstrapApplication(AppComponent, {
providers: [
+ provideHttpClient(withFetch()),
...
もうひとつの破壊的変更は、コールバック方式が廃止され msw
のシンタックスが大きく変わったことです。エディターの置換機能など使いつつ、すべてのモックハンドラーを書き換えました。
// Before
import { rest } from "msw";
rest.get("/resource", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.delay(1000),
ctx.json({ message: "Hello" })
);
})
// After
import { delay, http, HttpResponse } from "msw";
http.get("/resource", async ({ request, params }) => {
await delay(1000);
return new HttpResponse({ message: "Hello" }, { status: 200 });
});
テストを実行
ng lint
や ng serve
がエラーや警告を検出しなくなったら、テストを実行します。
$ ng test
# ● TestComponent › Should be rendered.
# Unexpected "ChildComponent" found in the "declarations" array of the "TestBed.configureTestingModule" call,
# "ChildComponent" is marked as standalone and can't be declared in any NgModule -
# did you intend to import it instead (by adding it to the "imports" array)?
#
# 6 | describe("TestComponent", () => {
# > 7 | await render(TestComponent, {
# 8 | declarations: [ChildComponent],
# at node_modules/@angular/core/fesm2022/testing.mjs:761:23
# at Array.forEach (<anonymous>)
# at assertNoStandaloneComponents (node_modules/@angular/core/fesm2022/testing.mjs:757:11)
# at TestBedCompiler.configureTestingModule (node_modules/@angular/core/fesm2022/testing.mjs:832:13)
# at _TestBedImpl.configureTestingModule (node_modules/@angular/core/fesm2022/testing.mjs:1899:23)
今回はアップデート作業と同時にスタンドアロンコンポーネントにマイグレーションしたことが影響して、大量にテストが落ちました。
テストコードのスタンドアロンコンポーネント対応
スタンドアロンコンポーネントへの一括変換コマンドの実行後、 テストコードは何も更新されていませんでした。これらのテストは手作業で書き換えていきました。
テスト対象がスタンドアロンコンポーネントなら、依存をひとつひとつ指定する必要はありません。ずらりと並んだコンポーネントやモジュール群の imports
や declarations
を一気に削除できるのは気持ちが良いですね。
// Before
import { render } from "@testing-library/angular";
import { MockComponent, MockPipe } from "ng-mocks";
import { CommonModule } from "@angular/common";
import { FormsModule } from "@angular/forms";
test("Should be rendered", async () => {
return await render(TestComponent, {
declarations: [ // 🙄 依存が多い
MockPipe(FooPipe),
MockComponent(FooComponent),
],
imports: [ // 🙄 依存が多い
CommonModule,
FormsModule,
],
});
}
// After
import { render } from "@testing-library/angular";
test("Should be rendered", async () => {
return await render(TestComponent, {
imports: [], // 😊
declarations: [], // 😊
});
});
*ngIf
や *ngFor
を使用しているコンポーネントは一括変換コマンドによって NgIf
NgFor
のモジュール参照が自動的に追加されます。テスト用のフェイクコンポーネントは更新されなかったので手作業で書き換えました。
// After
import { render } from "@testing-library/angular";
import { NgIf, NgFor } from "@angular/common";
test("Should be rendered", async () => {
return await render(FakeComponent);
}
@Component({
standalone: true, // 👈
imports: [NgIf, NgFor], // 👈
template: `
<p *ngIf="...">
<p *ngFor="...">
`
})
class FakeComponent {}
テストコードの @deprecated
対応
Router
に依存するコンポーネントのテストで RouterTestingModule
が @deprecated
でマークされるようになりました。
// Before
import { RouterTestingModule } from "@angular/router/testing";
import { render } from "@testing-library/angular";
test("Should be rendered", async () => {
return await render(TestComponent, {
imports: [
RouterTestingModule, // 🙄 @deprecated
],
});
}
単純な依存解決なら provideRouter
を、ページ遷移をテストするなら RouterTestingHarness
を使うのが良さそうです。
// After
import { provideRouter } from "@angular/router";
import { render } from "@testing-library/angular";
test("Should be rendered", async () => {
return await render(TestComponent, {
providers: [
provideRouter([]), // 😊
],
});
}
テストコードのパッケージアップデート対応
今回アップデートしたプロジェクトでは、ツールチップ表示に helipopper というパッケージを使用しています。このパッケージは Tippy.js に依存していて、テスト実行時に TypeError: (0 , import_tippy.default) is not a function
エラーを吐くようになってしまいました。
// Before
import { render } from "@testing-library/angular";
import { MockDirective } from "ng-mocks";
import { TippyDirective } from "@ngneat/helipopper";
test("Should be rendered", async () => {
return await render(TestComponent, {
declarations: [
MockDirective(TippyDirective),
],
});
}
// TypeError: (0 , import_tippy.default) is not a function
// at node_modules/@ngneat/helipopper/fesm2020/ngneat-helipopper.mjs:240:29
// at _ZoneDelegate.Object.<anonymous>._ZoneDelegate.invoke (node_modules/zone.js/bundles/zone.umd.js:411:30)
Angular Testing Library の render()
で componentImports
キーを指定すると、スタンドアロンコンポーネントの依存をすべて外から指定できます。これを使って Tippy
モジュールをモックで差し替えることで対応しました。
// After
import { render } from "@testing-library/angular";
import { MockModule } from "ng-mocks";
import { TippyModule } from "@ngneat/helipopper";
test("Should be rendered", async () => {
return await render(TestComponent, {
componentImports: [ // 😊
MockModule(TippyModule),
ChildComponent1,
ChildComponent2,
],
});
}
気持ちよく削除したテストコードのモジュール参照を componentImports
配下に戻すことになりますが、他の解決策が見つからずワークアラウンドでテストを通すことを優先しました。そもそものエラーメッセージについては GitHub Discussion
で議論されているため、新しいバージョンでの解決を期待しています。
https://github.com/ngneat/helipopper/discussions/150
ビルドを実行
Angular
アップデートでビルド時間とファイルサイズがどのように変わるか、気になるところですよね。そこでアップデート前後のプロダクションビルドを比較してみました。
# Before(v15)
$ ng build
Initial Chunk Files | Names | Raw Size | Estimated Transfer Size
main.js | main | 1.40 MB | 306.19 kB
styles.css | styles | 106.96 kB | 11.30 kB
polyfills.js | polyfills | 36.30 kB | 11.33 kB
runtime.js | runtime | 2.68 kB | 1.25 kB
| Initial Total | 1.54 MB | 330.06 kB
Lazy Chunk Files | Names | Raw Size | Estimated Transfer Size
965.js | foo-module | 157.42 kB | 34.50 kB
7.js | bar-module | 86.10 kB | 15.88 kB
Build at: 0000-00-00T00:00:00.000Z - Hash: 1234abcd - Time: 17984ms # 🙄
# After(v17)
$ ng build
Initial chunk files | Names | Raw size | Estimated transfer size
main.js | main | 1.41 MB | 309.82 kB
styles.css | styles | 106.80 kB | 11.25 kB
polyfills.js | polyfills | 33.36 kB | 10.74 kB
runtime.js | runtime | 2.68 kB | 1.24 kB
| Initial total | 1.55 MB | 333.04 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
315.js | foo-module | 157.63 kB | 34.50 kB
44.js | bar-module | 89.67 kB | 16.60 kB
Build at: 0000-00-00T00:00:00.000Z - Hash: 5678abcd - Time: 8295ms # 😊
ファイルサイズはほとんど変わらず(むしろ微増)していますが、ビルド時間は半分以下に短縮されました。ng serve
も同じ結果で、ファイルサイズはほとんど変わらずビルド時間は半分以下でした。開発環境がサッと立ち上がるので、これは嬉しいですね。
おわりに
他にも TypeScript
のアップデートによる型の書き換えや、サードパーティパッケージのインターフェイス変更などがあり、今回のアップデート作業はそこそこ時間がかかるものでした。
ひととおりのコンパイルエラーに対応しテストがパスするようになった後、アプリケーションコードは(@deprecated
を除き)何も変更せずにすんなり動いたので「え?マイグレーションの変換ミスとかないの?ほんとうに?」と思いました。
Angular
のアップデート作業でしんどいのは、付随してサードパーティのパッケージがバージョンアップすることで起こる、依存問題やコンパイルエラーだと思っています。TypeScript
のバージョンアップにひっぱられた問題が多いので、フロントエンドフレームワークのアップデートあるあるかなぁと思います。新しいバージョンに追随しないまま時間が経過し、やむなく依存を削除して自作コードに書き換えたパッケージも過去いくつかありました。
できるだけシームレスにアップデートできるよう気軽にパッケージ追加せず、そのパッケージが本当に必要なのか、信頼できる開発元なのかを自問していきたいと思います。
ようやくv17の波に乗り Signals
や Control flow
を使うのが楽しみになりました!