Angular in Productionシリーズの第4部

大規模なAngularアプリケーションに携わった後に気づいたことの一つは、コンポーネントが一夜にして保守困難になることはめったになく、徐々に発生するということです。機能を1つ、また1つと追加し、新しいモーダル、新しいAPI呼び出し、新しい権限チェックを加えていくうちにそうなります。誰かが意図的に1000行のコンポーネントを作ろうとするわけではありません。少しのロジックを追加する方が、新しい抽象化を作るより常に簡単だと感じるために、自然とそうなってしまうのです。最初は問題ないように見え、アプリケーションも動作しています。しかしある時点でファイルを開いてみると、もはやUIコンポーネントを見ているのではなく、単一のクラスに詰め込まれた機能全体を見ていることに気づきます。通常、新しい変更が想定以上に時間がかかるようになるのはその時点からです。


コンポーネントのサイズは症状であって問題そのものではない

以前私が犯していた間違いの一つは、コンポーネントを行数で測ることでした。800行のコンポーネントが自動的に悪いわけではありません。同様に、150行のコンポーネントが自動的に良いわけでもありません。重要なのは責任範囲です。比較的小さなコンポーネントが次のような責任を負っているのを見てきました:

  • データの読み込み
  • データの変換
  • フォームのバリデーション
  • 権限の処理
  • ダイアログのオープン
  • アプリケーション状態の管理
  • 値のフォーマット
  • ルーター変更への反応
  • 複数のAPIの呼び出し

技術的にはそれほど大きくありませんでしたが、アーキテクチャ的にはアプリケーションの異なる複数の部分の作業を行っていました。
今日、コンポーネントを開くたびに、私は通常、そのコンポーネントに明確な責任があるかどうかを自問します。答えが明らかでない場合、それは通常、コンポーネントが間違った方向に成長し始めているサインです。

ビジネスロジックが徐々に支配する

大きくなりすぎたコンポーネントのほとんどは、ビジネスロジックから始まるわけではありません。1つの機能ずつ追加されていきます。このようなものは完全に合理的だと感じられます:

loadUsers() {
    this.userService.getUsers().subscribe(users=> {
        this.users=users;
    });
}

Enter fullscreen mode Exit fullscreen mode

数週間後、そのメソッドは次のように進化します:

loadUsers(){
    this.userService.getUsers().subscribe(users=> {
        this.users=users.filter(user=> user.active);
        this.totalRevenue=users.reduce(
            (sum, user) => sum + user.revenue,
            0
        );
        this.canExport=
            users.length < this.subscription.maxUsers;
        this.chartData = this.buildChart(users);
    });
}

Enter fullscreen mode Exit fullscreen mode

ここには必ずしも間違ったことはありません。新しい要件は追加された時点では理にかなっていました。問題は、コンポーネントが静かに、ユーザーの表示のみに責任を持つことをやめたことです。今ではデータのフィルタリング、ビジネスメトリクスの計算、サブスクリプション制限のチェック、チャートデータの準備を行っています。
プロジェクトが成長するにつれ、これらのメソッドは通常、拡大を続けます。最終的に、一つのロジックを変更することは、周りで起こっているすべてを理解することを意味します。今日、ビジネスルールがコンポーネント内に蓄積されていることに気づいたら、私はそれをよりその目的を反映する場所に移動しようとします。
次のようにする代わりに:

loadUsers() {
    this.userService.getUsers().subscribe(users => {
        this.users = users.filter(user => user.active);
        this.totalRevenue = users.reduce(
            (sum, user) => sum+user.revenue,
            0
        );
    });
}

Enter fullscreen mode Exit fullscreen mode

私は次のような形を好みます:

loadUsers() {
    this.dashboardService
        .getDashboardData()
        .subscribe(data => {
            this.user = data.users;
            this.totalRevenue = data.totalRevenue;
        });
}

Enter fullscreen mode Exit fullscreen mode

コンポーネントがシンプルになるのは、行数が少ないからではありません。ビジネス上の判断を所有しなくなったからです。その仕事は単に情報を表示することです。


コンポーネントはすべてを実装するのではなく、調整すべきである

