Afficher un loader pendant une requête AJAX v2

La logique de l’application, dans le service, pas dans le contrôleur

La semaine dernière j’ai écrit un article sur la manière d’afficher un loader pendant une requête AJAX, et dans les commentaires j’ai eu plusieurs remarques me signalant qu’il était préférable de ne pas mettre la logique dans le contrôleur mais plutôt dans un service.

Afficher un loader pendant une requête AJAX

Le but de cet article était de présenter une astuce et non la manière d’organiser le code, mais c’est vrai qu’en me relisant je me suis rendu compte que ma démo n’était pas un bon exemple à suivre.

Rappel de l’astuce

L’idée est d’afficher un loader pour signaler à l’utilisateur qu’une requête est en cours. Afin que le système fonctionne même si plusieurs requêtes sont lancées en parallèle, on utilise un integer plutôt qu’un booléen. Le loader est masqué tant que la variable est à 0 / est affiché tant que la variable est supérieure à 0 :

$scope.isSomethingLoading = 0;

Quand une requête est lancée, la variable est incrémentée.

$scope.isSomethingLoading++;

Quand une requête se termine (ou échoue), la variable est décrémentée.

$scope.isSomethingLoading--;

Le problème est que j’ai mis cette logique dans un contrôleur, ce qui a pour résultat de rendre le code non réutilisable dans le reste de l’appli (ou dans une autre appli).

La solution, ou en tout cas l’une des solutions, est d’utiliser un interceptor (merci à Geoiris et Julien Bouquillon pour l’astuce).

Intercepter les requêtes AJAX avec un interceptor

Un interceptor est un service, une factory pour être précis, que l’on attache au $httpProvider. De cette manière on va pouvoir intercepter les requêtes faites via le service $http.

Vous pouvez vous rendre sur la documentation officielle du service $http pour en savoir plus.

Démo, version 2

Note: J’ai laissé une grande partie de la logique dans le contrôleur, car il ne s’agit que d’une démo et je ne souhaite pas compliquer les choses. Pour votre application, préférez placer la logique dans des services. En revanche, la logique concernant le loader est placée dans une factory, car c’est l’objet de l’article !

 

Demo sur plnkr.coTélécharger la source

 

index.html

<!DOCTYPE html>
<html lang="en" ng-app="myApp">

  <head>
    <meta charset="UTF-8" />
    <title>AJAX Loader</title>
    <link href="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="style.css">
  </head>

  <body ng-controller="MainCtrl">
		<br>
		<p>We run 5 requests to retrieve data from the Dailymotion API</p>
		<p>The loader is displayed while at least one request is still running</p>

		<div class="wrapper">

			<!-- Run multiple AJAX requests -->
			<!-- While something is loading, the button is disabled -->
			<button type="button" class="btn btn-primary" ng-click="getDataFromDM()" ng-disabled="isSomethingLoading">Run 5 AJAX requests</button>

			<!-- While something is loading, the loading loader is displayed -->
  		<div class="loader" ng-show="showLoader"></div>

			<br><br>
			<p ng-repeat="result in results">Request done for the keyword: <span class="bold">{{result}}</span></p>
		</div>

    <script src="https://code.angularjs.org/1.2.9/angular.min.js"></script>
    <script src="app.js"></script>
  </body>

</html>

app.js

var app = angular.module('myApp', []);

//Credit: http://stackoverflow.com/a/17850865
app.factory('httpInterceptor', ['$q', '$rootScope', function($q, $rootScope) {
	var currentRequestsCount = 0;
	return {
		//Everytime a request starts, the loader is displayed
		request: function(config) {
			currentRequestsCount++;
			$rootScope.$broadcast('loaderShow');
			return config || $q.when(config)
		},
		//When a request ends, and if there is no request still running, the loader is hidden
		response: function(response) {
			if ((--currentRequestsCount) === 0) {
				$rootScope.$broadcast('loaderHide');
			}
			return response || $q.when(response);
		},
		//When a request fails, and if there is no request still running, the loader is hidden
		responseError: function(response) {
			if (!(--currentRequestsCount)) {
				$rootScope.$broadcast('loaderHide');
			}
			return $q.reject(response);
		}
	};
}]);

app.config(['$httpProvider', function($httpProvider) {
	$httpProvider.interceptors.push('httpInterceptor');
}]);

