Closures en JavaScript

applications internet utilitaires

Ce site ne sera plus alimenté de contenu après août 2014. Tous les nouveaux articles seront redigés pour www.waitingforcode.com
Dans la plupart des cas, la connaissance basique de JavaScript nous suffira à développer des applications plus user-friendly (surtout avec des frameworks du type jQuery). Malgré cela, JavaScript reste un langage de programmation. Un langage beaucoup plus complexe qu'on y croit.

Cet article va présenter la notion de closure en JavaScript. Au début on verra ce qui se cache derrière ce mot. Dans la deuxième partie on passera à un exemple d'utilisation. Pour illustrer la mise en place de closure, on utilisera la géolocalisation avec la version 3 de GoogleMaps.

Closures en JavaScript
Il arrive souvent qu'on parcourt une dizaine d'éléments dans la boucle for et qu'on veut utiliser la valeur incrémentée/décrémentée plus loin dans le code (par exemple suite au chargement d'un fichier). Pour éviter le problème où la valeur récupérée correspond toujours à la valeur maximale, on utilisera un closure. Voici l'exécution d'une méthode se basant sur la variable incrémentée i:


$(document).ready(function() {
for(var i = 0; i < 4; i++)
{
window.setTimeout(function() { alert(i) }, 1000);
}
});

Le test affiche tout le temps le chiffre 4. On a donc l'impression que la boucle et la méthode alert(i) s'exécutent dans deux fils distincts. Le problème peut être évité grâce au closure qui "protégera" la variable i. Voici le code corrigé :


$(document).ready(function() {
for(var i = 0; i < 4; i++)
{
(function(i) {
window.setTimeout(function() { alert(i) }, 1000);
})(i);
}
});

Maintenant on voit bien les fenêtres d'alerte avec i incrémenté.

Pour comprendre ce qui s'est passé, on va remonter dans le temps et se rappeler quelle est la portée des variables dans JavaScript.

