Inversion de contrôle pour gérer les événements

Le développement d'une IHM (interface homme-machine) conduit très rapidement à associer un traitement à une séquence d'actions. Par exemple, attendre que l'utilisateur presse la touche "Suppr" pour initier la suppression d'un élément graphique, puis qu'il clique sur un élément graphique pour supprimer ce dernier. On parle ici d'un automate qui est dans un état d'attente de la pression de la touche "Suppr", puis dans un état d'attente d'un clique sur un élément avant de revenir à son état initial.
La solution qui vient à l'esprit est de s'appuyer sur la gestion des événements du système. Il s'agit d'associer un gestionnaire d'événement à chacun des événements qui entraîne un possible changement d'état de l'automate, et de gérer dans ce gestionnaire le changement d'état. Pour reprendre l'exemple, supposons qu'il s'agisse de permettre de supprimer un élément HTML d'identifiant "tagElement" en pressant la touche Suppr, puis en cliquant sur l'élément. Le code JavaScript est alors le suivant :
var STATE_IDLE = 0;
var STATE_KEYPRESSED = 1;
var state;

function run () {
	document.body.addEventListener ("keypress", handlerKeyPress, false);
	document.getElementById ("tagElement").addEventListener ("click", handlerMouseClick, false);
	state = STATE_IDLE;
}

function handlerKeyPress (e) {
	switch (state) {
		case STATE_IDLE :
			if (e.key != "Delete")
				break;
			state = STATE_KEYPRESSED;
			document.getElementById ("tagElement").innerHTML = "Cliquez-moi maintenant";
			break;
	}
}

function handlerMouseClick (e) {
	switch (state) {
		case STATE_KEYPRESSED :
			document.getElementById ("tagElement").innerHTML = "Vous m'avez supprimé ! (pressez Suppr pour recommencer)";
			state = STATE_IDLE;
			break;
	}
}
Cliquez ici pour accéder à une page de test minimaliste. Vous pourrez visualiser le code et le récupérer pour travailler avec.
Le problème est que le code de l'automate est réparti dans les deux gestionnaires d'événement impliqués. En soi, c'est déjà compliqué. Ça le devient encore plus tandis que l'IHM s'enrichit et que les automates de ce type se multiplient et se complexifient. N'est-il pas possible de regrouper le code d'un automate dans une seule fonction spécifique ?

La solution

La solution est une inversion de contrôle. Plutôt que le programme principal s'abonne à des événements du système et qu'il contrôle l'exécution de l'automate lorsque ces événements surviennent, c'est l'automate qui va s'abonner auprès du programme principal à des événements spécifiques et contrôler sa propre exécution lorsque ces événements surviennent (le programme principal va s'abonner aux événements du système correspondant aux événements spécifiques, jouant le rôle d'une couche d'abstraction entre l'automate et le système).

Le code JavaScript

Pour reprendre l'exemple, le code JavaScript devient le suivant :
var STATE_IDLE = 0;
var STATE_KEYPRESSED = 1;
var automata, supprElement, subscribers;

function run () {
	subscribers = new Array ();
	document.body.addEventListener ("keypress", handler, false);
	document.getElementById ("tagElement").addEventListener ("click", handler, false);
	automata = new CSupprElement (document.getElementById ("tagElement"));
}

function handler (e) {
	// Appeler le gestionnaire des événéments de tous les automates abonnés à l'événement
	var s;
	for (s in subscribers) {
		if ((subscribers[s].eventTarget == e.target) && (subscribers[s].eventType == e.type))
			subscribers[s].automata.handler (e);
	}
}

function CSubscriber (automata, eventTarget, eventType) {
	this.automata = automata;
	this.eventTarget = eventTarget;
	this.eventType = eventType;
}

function CSupprElement (tagElement) {
	// Initialiser l'état de l'automate
	this.state = STATE_IDLE;
	// Abonner l'automate aux événements qui l'intéresse
	subscribers.push (new CSubscriber (this, document.body, "keypress"));
	subscribers.push (new CSubscriber (this, tagElement, "click"));
}