時間が経つにつれ、私はAngularコンポーネントを調整役のように考えるようになりました。その責任は、アプリケーションの異なる部分を接続することです。すべての部分を自分で実装することではありません。

たとえば、ダッシュボードコンポーネントはデータの要求、子コンポーネントへのデータのパス、ユーザーインタラクションへの反応、ナビゲーションのトリガーを担当するかもしれません。それだけで十分な責任です。また、請求書がどのように計算されるか、権限がどのように評価されるか、レポートがどのように生成されるか、エクスポートがどのようにフォーマットされるかを知る必要はありません。

次のようなメソッドが同じコンポーネント内に共存しているのを見たときはいつでも:

calculateInvoice()
validatePermissions()
buildChartData()
generateStatistics()
formatExport()
sendNotification()

Enter fullscreen mode Exit fullscreen mode

私はすぐに、それらの責任がどこか別の場所に属するかどうかを問いかけます。

目標は「よりクリーンだから」という理由だけでコードをサービスに移動することではありません。目標は、すべてのファイルが1種類の問題を解決する責任を持つようにすることです。これにより、通常、将来の変更がはるかに簡単になります。なぜなら、無関係な機能を壊すことを恐れなくなるからです。


単一の画面は単一のコンポーネントを意味しない

初期に私が抱いていた誤解の一つは、すべてのページが大きなコンポーネントに対応すると仮定することでした。結局のところ、それは1つの画面です。なぜ分割するのでしょうか?その後、それらのページは成長し始めました。次のようなものを含むダッシュボードを想像してみてください:

├─ User summary
├─ Sales chart
├─ Recent orders
├─ Notifications
├─ Activity feed
├─ Quick actions
└─ Reports

Enter fullscreen mode Exit fullscreen mode

ユーザーの視点からは、それは1つのページです。しかし、開発の視点からは、それは複数の独立した機能です。それぞれが最終的に必要とする可能性があります

  • 独自のAPIリクエスト
  • 独自のローディング状態
  • 独自の権限
  • 独自のインタラクション
  • 独自のテスト

同じ画面に表示されるという理由だけで、すべてを単一のコンポーネント内に保持することは、しばしば不要な結合を生み出します。機能を専用のコンポーネントに分割することは、ファイルサイズを減らすだけでなく、所有権をはるかに明確にします。
通知に取り組む人は、レポートがどのように生成されるかを理解する必要はありません。同様に、売上チャートを変更することは、最近の注文を壊すリスクを冒すべきではありません。アプリケーションが成長するにつれ、これらの境界はますます価値のあるものになります。


大きなテンプレートは通常、同じ問題を隠している

テンプレートでも同様のことが起こります。最初はHTMLは理解しやすいです。その後、いくつかの条件が現れます。いくつかのループ。いくつかの権限チェック。ユーザーの役割に応じた異なるレイアウト。最終的に、ページの単一のセクションがどのようにレンダリングされるかを理解するために、何百行もスクロールすることになります。
簡略化された例は次のようになります:

        </app-admin-panel>
        <app-basic-panel
            *ngIf="dashboardMode === 'basic'">
        </app-basic-panel>
    </div>
    <app-user-panel
        *ngIf="!user.permissions.includes('admin')">
    </app-user-panel>
</div>

Enter fullscreen mode Exit fullscreen mode

これらの条件は個別には間違っていません。問題は、新しい要件がテンプレートに別の分岐を追加するということです。最終的に、ページが何を表示するかを理解することは、コンポーネント自体を理解することとほぼ同じくらい困難になります。セクションが独自のロジックを持ち始めたら、私は通常「ここに条件を追加し続けられるか?」と尋ねるのをやめて、「これは独自のコンポーネントになるべきか?」と尋ねるようになります。多くの場合、答えは「はい」です。


入力と出力は設計の問題を明らかにする

コンポーネント間でデータを渡すことは完全に正常です。それこそがAngularコンポーネントの目的です。しかし、時間が経つにつれ、私は興味深いことに気づきました。コンポーネントがあまりにも多くの入力を受けるようになるのは、多くの場合、それがあまりにも多くの責任を負っているからです。たとえば:

@Component({ 
    selector: 'app-dashboard-card'
})