<body>
<script type="text/javascript" src="http://code.jquery.com/jquery-latest.js"></script>
<script type="text/javascript">
var a = "a";
$(document).ready(function() {
outerMethod();
$("#global").append("
a =========> " + typeof a);
$("#global").append("
b =========> " + typeof b);
$("#global").append("
c =========> " + typeof c);
});

function outerMethod()
{
var b = "b";
$("#outer").append("
a =========> " + typeof a);
$("#outer").append("
b =========> " + typeof b);
$("#outer").append("
c =========> " + typeof c);
function innerOfOuter()
{
var c = "b";
$("#inner").append("
a =========> " + typeof a);
$("#inner").append("
b =========> " + typeof b);
$("#inner").append("
c =========> " + typeof c);
}
innerOfOuter();
}
</script>
<p id="global"><strong>GLOBAL</p>
<p id="outer"><strong>outerMethod</p>
<p id="inner"><strong>innerOfOuter</p>
<!-- the results are :
GLOBAL
a =========> string
b =========> undefined
c =========> undefined

outerMethod
a =========> string
b =========> string
c =========> undefined

innerOfOuter
a =========> string
b =========> string
c =========> string
-->
</body>

On voit clairement que la portée des variables en JavaScript ressemble un peu à une arbre. Plus on descend, plus de variables on peut manipuler. Si l'on rajoute une fonction anonyme (Self-Invoking Anonymous Function - SIAF) à cette logique, on obtiendra un closure.

Une fonction anonyme, qu'est-ce que c'est ? La fonction anonyme est une fonction sans le nom défini, qui peut être appelée seulement une fois, au moment de sa déclaration. Les paramètres de cette fonction sont privés et donc indépendants d'autres fils d'exécution.

Voici donc comment se présente un squelette de closure :

(function(param1, param2) {
// do something with two params
})("first param value", "second param value");

A part de mettre cette fonction anonyme dans une boucle, il nous est incapable de l'appeler plusieurs fois avec des paramètres différents.

Closures dans GoogleMaps
Où les closures peuvent trouver leur utilité ? On peut l'employer par exemple dans l'usage des API, comme GoogleMaps. C'est d'ailleurs ce web service qu'on utilisera pour illustrer l'utilisation des closures dans le monde réel.

Imaginons qu'on charge 10 adresses différentes qu'on veut marquer sur une carte avec des markers. Pour le faire, on doit récupérer d'abord les coordonnées géographiques de ces adresses. Et c'est un processus qui nécessite un calcul de la part du service Geocoder. Cette opération prend du temps et ressemble un peu à l'exemple évoqué au tout début, avec l'appel du setTimeout() et l'affichage de "alert(i);". Voici comment se présente le code de création des markers :


<body>
<script type="text/javascript" src="http://code.jquery.com/jquery-latest.js"></script>
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script type="text/javascript">
var geocoder;
var map;
var marker;
var infowindow = new google.maps.InfoWindow();
var address = new Array();
var addressInfo = new Array();
$(document).ready(function() {
$.ajax({
type: "GET",
url: "/closures/address.php",
dataType: "json",
success: function(ret)
{
for(var i=0; i < ret.address.length; i++)
{
address[i] = ret.address[i];
addressInfo[i] = ret.info[i];
}
},
error: function(jqXHR, textStatus, errorThrown)
{
alert("An error occured");
}
});
});

function makeMap()
{
geocoder = new google.maps.Geocoder();
var myOptions = {
zoom: 11,
mapTypeId: google.maps.MapTypeId.ROADMAP
};
map = new google.maps.Map(document.getElementById("map"), myOptions);
createMap();
}

function makeMarker(location, i)
{
map.setCenter(location);
var marker = new google.maps.Marker({
map: map,
position: location,
title : ""+address[i]
});
marker.set("bulkContent", addressInfo[i]);
google.maps.event.addListener(marker,"click",function() {
infowindow.setContent(this.get("bulkContent"));
infowindow.open(map, this);
});
}

function createMap()
{
for(var i=0; i < address.length; i++)
{
if(geocoder) {
(function(i) {
geocoder.geocode({'address': address[i]}, function(results, status) {
if(status == google.maps.GeocoderStatus.OK)
{
makeMarker(results[0].geometry.location, i);
}
});
})(i);
}
}
}

<a href="#" onclick="javascript:makeMap();return false;">init map</a>
<div id="map" style="width:500px; height:500px;"></div>
</body>



// address.php
$address = array(
"1, rue de Belleville, Paris",
"1, rue Georges Berger, Paris",
"1, rue Lafayette, Paris",
"1, rue de Clichy, Paris",
"1, rue de Londres, Paris",
"1, rue de Lisbonne, Paris",
"1, rue Faraday, Paris",
"1, rue Duret, Paris",
"1, rue Vernet, Paris",
"1, rue Hautefeuille, Paris",
"1, rue Christoph Colomb, Paris"
);

$info = array(
"Bellevile",
"George Berger",
"Lafayette",
"Clichy",
"Londres",
"Lisbonne",
"Faraday",
"Duret",
"Vernet",
"Hautefeuille",
"Christop Colomb"
);

echo json_encode(array("address" => $address, "info" => $info));
die();

Les adresses sont mises correctement sur la carte. Maintenant prenez le code et essayez de gérer les adresses différemment, par exemple avec un champ caché contenant la clé de l'adresse à insérer :
<body>
<script type="text/javascript" src="http://code.jquery.com/jquery-latest.js"></script>
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
<script type="text/javascript">
var geocoder;
var map;
var marker;
var infowindow = new google.maps.InfoWindow();
var address = new Array();
var addressInfo = new Array();
$(document).ready(function() {
$.ajax({
type: "GET",
url: "/closures/address.php",
dataType: "json",
success: function(ret)
{
for(var i=0; i < ret.address.length; i++)
{
address[i] = ret.address[i];
addressInfo[i] = ret.info[i];
}
},
error: function(jqXHR, textStatus, errorThrown)
{
alert("An error occured");
}
});
});

function makeMap()
{
geocoder = new google.maps.Geocoder();
var myOptions = {
zoom: 11,
mapTypeId: google.maps.MapTypeId.ROADMAP
};
map = new google.maps.Map(document.getElementById("map"), myOptions);
createMap();
}

function makeMarker(location)
{
var i = parseInt($("#i").val());
map.setCenter(location);
var marker = new google.maps.Marker({
map: map,
position: location,
title : ""+address[i]
});
marker.set("bulkContent", addressInfo[i]);
google.maps.event.addListener(marker,"click",function() {
infowindow.setContent(this.get("bulkContent"));
infowindow.open(map, this);
});
$("#i").val(i+1)
}

function createMap()
{
for(var i=0; i < address.length; i++)
{
if(geocoder) {
geocoder.geocode({'address': address[$("#i").val()]}, function(results, status) {
if(status == google.maps.GeocoderStatus.OK)
{
makeMarker(results[0].geometry.location);
}
});
}
}
}

<a href="#" onclick="javascript:makeMap();return false;">init map</a>
<div id="map" style="width:500px; height:500px;"></div>
<input type="hidden" name="i" id="i" value="0" />
</body>

Vous verrez que tous les markers sont placés dans un seul endroit et, qu'en plus, la fenêtre d'information contient le dernier libellé récupéré (Christoph Colomb).

Closures sont donc un élément important dans le traitement d'un grand nombre de données dans quelques opérations simultanément. N'hésitez donc pas à penser à eux car ils peuvent s'avérer la solution à beaucoup de problèmes.
Bartosz KONIECZNY 13-06-2012 20:22 applications web
Moi

Développeur d'applications Internet et journaliste passionné par l'adjectif français. Un aigle polonais orienté vers la progression, volant très haut et écoutant du zouk après les matches du foot français.

Vous appréciez mon travail ?

Pour contribuer au développement de ce site, ou pour remercier pour des articles rédigés, vous pouvez faire un don.

Un conseil Symfony2

Comment personnaliser l'affichage des champs du formulaire ?

Pour personnaliser l'affichage des champs du formulaire sous Symfony2 on doit surcharger le paramètre nommé templating.helper.form.class. Par défaut il s'agit de la classe Symfony\Bundle\FrameworkBundle\Templating\Helper\FormHelper. Le code se présente ainsi :

<?xml version="1.0" encoding="UTF-8" ?>
  <container xmlns="http://symfony.com/schema/dic/services"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
    <parameters>
      <parameter key="templating.helper.form.class">Frontend\FrontBundle\Helper\FormHelper
    </parameters>
  </container>
La classe surchargée va, avant d'afficher le champ, faire appel à la méthode setTheme(). Tout cela pour déterminer quel thème doit utiliser Symfony2 pour le rendu du formulaire (champ text, boutons radio etc.). Le code de cette FormHelper se présente ainsi :
namespace Frontend\FrontBundle\Helper;

use Symfony\Component\Templating\Helper\Helper;
use Symfony\Component\Templating\EngineInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\Form\Exception\FormException;
use Symfony\Component\Form\Util\FormUtil;
use Symfony\Bundle\FrameworkBundle\Templating\Helper\FormHelper as ParentHelper;

class FormHelper extends ParentHelper
{
    protected $engine;

    protected $varStack;

    protected $context;

    protected $resources;

    protected $themes;

    protected $templates;

    protected function renderSection(FormView $view, $section, array $variables = array())
    {
        // if new theme is set (for exemple for override form templates)
        if(isset($variables['attr']['theme'])) $this->setTheme($view,  $variables['attr']['theme']);
        return parent::renderSection($view, $section, $variables);
    }

}
Afin de personnaliser un champ du formulaire sous Symfony2 il faut passer un paramètre theme qui appelera la fonction setTheme. Voici un exemple :
// it will show the template located ad /app/Resources/views directory
echo $view['form']->widget($form['replyType'], array('attr' => array('theme' => array(0 => ':'))));
Pour voir comment on peut personnaliser ces champs, veuillez vous référer aux fichiers placés dans /vendor/Symfony/Bundle/FrameworkBundle/Resources/views/Form.