ぼくたちがフロントエンドエンジニアと呼ばれるまで

Angular1によるSPA開発 – Tips編

こんにちは、エンジニアの尾形です。

前回のブログではAngular1の設計の勘所についてお伝えしました。
今回はAngular1のTipsをいくつかご紹介します。
(なぜAngular1を採用しているかについては前々回記事をご参照ください。)

本記事では、Angular1のサンプルプログラムをTypeScriptで記述しています。インターネット上に見られるAngular1の各種サンプルはJavaScriptで記述されていることが多いため、TypeScriptで開発を行なっている方の参考になれば幸いです。

URLによる二重クリック対策

「画面のボタンが二重クリックされたときに、二回目のクリックを無効にしたい。」
よくある要件です。特に登録系の機能の場合は、ほぼ必ず制御することになるでしょう。

二重クリックの対策としては、ボタンをdisabledにするなど、いくつかありますが、今回はURLを使用する方法についてご紹介します。ここでいうURLとは、ボタンがクリックされたときに実行されるサーバー側のAPIのURLのことです。Angular側でURLの重複チェックを行ない、現在既に当該URLにアクセス中であれば後発のリクエストを破棄することにより二重クリック対策とします。

$httpサービスのリクエストをフックするインターセプターを作成し、そこで共通的に処理を行ないます。

まず最初に、Angular1のHTTP設定を拡張して、HTTPリクエストの重複チェックを行なうためのプロパティーを追加します。

export interface ApiRequestShortcutConfig extends ng.IRequestShortcutConfig {
    /**
     * リクエストの重複チェックを行なうかどうか
     */
    appUnique?: boolean;

    /**
     * リスエストを一意に識別するためのID(このIDが一致したら重複とみなされる)
     * URLで排他したい場合はURLを設定する
     */
    appRequestId?: string;
}

次に、インターセプターのサービスを作成します。
Angular1は $http.pendingRequests にリクエスト中のものを登録しているため、それを利用して重複チェックを行ないます。

import { ApiRequestShortcutConfig } from './api-request-shortcut-config';

export class ApiInterceptorService {
    constructor(private $q: ng.IQService, private $injector: ng.auto.IInjectorService) {
        "ngInject";
    }

    /**
     * リクエスト時にURLによる二重クリック対策を行う.
     * (同一URLに対しては、一度に一回しか実行できない.)
     */
    public request = (config: ApiRequestShortcutConfig): ApiRequestShortcutConfig | ng.IPromise<any> => {
        if (this.checkForDuplicates(config) && this.checkIfDuplicated(config)) {
            return this.buildRejectedRequestPromise(config);
        }
        return config;
    }

    /**
     * 重複チェックを行なうかどうかを確認する.
     */
    private checkForDuplicates = (config: ApiRequestShortcutConfig): boolean => {
        return !!config.appUnique;
    }

    /**
     * 重複しているかどうかを確認する.
     */
    private checkIfDuplicated = (config: ApiRequestShortcutConfig): boolean => {
        let http = this.$injector.get('$http');
        let duplicated = http.pendingRequests.filter((pendingConfig: ApiRequestShortcutConfig): boolean => {
            return pendingConfig.appRequestId && pendingConfig.appRequestId === config.appRequestId;
        });
        return duplicated.length > 0;
    }

    /**
     * リクエストをrejectする.
     */
    private buildRejectedRequestPromise = (config: ApiRequestShortcutConfig): ng.IPromise<any> => {
        let deferred = this.$q.defer();
        return deferred.promise;
    }
}

メインのプログラムファイルは次のようになります。
$httpProviderにインターセプターのサービスを登録しています。

import angular = require('angular');

import { ApiInterceptorService } from './services/api-interceptor-service';

var appModule = angular.module('app', []);
appModule.service('apiInterceptorService', ApiInterceptorService);
appModule.config(($httpProvider: ng.IHttpProvider) => {
    "ngInject";
    $httpProvider.interceptors.push('apiInterceptorService');
});

最後に、APIの実行時に次のようにすることにより、URLが重複したリクエストは無視されるようになります。

// URLによる二重クリック対策を行う
var appUnique = true;
var url = '/api/sample';
var request = {};

// HTTP設定
let config: ApiRequestShortcutConfig = {
    appUnique: appUnique,
    appRequestId: url
};

// POSTでAPI実行
this.$http.post<ApiResponse>(url, request, config).then<any>((promiseValue: ng.IHttpPromiseCallbackArg<ApiResponse>) => {
    // 成功時のコールバック
}).catch<any>((reason: any) => {
    // 失敗時のコールバック
});

$scope.$watch() や $scope.$on() でイベントのリスナーを登録する際の注意事項

