AngularJS を Angular に移行する際に必要だった作業

はじめに

こんにちは、ヌーラボの池です。ビジネスチャットツール Typetalk の開発をしています。
さて、先日ビジネスチャットアプリの Typetalk はフロントフレームワークを AngularJS から Angular バージョン2以降(以下、Angular2系 という)に完全移行しました。この記事では AngularJS を Angular2系 に移行する際に必要だった具体的な作業を紹介させていただきます。プロジェクトにかかったリソースやマネジメント関係の内容については別の記事にまとめているので、そちらをご参照ください。

なぜ移行先を Angular2系 にしたか

移行先を Angular2系 に選んだ理由については大きく分けて2つあります。以下の理由からAngular2系 にすることをチームで合意しました。判断したのは2019年ごろです。

理由その1: Angular2系 が公式で AngularJS からの移行をサポートしている

Angular 公式が AngularJS から Angular2系 への移行をサポートしている恩恵が大きかったです。公式のマイグレーションガイドが充実しており、移行のための様々なツールが用意されています。Angular2系 は AngularJS とハイブリッドで実行可能なため、コンポーネント単位で移行し細かい粒度でリリースしていくことができます。他のフレームワークは AngularJS と柔軟に共存させることが難しいため、1回のリリースの変更量が大きくなり不具合のリスクが高くなります。移行作業は数年単位の長期の作業になることは予想できていたため、移行中の新規機能追加、仕様変更にも追従する必要があります。その際に2度手間になりにくいという点でも Angular2系 が優れていました。

理由その2: ライブラリ選定、ビルド設定の容易さ

開発チームにはフロント専門のエンジニアはおらず、フロントの最新のライブラリ事情について詳しく webpack 等のビルド設定に精通した人がいませんでした。他の移行先の候補としては React が挙がっていましたが、当時の React は HTTPクライアントやルーターなどのライブラリは自分で選定しなければならず流行り廃りが激しい時期でした。また、TypeScript 等のwebpack の設定も開発者に委ねられていました。それに比べて Angular2系 はオールイン1で必要なライブラリはほぼ揃っており、Angular CLI によってビルドも容易に行うことができる点が当時のチームメンバーに合っていました。

必要な作業一覧

移行に必要だった作業とその比重です。追って1つ1つ詳細を説明させていただきます。

順番 作業 作業量の割合
1 サーバーサイドテンプレートをやめる 5%
2 コントローラーとテンプレートが一体1になるようにコンポーネント化する 5%
3 $scopeの使用をやめる 10%
4 AngularJS の標準サービス を Angular2系 の標準サービスに置き換える 20%
5 サービスを Angular2系 にする 20%
6 コンポーネントを Angular2系にする 40%
7 ルーターを Angular2系 にする 1%未満

1. サーバーサイドテンプレートをやめる

Angular2系 において、エントリーポイントとなるHTMLファイルはバンドルされた JavaScript ファイルを読み込むだけの静的なHTMLファイルにするのが基本です。サーバーサイドアプリの動的データは Angular アプリケーション内で Ajax 経由で取得します。今回の移行作業ではサーバーサイドが動的データを含んだHTMLファイルをいくつか返していたので、それを AngularJS 内のコンポーネントに作り替える必要がありました。

2. コントローラーとテンプレートが1対1になるようにコンポーネント化する

Angular2系 ではコンポーネントのHTMLテンプレート部分(*.component.html といったファイル)とロジック部分(*.component.ts といったファイル)は1対1に対応していますが、AngularJS は1つのテンプレートファイルに複数のコントローラーを宣言できます。その場合テンプレートファイル側から<div ng-controller="myController">といった形で対応づけられます。これは Angular2系 ではできないため廃止しAngularJS のコンポーネントの形で実装し直す必要があります。

3. $scope の使用をやめる

$scope は AngularJS において、コントローラー側で生成したデータをテンプレートの側で参照する時に使われます。この $scope のデータは親コンポーネントから子コンポーネントへデータを継承させ共有させることもできます。一方 Angular2系 ではこの $scope に対応するものはなく、親コンポーネントと子コンポーネントの間で値を受け渡す場合、子コンポーネントで @Input(), @Output デコレーターによって宣言したメンバ変数を用いて親とデータを共有します。AngularJS でも $scope を使わない Angular2系 に対応するデータ共有の方法が用意されているので、あらかじめその方法でコンポーネントを作り直しておくと後の移行作業が簡単になります。
Angular2系 での書き方が以下だとすると、

@Component({
  selector: 'my-component',
  template: '<button (click)="onClick()">{{ dataA }}</button><p>{{ dataB }}</p>',
})
export class MyComponent {
  @Input() dataA: string; // 親コンポーネントから受け取る値
  @Output() dataAclicked = new EventEmitter<string>(); // 親コンポーネントに伝えるイベント
  dataB = 'dataB'; // パブリックフィールドはテンプレートから参照できる

