[AngularJS] GUI 連打防止作戦 考察

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

angularjs125title

車輪開発大好きおたいがです。こんにちは。( 挨拶 )

AngularJS 1.x 系 ( 1.3~ ) アプリケーションにおける、ボタンクリックや [Enter] キーの連打によって起こりうる事故を予防する手段についてまとめてみました。自分向けの備忘録的要素強めでお届けいたします。

余談ですが、英語でググるときには prevent double(multiple) submission といった単語を使用すると色々出てくるようです。

$httpProvider.interceptors の利用

想定ケースとしては XHR 処理に限定した話ですが、$http サービスのリクエストおよびレスポンスの前処理をフックするインターセプタを用意することで、通信中に GUI の操作を無効にさせるような共通処理を仕込むことが可能です。

angular-loading-barangular-block-ui などを利用したことがありました。

$httpProvider.interceptors.push(function($q) {
  return {
    'request': function(config) {
      //TODO: config.url でリクエストのパスが取得できるので、正規表現で条件を切り分けることが可能
      //TODO: GUI ブロック処理開始
      return config;
    },
   'requestError': function(rejection) {
      //TODO: GUI ブロック処理終了
      return $q.reject(rejection);
    },
    'response': function(response) {
      //TODO: GUI ブロック処理終了
      return response;
    },
   'responseError': function(rejection) {
      //TODO: GUI ブロック処理終了
      return $q.reject(rejection);
    }
  };
});

ただし、短時間かつ直列的に複数のリクエスト、レスポンス処理が連続して走ると、GUI の見せ方によっては「チラつき」現象が起きることが考えられるので、GUI ブロック終了処理にはひと工夫必要になるかもしれません。( たとえば debounce のような間引き処理を仕込むなど )

また、同時に複数の XHR 処理を実行して $q.all() で Promise をまとめて監視するような実装コードがある場合には、上記の手段では対応しきれなくなる場合も考えられます。このようなときには、大人しく実装箇所ごとに個別でお世話した方が無難と考えます。

他にも $http.pendingRequests をチェックする方法もあるのですが、リクエストとレスポンスのチューニングが必要で面倒そうなのでスルーしました。

参考 : Preventing duplicated requests in AngularJS | Codebrag blog
http://blog.codebrag.com/post/57412530001/preventing-duplicated-requests-in-angularjs

ngModelOptions ディレクティブの利用

<input type="text" …> 要素の入力に限った話ですが、 ng-change で入力内容の変更を検知して処理を発火させたいとき、1 文字増減するたびに発火されては堪ったものではありません。

このようなシーンで ngModelOptions を利用すると便利です。

<div ng-controller="ExampleController">
  <form name="userForm">
    <label>Name:
      <input type="text" name="userName"
             ng-model="user.name"
             ng-model-options="{ debounce: 1000 }" />
    </label>
    <button ng-click="userForm.userName.$rollbackViewValue(); user.name=''">Clear</button>
    <br />
  </form>
  <pre>user.name = <span ng-bind="user.name"></span></pre>
</div>

updateOn, debounce などを利用することで ng-change の発火を間引けます。

参考 : AngularJS: API: ngModelOptions
https://docs.angularjs.org/api/ng/directive/ngModelOptions

$provide.decorator によるイベント系 ng ディレクティブの魔改造

angular.js では ng + ≪イベント名≫ なディレクティブが以下のように一括で定義されています。

forEach(
  'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '),
  function(eventName) {
    var directiveName = directiveNormalize('ng-' + eventName);
    ngEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse, $rootScope) {
      return {
        restrict: 'A',
        compile: function($element, attr) {
          // We expose the powerful $event object on the scope that provides access to the Window,
          // etc. that isn't protected by the fast paths in $parse.  We explicitly request better
          // checks at the cost of speed since event handler expressions are not executed as
          // frequently as regular change detection.
          var fn = $parse(attr[directiveName], /* interceptorFn */ null, /* expensiveChecks */ true);
          return function ngEventHandler(scope, element) {
            element.on(eventName, function(event) {
              var callback = function() {
                fn(scope, {$event:event});
              };
              if (forceAsyncEvents[eventName] && $rootScope.$$phase) {
                scope.$evalAsync(callback);
              } else {
                scope.$apply(callback);
              }
            });
          };
        }
      };
    }];
  }
);

この中で、利用頻度の高い一部のディレクティブを改造した例が以下になります。

ng-clickng-submit に連打防止 (debounce) 処理を仕込んだ簡易サンプルです。
同期、非同期処理問わずに間引きます。

…
.config(function($provide) {
  var directiveTypes = ['Submit', 'Click'];
  angular.forEach(directiveTypes, function(type, key) {
    $provide.decorator(('ng' + type + 'Directive'), function($delegate, $parse) {
      $delegate[0].compile = function($element, attr) {
        var fn = $parse(attr['ng' + type], null, true);
        var timer;
        return function(scope, element) {
          element.on(type.toLowerCase(), function(event) {
            var _fn = function() {
              scope.$apply(function() {
                fn(scope, { '$event' : event });
              });
            };
            if (timer) {
              clearTimeout(timer);
            }
            timer = setTimeout(_fn, 300); //NOTE:間引き間隔は任意で
          });
          var destroyer = scope.$on('$destroy', function() {
            element.off();
            destroyer();
          });
        };
      };
      return $delegate;
    });
  });
})
…

参考 : AngularJSで全てのng-clickに2重クリック防止機能を付ける | The Wacul Blog
http://blog.wacul.co.jp/blog/2015/02/17/ngclick_waits_promise/

さいごに

連打防止策に関わる話をまとめてみました。
正直仕込むのは手間で面倒ですが、お行儀の良い Web アプリを作るためには欠かせません。

当エントリが、自分と同じような面倒くさがり屋さんの手助けになれば幸いです。