これは実際にドハマりした問題です。

次のようにコンポーネントのコントローラーにて $scope.$watch() や $scope.$on() でイベントのリスナーを登録するケースがあると思います。

class SampleController {
    public count: number;

    constructor(private $scope: ng.IScope) {
        "ngInject";
    }

    $onInit() {
        this.$scope.$watch(() => {
            return this.count;
        }, (newValue: number, oldValue: number) => {
            if (newValue !== oldValue) {
                // countが変化したときの処理
            }
        });

        this.$scope.$on('sample_event', (event: ng.IAngularEvent) => {
            // sample_event を受信したときの処理
        });
    }
}

上記に対応するコンポーネントがDOM上から消えた場合、コントローラーはdestroyされますが、コントローラーで登録したイベントのリスナーは残り続けるため、ng-ifなどでコンポーネントの表示/非表示を繰り返すと、その分リスナーが増えていきます。これにより、存在しないはずのコントローラーの処理が動いてしまうなどの問題が発生します。

この問題は、コントローラーがdestroyされるときにイベントのリスナーを明示的に解除することで解消されます。具体的には、$scope.$watch() や $scope.$on() の返り値として、リスナーを登録解除するための関数が返却されるので、コントローラーがdestroyされるときに呼び出される$onDestroy()にて、その関数を呼び出します。

改善後のプログラムは次のようになります。

class SampleController {
    public count: number;
    private deregisterListeners: Array<Function> = [];

    constructor(private $scope: ng.IScope) {
        "ngInject";
    }

    $onInit() {
        // $watchの返り値としてリスナー登録解除用の関数が返却されるので、保持しておく
        this.deregisterListeners.push(this.$scope.$watch(() => {
            return this.count;
        }, (newValue: number, oldValue: number) => {
            if (newValue !== oldValue) {
                // countが変化したときの処理
            }
        }));

        // $onの返り値としてリスナー登録解除用の関数が返却されるので、保持しておく
        this.deregisterListeners.push(this.$scope.$on('sample_event', (event: ng.IAngularEvent) => {
            // sample_event を受信したときの処理
        }));
    }

    $onDestroy() {
        // リスナー登録解除用の関数を実行
        this.deregisterListeners.forEach((deregisterListener: Function) => {
            deregisterListener();
        });
    }
}

余談ですが、$scope.$watch() と $scope.$on() の型定義では、返却値型が void型となっていました。Function型と定義されていれば関数が返却されることが分かるので、もう少し早く問題に気づけたかもしれません。。

なお、サービスなどのシングルトンインスタンスの場合は、アプリケーション上に常に存在するため、destroyの概念はありませんが、イベントの監視が不要となる場合にはリスナーを解除する必要があるでしょう。

jQueryによるDOMの変更をAngular側へ通知する

jQueryによるDOMの変更をAngular側へ通知するには、jQueryのtriggerイベントを使用します。

たとえば、jQueryのDatepickerの結果を通知するには以下のようにします。

$('.js-select-date').datepicker({
    dateFormat: 'yy/mm/dd',
    onSelect: function(dateText) {
        $('.js-select-date-text').text(dateText);
        $('.js-select-date').trigger('change');
    }
});

trigger(‘input’)はIEで動作しないため、trigger(‘change’)としています。

ng-repeat でユニークキー重複エラー

<div ng-repeat="item in $ctrl.list"></div>

上記のようにオブジェクトの配列をng-repeatでイテレートすると、タイミングによって以下のエラーが発生することがありました。

Error: [ngRepeat:dupes] Duplicates in a repeater are not allowed. Use ‘track by’ expression to specify unique keys.

オブジェクトの配列に重複はないはずなので調べると、どうやらAngularが生成する$$hashKeyが重複してしまうことがあるようです。$$hashKeyは “object:28” のような形式でしたが、オブジェクトの内容が異なるにもかかわらず同一の$$hashKeyが生成されるのは生成ロジックがよろしくないのだろうか。。

エラーメッセージにあるヒントに従い、’track by’でキーを指定することにより対応しました。

<div ng-repeat="item in $ctrl.list track by item.id"></div>

ブロードキャストの送信処理と受信処理をセットで定義する

ブロードキャストを使用する場合は、コーディング時に工夫することにより実行時のエラーを未然に防ぐことができます。
アラートダイアログのコンポーネントがあるとして、別のコンポーネントからアラートダイアログを表示するケースを例にします。

/* あるコンポーネント */

// エラーを検知したので、アラートダイアログの表示依頼をブロードキャストする
if (error) {
    const data: string = 'エラーが発生しました';
    this.$rootScope.$broadcast('open_alert', 'エラーが発生しました');
}

/* アラートダイアログのコンポーネント */