  onClick(): void {
    this.dataAclicked.emit(this.dataA);
  }
}

AngularJS で対応する書き方は以下のようになります。($scope を使わない書き方)

const myComponent = {
  template: '<button ng-click="$ctrl.onClick()">{{ $ctrl.dataA }}</button><p>{{ $ctrl.dataB }}</p>',
  bindings: {
    dataA: '<', // 親コンポーネントから受け取る値
    dataAClicked: '&' // 親コンポーネントに伝えるイベント
  },
  controller: function() {
    this.dataB = 'dataB'; // この値は '$ctrl.dataB'という形でテンプレートから参照できる

    this.onClick = function() {
      this.dataAClicked({ dataA: this.dataA });
    }
  }
};

4. AngularJS 標準サービスを Angular2系 の標準サービスに置き換える

AngularJS の $http や $timeout といった標準で用意されているサービスを Angular2系 標準のものまたは代替となるライブラリに変える必要があります。Typetalk でも以下のサービスの移行が必要でした。

  • $scope.$broadcast, $scope.$on
    • イベントのブロードキャストのために使用されます。RxJS の Subject を用いたサービスを独自実装し置き換えました。
  • $http
    • HTTPリクエストを行うサービスです。Angular2系 の HttpClient が対応するサービスです。Angular2系 の HttpClient もダウングレードすることでAngularJS から使うことができます。注意点は、$http はリクエストと後続処理が終了した後、データ変更を DOM に反映するため $scope.$apply() を呼び出すことです。今回の移行作業ではHTTPリクエスト処理の最後に $apply() を実行する Interceptor を追加しました。これは AngularJS コンポーネントがなくなった後に消します。
  • $timeout, $interval
    • JS標準の setTimeout, setInterval を使います。注意点としては $timeout, $interval は渡した関数の処理が実行した時のデータ変更を DOM に反映するため、自動的に $scope.$apply() を呼び出すことです。そのため、標準のsetTimeout 使う場合はその後に $scope.apply() を呼び出す必要があります。今回の移行作業では setTimeout をラップしたサービスを独自で作り$apply を呼び出す処理を追加しました。AngularJS のコンポーネントがなくなった後、そのサービスから $apply() の処理を消すようにしました。
  • $element
    • 自身のコンポーネントの要素をコントローラー内で取得するために使用するものです。Angular2系 では ElementRef が対応します。Angular2系にする際にElementRef を使用すればいいのですが、$element は jQuery オブジェクトなので jQuery のメソッドを使用している場合は標準の JS にする必要があります。
  • $location
    • Angular2系 では Location が対応します。移行中は $location と Angular2系 のLocation を両方同時に機能させる必要があるため、locationShim を使って同期させます。
  • $window, $document
    • 標準の document, window を使います。テストのために DI によって差し替えたい場合は適宜サービスにラップするなどします。$document は jQuery オブジェクトになっているので使用している jQuery のメソッドも標準JS にする必要があります。
  • $q
    • JS標準の Promise を使うようにしました。
  • $translate
    • API がよく似ている Angular2系版のサードパーティライブラリ ngx-translate に置き換えました。
  • $cookies
    • API がよく似ているAngular2系版のサードパーティライブラリ ngx-cookie に置き換えました。

5. サービスを Angular2系にする

アプリケーションで独自に作成している AnglarJS のサービスを Angular2系にしていきます。
Angular2系にしたサービスを AngularJS のコンポーネントでも使えるようにするにはdowngradeInjectable を用います。

6. コンポーネントを Angular2系する

一番比重が高い作業です。まずは他のどのコンポーネントにも依存していない小さいコンポーネントから初めていくことをお勧めします。初めの1つ目のコンポーネントに着手する際は調べることが多く、問題も多く遭遇します。1つコンポーネントを Angular2系したらそれを AngularJS のコンポーネントで使えるようダウングレードし、親の AngularJS のコンポーネントから使用します。この方法は公式の移行ガイドの Using Angular Components from AngularJS Code で説明されています。
また、このやり方とは反対に、親コンポーネントから Angular2系にし AngularJS の子コンポーネントを一時的に Angular2系 で使えるようにアップグレードする方法もあります(公式ガイドの ”Using AngularJS Component Directives from Angular Code”)。しかし、この方法は追加のボイラープレートが多いので余分な移行コスト・コードが生まれます。”差し込みで新規機能を Angular2系 で開発したいが AngularJS のコンポーネントも流用したい”といった場合のみこの方法を使うと良いでしょう。
また、コンポーネントの移行作業はその依存関係によって着手する順番を把握するのが難しいという問題があります。その問題については以下の記事に解決法を示しているのでそちらをご参照ください。