Enter fullscreen mode Exit fullscreen mode

[user]="user"
[permissions]="permissions"
[settings]="settings"
[statistics]="statistics"
[reports]="reports"
[loading]="loading"
[theme]="theme"
[subscription]="subscription"
(refresh)="refresh()"
(delete)="deleteUser()"
(export)="exportDat()">
</app-dashboard-card>

Enter fullscreen mode Exit fullscreen mode

これが間違った状態になる魔法の数字はありません。しかし、ページ状態のほぼすべてを単一の子コンポーネントに渡していることに気づいたら、私は通常、一時停止します。子コンポーネントはもはや1つのコンポーネントではなくなっている可能性があります。さらに分割する価値のある別の機能かもしれません。出力についても同じことが言えます。1つの子が5つまたは6つの異なるイベントを発行する場合、それがあまりにも多くの異なることをしようとしているかどうかを問いかける価値があります。


フォームは他のすべてよりも速く成長する傾向がある

大きくなりすぎたコンポーネントが最も頻繁に現れる場所があるとすれば、それはフォームです。少数のフィールドと送信ボタンから始まります。その後、ビジネス要件がやってきます。条件付きフィールド、動的バリデーション、役割ベースの表示、自動保存、添付ファイル、外部ルックアップ... やがて、フォームを担当するコンポーネントは、アプリケーションのビジネスルールのほとんどを含むようになります。私はこれと戦わないことを学びました。大規模なフォームは自然に複雑です。役立つのは、それらの周りの責任を分離することです。たとえば:

  • 1つのコンポーネントが個人情報セクションをレンダリングする
  • 別のコンポーネントが住所を処理する
  • 別のコンポーネントが添付ファイルを管理する
  • 共有バリデーションは専用のサービスに存在する
  • 再利用可能なコントロールはスタンドアロンコンポーネントになる

フォームはユーザーにとって単一の体験として動作します。しかし、コードベースははるかにナビゲートしやすくなります。


コンポーネントを分割する最適なタイミングは、あなたが思うより早い

学ぶのに時間がかかった教訓の一つは、コンポーネントの抽出は最後の手段であってはならないということです。長い間、私はコンポーネントが痛みを伴うようになるまで待ってから分割していました。今はもっと早く行うようにしています。より多くのファイルを作成するのが好きだからではありません。小さく、焦点を絞ったコンポーネントは独立して進化する傾向があるからです。これは、マージコンフリクトの減少、よりシンプルなレビュー、より明確な所有権、既存の機能を変更する際の恐怖の大幅な軽減を意味します。皮肉なことに、コンポーネントを早期に分割することは、しばしば全体としてより少ないコードを書く結果になります。重複が少なく、分岐が少なく、無関係なロジックが誤って接続される場所が少なくなります。


結論

大規模なAngularコンポーネントが保守困難になるのは、Angularが悪いプラクティスを奨励するからではありません。機能が同じ場所に蓄積され続けるために困難になるのです。個々の変更はそれぞれ理にかなっているように感じられます。総合すると、それらはゆっくりとUIコンポーネントを、機能全体に責任を持つものに変えていきます。今日、私はコンポーネントのサイズに基づいて分割を決定しません。複数の異なる問題を解決していることに気づいたときに分割します。それは通常、将来の変更が本来あるべきよりも困難になる最初のサインです。責任を小さく保つことは、可読性を向上させるだけでなく、テスト、デバッグ、新しい機能の追加をはるかにストレスなくします。


このシリーズの次の記事

第5部:本番環境でのみ現れるAngularのサブスクリプション問題

このシリーズの前の記事

第1部:Angularアプリケーションが成長するにつれて遅くなる理由
第2部:Angularのバンドルサイズはパフォーマンスの一部に過ぎない

第3部:大規模アプリケーションを遅く感じさせるAngularの変更検知の間違い



読んでいただきありがとうございます!あなたのAngularアプリケーションが時間の経過とともにパフォーマンスや保守性の問題を蓄積している場合、これが私がチームを支援する作業の種類です:既存のコードベースのデバッグ、パフォーマンスの改善、段階的なリファクタリング。私のDevプロフィールにupWorkへのリンクがあります。