// アラートダイアログの表示依頼を受信する
this.$rootScope.$on('open_alert', (event: ng.IAngularEvent, data: string) => {
    // ダイアログを表示する処理
});

上記の場合、アプリケーションの実行時に次のような問題が発生する可能性があります。
・イベント名の ‘open_alert’ を間違えると、正常に動作しない
・送信側と受信側でデータ定義の対応付けができていないため、データを追加、変更したときに修正漏れが発生し、正常に動作しない

そこで、次のように改善することにより、これらの問題を解消することができます。
※コンパイル時の静的チェックにより、実行前にエラーを検知できるようになるのがポイントです。

ブロードキャストの送信処理と受信処理をラップするブロードキャストサービスを作成します。
送受信するデータの定義も合わせて作成します。

// アラートダイアログ表示のデータ
export interface OpenAlert {
    message: string;
    errorNo?: number;
}

// ブロードキャストサービス
// ブロードキャストの送信処理と受信処理をセットで定義する
export class BroadcastService {
    constructor(private $rootScope: ng.IRootScopeService) {
        "ngInject";
    }

    // アラートダイアログの表示依頼のブロードキャストを送信する。
    public openAlert(data: OpenAlert) {
        this.$rootScope.$broadcast('open_alert', data);
    }

    // アラートダイアログの表示依頼のブロードキャストのリスナーを登録する。
    public openAlertListener(listener: (event: ng.IAngularEvent, data: OpenAlert) => void): Function {
        return this.$rootScope.$on('open_alert', listener);
    }
}

上記を使用するようにプログラムを変更します。

/* あるコンポーネント */

// エラーを検知したので、アラートダイアログの表示依頼をブロードキャストする
if (error) {
    // 改善前
    // const data: string = 'エラーが発生しました';
    // this.$rootScope.$broadcast('open_alert', 'エラーが発生しました');

    // 改善後
    const data: OpenAlert = { message: 'エラーが発生しました', errorNo: 100 };
    this.broadcastService.openAlert(data);
}

/* アラートダイアログのコンポーネント */

// 改善前
// this.$rootScope.$on('open_alert', (event: ng.IAngularEvent, data: string) => {
//     // ダイアログを表示する処理
// });

// 改善後
this.broadcastService.openAlertListener((event: ng.IAngularEvent, data: OpenAlert): void => {
    // ダイアログを表示する処理
});

改善前後の違いは一目瞭然ですね。プログラムもシンプルでわかりやすくなっています。
前者は、処理を変更したいときには’open_alert’の文字列でプログラムを検索して、ヒットした個所を個別に変更して、、、のようなやり方になりますが、後者は型レベル、関数レベルでの検索ができるため、修正も容易になります。エディターによるコードの自動補完やリファクタリングも可能となります。

まとめと予告

今回はAngular1のTipsについて書かせていただきました。
「ぼくたちがフロントエンジニアと呼ばれるまで」の連載は今回で最後となります。

フロントエンドの技術は日進月歩どころか秒進分歩で変化しているとも言われています。Angularもリリースサイクルが発表され、6か月に一度メジャーバージョンアップが行なわれる予定となっています。Angular5は2017年9月、Angular6は2018年3月、Angular7は2018年9月にリリースされる見通しです。

今回はAngular1で開発を行ないましたが、近い将来Angularのバージョンアップを行なうことになるでしょう。その機会が来たらまたこの場でバージョンアップの苦労話などをご紹介させていただきたいと思います。


次回からは他のエンジニアがインフラ関連やアプリなどの情報をご紹介させていただく予定です。乞うご期待ください!

引用元・出典
AngularJS で二重ポストしないようにする
How to check internet connection in AngularJs – Stack Overflow

フロントエンドエンジニアへの道

1- HTML/CSSコーディングはじめるぞ〜HTML/CSSコーディングを行なうにあたっての事前準備
2- チーム作業をするための最適なCSS規約、Sassについて考えた
3- Sassコーディングを行なう上で必要となる各種自動化の話
4- Angular1によるSPA開発 – 基本編
5- Angular1によるSPA開発 – 設計編
6- Angular1によるSPA開発 – Tips編

この記事を書いた人・プロフィール
オガティー
ニックネーム: オガティー

2014年3月から現職。当社のベンチャービジネスに心惹かれて入社。
それ以前はソフトハウス(2社)に在籍。
エンジニア歴はもう少しで20年。通信キャリアーのシステム開発、サーバーサイドプログラミングの経験が長い。
エンジニアとしてのベースはJava屋。Androidアプリ開発も。サーバーサイドはPHP, Node.jsが多い。
フロントエンドエンジニアとしては駆け出し。
趣味:ギター、ドラム、フットサル、お酒