app.controller('MainCtrl', ['$scope', '$http',
	function($scope, $http) {

		/* PUBLIC VARIABLES
		================================================== */

		//While this variable is true, we display the loader
		$scope.showLoader = false;

		//Results displayed in the view
		//For the example, will only contain the searched term
		$scope.results = [];

		/* PRIVATE FUNCTIONS
		================================================== */

		//Generate a random string
		//Credit: http://stackoverflow.com/a/1349426/962893

		function generateString() {
			var text = "";
			var possible = "abcdefghijklmnopqrstuvwxyz";
			for (var i = 0; i < 3; i++)
				text += possible.charAt(Math.floor(Math.random() * possible.length));
			return text;
		}

		//Get a lot of data from the Dailymotion API

		function getDataFromDMAPI(keyword) {
			//Clear the previous results
			$scope.results = [];
			$http({
				method: 'GET',
				url: 'https://api.dailymotion.com/videos?fields=3d,access_error%2Cchannel%2Cdescription%2Cduration%2Csharing_urls%2Csoundtrack_info%2Csources%2Cstart_time%2Cstatus%2Cstream_h264_hd1080_url%2Cstream_h264_hd_url%2Cstream_h264_hq_url%2Cstream_h264_ld_url%2Cstream_h264_url%2Cstream_source_url%2Cstrongtags%2Csvod%2Cswf_url%2Csync_allowed&limit=100&search=' + keyword
			})
				.success(function(data, status, headers, config) {
					//The results are displayed (only the keyword for this example)
					$scope.results.push(keyword);
				});
		}

		/* PUBLIC FUNCTIONS
		================================================== */

		//Run multiple AJAX Requests
		//Note that if we didn't use random strings, the demo would only work once because the results are cached by the browser (i.e. you wouldn't have the time to see the loader)
		$scope.getDataFromDM = function() {
			for (var i = 1; i < 6; i++) {
				getDataFromDMAPI(generateString());
			}
		};

		/* EVENT HANDLERS
		================================================== */

		//The httpInterceptor will send a message when the loader should be displayed
		$scope.$on('loaderShow', function () {
			$scope.showLoader = true;
		});

		//The httpInterceptor will send a message when the loader should be hidden
		$scope.$on('loaderHide', function () {
			$scope.showLoader = false;
		});

	}
]);

Explication du code

La logique du loader est placé dans un service (de type factory).

app.factory('httpInterceptor', ['$q', '$rootScope', function($q, $rootScope) {
	//...
}]);

Ce service est attaché au $httpProvider, ce qui fait que les requête faites via le service $http seront interceptées.

app.config(['$httpProvider', function($httpProvider) {
    $httpProvider.interceptors.push('httpInterceptor');
}]);

Quand une requête est lancée, on incrémente une variable et on broadcast un message pour signifier aux contrôleurs que le loader doit être affiché (dans cette démo on a un seul contrôleur).

currentRequestsCount++;
$rootScope.$broadcast('loaderShow');

Côté contrôleur, à la réception du message on met une variable à true.

$scope.$on('loaderShow', function() {
	$scope.showLoader = true;
});

Côté template, le loader est affiché quand la variable est à true.

<div class="loader" ng-show="showLoader"></div>

Retour au $httpInterceptor. Quand une requête se termine, on décrémente la variable, et si celle-ci est retombée à 0, c’est-à-dire s’il n’y a plus de requête en cours, on broadcast un message pour signifier aux contrôleurs que le loader doit être masqué.
Notez au passage que –currentRequestsCount est différent de currentRequestsCount–, en effet la deuxième expression retourne la valeur de la variable avant décrémentation (ce qui n’est pas un problème la plupart du temps, mais là oui).

response: function(response) {
	if ((--currentRequestsCount) === 0) {
		$rootScope.$broadcast('loaderHide');
	}
	//...
}

Enfin, on gère aussi le cas où la requête échouerait.

responseError: function(response) {
	if (!(--currentRequestsCount)) {
		$rootScope.$broadcast('loaderHide');
	}
	//...	
}
Share Button

2 thoughts on “Afficher un loader pendant une requête AJAX v2

  1. pourquoi $q.when dans request/response ?

    reste plus qu’à isoler le loader lui-même dans une directive et tu as une solution 100% réutilisable avec le simple ajout de la dépendance dans ton projet et de la directive dans ton template.

    J’en profite pour signaler que `$rootScope.$broadcast` est assez couteux car l’event est propagé dans tous les childScopes. Utiliser `$rootScope.$emit` + `$rootScope.$on’ conviendra aussi bien et permettra de gagner en performance. plus d’infos : http://stackoverflow.com/questions/11252780/whats-the-correct-way-to-communicate-between-controllers-in-angularjs/19498009#19498009

  2. Bixi

    Tu peux également remplacer tout le code (– / ++) de ton interceptor par un simple test sur : « $http.pendingRequests.length > 0 »

    😉

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

You may use these HTML tags and attributes:

<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>