CSupprElement.prototype.handler = function (e) {
	switch (this.state) {
		case STATE_IDLE :
			if ((e.target != document.body) || (e.type != "keypress"))
				break;
			if (e.key != "Delete")
				break;
			this.state = STATE_KEYPRESSED;
			document.getElementById ("tagElement").innerHTML = "Cliquez-moi maintenant";
			break;

		case STATE_KEYPRESSED :
			if ((e.target != document.getElementById ("tagElement")) || (e.type != "click"))
				break;
			document.getElementById ("tagElement").innerHTML = "Vous m'avez supprimé ! (pressez Suppr pour recommencer)";
			this.state = STATE_IDLE;
			break;
	}
}

L'exemple

Cliquez ici pour accéder à une page de test minimaliste. Vous pourrez visualiser le code et le récupérer pour travailler avec.

La logique

Le code sur lequel l'inversion de contrôle débouche semble bien plus complexe que le code initial, mais ce n'est qu'une apparence. Le gain est que tout le code de l'automate CSupprElement se trouve centralisé dans la fonction CSupprElement.prototype.handler ().
Mais n'est-il pas possible de procéder plus simplement ? Dans le code présenté pour exposer le problème, plutôt que d'assigner des gestionnaires distincts handlerKeyPress et handlerMouseClick aux événements keypress et click, leur assigner le même, qui contiendrait le code de CSupprElement.prototype.handler () présenté à l'instant ? :
function run () {
	document.body.addEventListener ("keypress", uniqueHandler, false);
	document.getElementById ("tagElement").addEventListener ("click", uniqueHandler, false);
	state = STATE_IDLE;
}
C'est vrai, mais il y a d'autres intérêts à rediriger les événements vers une fonction qui va ensuite appeler les automates. Cela apparaît clairement quand plusieurs automates sont susceptibles de se trouver dans un état où ils attendent un même événement, mais qu'en plus ils ne doivent réagir à cet événement que sous certaines conditions desquelles ils n'ont rien à connaître. Ces conditions doivent être vérifiées par un chef d'orchestre des automates, en l'occurrence la fonction handler ().
Par exemple, un automate X attend la pression de la touche "A" puis celle de la touche "B", et un automate Y attend la pression d'une touche "B". On ne souhaitera pas polluer le code de Y pour qu'il vérifie si X est en cours et ne réagisse pas si c'est le cas. En effet, dans ces conditions, le code de Y ne serait pas assez isolé de son contexte d'exécution, susceptible d'évoluer. On souhaitera donc que cette condition soit vérifiée au niveau du chef d'orchestre.
Le fonctionnement est le suivant :
  • Le programme principal s'abonne aux événements du système qui intéressent l'automate de suppression d'un élément. Pour cela, il spécifie que le système doit appeler un gestionnaire handler () quand les événements keypress et click surviennent (le premier sur l'élément HTML body, le second sur l'élément HTML tagElement).
  • Le programme principal instancie l'automate via new CSupprElement (document.getElementById ("tagElement")). A cette occasion, il indique à l'automate l'élément doit il doit gérer la suppression (ce petit plus permet de disposer d'un modèle d'automate qui peut être appliqué à divers éléments).
  • Le constructeur de l'automate abonne l'automate aux événements qui l'intéresse auprès du programme principal et non auprès du système. La mécanique rudimentaire mise en place consiste à ajouter un objet CSubscriber par événement dans un tableau subscribers[] du programme principal, chaque événement étant décrit par l'élément HTML qui en est à l'origine (eventTarget) et un type (eventTYpe).
  • Lorsqu'un événement survient, le gestionnaire d'événements du programme principal est appelé. Pour chaque automate abonné à l'événement auprès du programme principal, c'est-à-dire pour chaque entrée dans la liste des abonnés subscribers[] qui correspond (même élément HTML à l'origine de l'événement, même type d'événement), il appelle une méthode handler () de l'automate en transmettant l'événement en paramètre.
  • Le gestionnaire d'événements de l'automate CSupprElement.prototype.handler () est ainsi appelé. Selon l'état this.state de l'automate, il vérifie si l'événement est un de ceux susceptible de provoquer un changement d'état et procède au changement d'état si c'est le cas.
Inversion de contrôle pour gérer les événements