7. ルーターを Angular2系にする

AngularJS のルーティングを Angular2系のものに置き換えます。この際、全てのサイト内リンクの href 属性部分を routerLink ディレクティブに変える必要があります。注意すべき点は、Angular2系のルーターはルートのパラメータだけ変更された場合、例えば /foo/:id といったルートがあった時の ”/foo/1” から “/foo/2” への遷移の場合、コンポーネントが再利用されることです。その際、コンストラクタや ngOnInit() 等の初期化系の処理が呼ばれません。ActivatedRoute のparams (Observable型)を監視してルートパラメータの変更を反応する実装に変える必要があります。ただし、このデフォルトの再利用の動作は RouteReuseStorategy を独自の実装に差し替えて変更することもできます。

その他苦労したこと

1. $scope のコントローラー間の共有が切り離されることによるバグ

$scope のデータはしばしばコントローラーの間で共有されています。これは一目ではわかりづらく、バグの原因になることが多かったです。AngularJS のコードを Angular2系 に変える前段階の作業として AngularJS をコンポーネントスタイルにしていく際、$scope 値は基本的に親コントローラーと切り離されます。そのタイミングでバグがよく発生しました。コンポーネント化する際は $scope の値を親と共有していないか注意する必要がありました。

2. CSS の影響範囲が変わることによる予期せぬスタイル崩れ

Angular2系 では CSS はコンポーネントごとにカプセル化されますが AngularJS ではその機能はないので CSS は全てグローバルに適用されます。なので AngularJS を使用していた頃は BEM記法 を用いてコンポーネントごとにプレフィックスつけて適用範囲を制限していました。当初はこの BEM記法 を徹底しているのでスタイルの崩れは起きないと思っていましたが、実際は予期せぬスタイル崩れが多々おきました。あるコンポーネントの一部をさらにコンポーネント化した際に CSS を移し忘れていたなどが原因です。

3. zone.js によるパフォーマンスへの影響

Angular2系 は変更検知のために zone.js をつかって、全てのイベントを監視しています。Angular2系 と AngularJS をハイブリッド構成で動かし始めた当初、両方を動かすオーバーヘッドが大きかったのかパフォーマンスの問題が発生しました。具体的にはテキストエリアに文字を入力する時にアプリが重くなりました。なので初めにzone.js のイベント検知を無効化し、徐々に Angular2系 で検知が必要なイベントをイベントごとに有効化することで回避しました。(参考: zone.js のセッティング)

4. 属性ディレクティブの移行

コンポーネントのようにHTMLタグ名として使用するのではなく、タグの属性として使用するタイプのディレクティブは移行が難しかったです。なぜなら、Angular2系 のディレクティブはダウングレードして AngularJS から使用することはできないためです。そのため、以下のどちらかにする必要がありました。

  • AngularJS版 と Angular2系版 のディレクティブを両方用意しておき、AngularJS からの利用がなくなった時に AngularJS の方を削除する
  • ディレクティブを削除して使用しているコンポーネントに処理を移す

5. jQuery をやめる

AngularJS は jQuery を使用しており、$element など所々で jQuery オブジェクトが登場します。Angular2系 は jQuery 非依存で、チームとしても jQuery はやめていきたいという方針があったので 標準JS に書き換える作業が発生しました。

6. 値のミューテーションによる動作不良

Angular2系 の変更検知は基本的にオブジェクトの参照ごと変更されないと動かないです。AngularJS 時代のコードは配列やオブジェクトの変更はミューテーションで行っていることが多かったためよく動作不良を起こしました。

7. 型定義

AngularJS 時代は JavaScript だったコードを Angular2系 では TypeScript に変えていく必要があります。その際、今まで扱ってきたオブジェクトを全て型定義し直す必要がありました。AngularJS 時代のコードはオブジェクトに動的にプロパティを生やすようなコードが多く型が予測しにくかったため、型定義するたびにコードを注意深く読む時間が必要でした。

8. Angular2系 のバージョンアップ

移行作業中にも Angular2系 はどんどんメジャーバージョンアップしていきます。Angular2系 のバージョンアップは予想外に何かと問題が発生しました。アプリケーション自体のコードの変更が必要になることはなかったですが、テストの実行の仕方、ライブラリの依存関係、ビルド周りの設定方法などメタな部分が変わることでエラーがよく発生しました。

最後に

移行作業で出くわすエラーは、ネットでも情報が少なく解決に時間を要するものが多いです。もし AngularJS からの移行をされている方がいましたら、この記事が助けになれれば幸いです。

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる