Coder proprement en JavaScript par l'exemple : upload d'image
Dans cet article il ne va pas être question d'expliquer l'utilité du point virgule (« semi-colon ») ; ou encore les bienfaits de l'opérateur d'égalité stricte (« strict equality operator ») === mais plutôt de vous démontrer par l'exemple comment produire du code que vous et les autres pourrez relire sans entrer dans les détails si cela n'est pas nécessaire.
Nous allons tout au long de cet article aborder plusieurs notions comme :
- La programmation par intention (« intentional programming ») ou le fait de rassembler et nommer chaque suite d'instruction dans une fonction pour rendre le code aisé à la relecture.
- La programmation par fonction de rappel (« function callback ») ou le fait de déléguer à une fonction externe ce qu'il va se passer à la fin d'une suite d'instruction.
- La programmation par entrée / sortie (« I/O ») ou le fait que chaque fonction doit clairement définir ce qu'elle attend en entrée (« inputs »), et ce qu'elle renvoit en sortie (« outputs »).
- La programmation asynchrone ou le fait d'attendre un évènement ou un temps précis sur un tour (« tick ») de la boucle d'évènement (« Event Loop ») avant l'exécution du code.
C'est parti pour apprendre tout ça à travers un exemple d'upload de fichier.
Sélectionner une image
Commençons par tout script JavaScript courant, une simple suite d'instruction se trouvant dans le champ lexical global (« global scope ») afin, dans notre exemple, de sélectionner une image stockée sur notre disque dur.
var inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = 'image/*';
document.getElementsByTagName('body')[0].appendChild(inputFile);
Une fois ce code exécuté, vous devriez voir un bouton pour choisir une image.
Dans le détail c'est assez simple :
- On affecte à la variable inputFile l'objet d'instance HTMLInputElement (représentant une balise <input>) en le créant via la méthode document.createElement.
- On affecte la propriété type ainsi que la propriété accept représentant les attributs type et accept de la balise <input>.
- Et on insère dans l'objet d'instance HTMLBodyElement (<body>), en passant à la méthode appendChild, l'élément que nous venons de créer.
Comprendre le DOM et les nœuds (« nodes »)
La méthode document.createElement crée des objets d'instance HTMLElement et retourne une référence vers ces objets qui ne sont pas encore placés dans le DOM. Ces objets sont des nœuds du document (page web) de type HTML. Si le premier paramètre passé à document.createElement est un élément HTML connu, l'objet est encore plus précis. Dans notre cas document.createElement('input') est un objet d'instance HTMLInputElement.
La méthode document.getElementsByTagName récupère un tableau d'instance HTMLCollection dont chaque élément est une référence à un HTMLElement déjà placé dans le DOM. Dans notre exemple, document.getElementsByTagName('body') retourne un objet HTMLCollection d'un seul élément puisque la balise <body> est unique dans le DOM et document.getElementsByTagName('body')[0] retourne donc cet unique élément d'instance plus précise HTMLBodyElement.
Chaque objet en JavaScript est créé à partir d'un prototype et y est lié. Chaque prototype est lui-même un objet qui est créé à partir d'un prototype et y est lié, etc. Tous les objets en dernier lieu sont chainés à Object. Par exemple ici :
- Pour HTMLBodyElement (<body>) la chaine prototypale est la suivante : HTMLBodyElement -> HTMLElement -> Element -> Node -> EventTarget -> Object
- Pour HTMLInputElement (<input>) la chaine prototypale est la suivante : HTMLInputElement -> HTMLElement -> Element -> Node -> EventTarget -> Object
Nos deux objets ont chacun des méthodes utilisables comme appendChild qu'ils accèdent de leurs prototypes aussi :
- La possibilité de pouvoir utiliser la méthode inputFile.toString vient de Object.
- La possibilité de pouvoir utiliser la méthode inputFile.addEventListener vient de EventTarget.
- La possibilité de pouvoir utiliser la méthode inputFile.textContent vient de Node.
- La possibilité de pouvoir utiliser la méthode inputFile.getElementsByTagName vient de Element.
- etc.
Il faut également comprendre que l'on manipule des références vers les éléments HTML du DOM et que tous les objets HTML chargés en mémoire dans le navigateur ne sont pas forcément tous dans le DOM. C'est le cas de notre objet HTMLInputElement qui n'est référencé comme enfant de HTMLBodyElement dans le DOM qu'à partir de la ligne 5. Avant cela, l'objet existe et est manipulable via sa référence inputFile mais il n'est pas dans le DOM. Tout objet dont la référence se perd dans le code (n'est pas utilisée), et qui n'a pas été référencé dans le DOM (injecté) est alors perdu.
Lire l'image sélectionnée
Il va maintenant être question de lire l'image que nous venons de sélectionner. C'est à dire de transformer chaque octet de données de l'image en une chaine de caractère exploitable par notre navigateur afin que celui-ci puisse afficher l'image.
Toujours dans notre champ lexical global et à la suite des précédentes instructions nous ajoutons ceci :
var reader = new FileReader();
reader.readAsDataURL(inputFile.files[0]);
En executant le code, nous obtenons alors l'erreur suivante : Uncaught TypeError: Failed to execute 'readAsDataURL' on 'FileReader': parameter 1 is not of type 'Blob'.
Le problème est le suivant : il n'y a aucuns octets représentant l'image de chargés dans inputFile.files[0] au moment ou readAsDataURL est exécuté. Il faut donc exécuter ce pend de code après avoir sélectionné l'image sinon inputFile ne contient pas d'image dans son tableau files.
Après la sélection...
Nous allons donc ajouter un écouteur d'évènement sur notre HTMLInputElement grâce à la méthode addEventListener disponible et exécuter par une fonction de rappel ce qu'il va se passer quand l'évènement ce produit. Voici donc à quoi va maintenant ressembler la partie qui est dans le champ lexical global.
var inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = 'image/*';
inputFile.addEventListener('change', function () {
var reader = new FileReader();
reader.readAsDataURL(inputFile.files[0]);
console.log('Done');
});
document.getElementsByTagName('body')[0].appendChild(inputFile);
On précise donc ici via 'change' à la ligne 5 que l'évènement que nous souhaitons écouter est le changement d'image dans l'élément HTMLInputElement. La fonction qui se trouve ensuite est celle qui va être exécutée par addEventListener quand l'image aura effectivement changée, à savoir : lire et convertir l'image.
Si vous testez ce code, vous devriez voir dans la console de votre navigateur (F12, onglet console) le message 'Done' (ligne 9) une fois l'évènement 'change' déclenché.
Comprendre la boucle d'évènement
Les instructions en JavaScript sont analysées de haut en bas et exécutées par la boucle d'évènement JavaScript (« Event Loop »). Quand une instruction JavaScript est analysée, elle est exécutée sur le tour (« tick ») courant, dans la pile (« stack ») courante. Chaque fois qu'une instruction fait appel à une fonction ou méthode, la pile se déplie et c'est au tour des instructions de cette fonction d'être exécutées sur le tour courant et ainsi de suite. Quand toutes les instructions d'une fonctions sont exécutées, la pile se replie jusqu'à ce que toutes les fonctions ai été exécutées de manière synchrone sur le même tour de la boucle d'évènement.
Cependant, quand une instruction est asynchrone, elle n'est pas exécutée aussitôt qu'elle est analysée mais elle part dans une autre pile que la pile courante. Cette pile est placée dans la file d'attente (« queue ») pour plus tard et sera exécutée sur un autre tour de la boucle. Dans notre exemple précédent, c'est le cas avec addEventListener dont la fonction de rappel (« callback ») ne sera exécutée par la boucle d'évènement que lorsque l'évènement « changement d'image » sera levé. Les instructions dans addEventListener seront reversées dans la file d'attente au moment où l'image changera pour être exécutées sitôt que la boucle pourra s'en occuper.
Cependant, ce n'est pas parce qu'une fonction dispose d'une fonction de rappel que celle-ci ne sera pas exécutée de manière synchrone, dans notre cas précédent c'est parce qu'il s'agit d'un évènement.
Ce qui provoque une exécution de code sur un autre tour de boucle parmi des instructions à la suite dans une fonction sont de manière non exhaustives les éléments suivants :
- setInterval, setTimeout ou setImediate.
- Les Évènements (load, click, ...)
- XMLHttpRequest ou les WebSockets
- requestAnimationFrame
- Des API HTML comme l'API File, ou l'API Web Database.
- etc.
Ainsi dans l'exemple suivant :
console.log('Test 1'); setInterval(function () { console.log('Test 2'); }, 0); console.log('Test 3');
La sortie produira dans l'ordre : 'Test 1', 'Test 3' sur un premier tour de boucle et 'Test 2' sur un autre.
Avec fonction de rappel asynchrone
Nous allons maintenant déporter ce qui est logique dans une fonction. Nous allons nommer cette fonction de manière à indiquer notre intention (« intentional programming ») pour comprendre ce qui est fait lors de la relecture du code.
Le développeur se référera à la partie « Déclaration » si cela lui est nécessaire, sinon il se contentera de relire la partie « Exécution ».
Déclaration
/**
* Select an image from your device library and allow you to define what do after.
* @param {selectImage~callback} afterSelection - Allow you to set what do after selection.
* @return {HTMLInputElement} The `HTMLInputElement` used to select photo from library.
*/
function selectImage(afterSelection) {
var inputFile = document.createElement('input');
inputFile.type = 'file';
inputFile.accept = 'image/*';
inputFile.addEventListener('change', function () {
if (afterSelection) {
/**
* What do after selecting image.
* @callback selectImage~callback
* @param {HTMLInputElement} inputFile - The `HTMLInputElement` used to select photo from library.
*/
afterSelection(inputFile);
}
});
return inputFile;
}
Notre fonction selectImage va nous permettre de créer le mécanisme de sélection d'image, de le mettre à disposition et de nous permettre de déléguer par fonction de rappel ce qu'il sera fait une fois que l'image aura été choisie. Par ailleurs, cette fonction retourne le HTMLInputElement qu'elle crée en retour immédiat et le fourni également en premier argument de la fonction de rappel.
Exécution
document.getElementsByTagName("body")[0].appendChild(
selectImage(function (inputFile) {
var reader = new FileReader();
reader.readAsDataURL(inputFile.files[0]);
console.log("Done");
})
);
Ainsi à la ligne 2 nous savons que le code va nous permettre de sélectionner une image et la fonction passée en argument va constituer ce qu'il se passera quand celle-ci aura été choisie. En outre selectImage retourne de manière synchrone via son mot clé return le HTMLInputElement ce qui permet de le passer à appendChild à la ligne 1.
Comprendre les commentaires JSDoc
Si le but de la programmation par intention est de permettre de relire plus facilement ce que le code fait dans la partie « Exécution », les commentaires JSDoc permettent quand à eux d'expliquer les entrées / sorties (« inputs » / « outputs » ou « I/O ») des fonctions définies dans la partie « Déclaration ».
Le but est de ne pas relire le contenu de la fonction (sauf s'il faut la modifier) mais de comprendre
- ce qu'elle fait,
- ce qu'elle à besoin en entrée et
- ce qu'elle retourne en sortie (ou ce qu'elle fournit à la fonction de rappel).
Bien qu'il ne soit pas obligatoire de respecter le schéma de mon exemple, cela vous permettra avec divers outils de générer une documentation de vos scripts automatiquement.
Afficher l'image
Augmentons un peu le code de retour de notre exemple précédent après l'utilisation de la fonction selectImage.
var reader = new FileReader(),
image = document.createElement('img');
reader.readAsDataURL(inputFile.files[0]);
image.src = reader.result;
document.getElementsByTagName('body')[0].appendChild(image);
console.log('Done');
Nous voyons à la ligne 7 que notre image est censée être affichée dans le DOM puisqu'elle est injectée dans la balise <body>. Pourtant ce n'est pas le cas car le reader n'a pas eu le temps de convertir le résultat avant de l'afficher dans la source ce qui fait qu'il ne se passe rien.
Attendre la fin de la lecture
De la même manière qu'avec notre objet HTMLInputElement, il va falloir écouter l'évènement 'load' qui exécutera une fonction de rappel quand l'image aura correctement été lue et convertie.
var reader = new FileReader();
reader.addEventListener('load', function () {
var image = document.createElement('img');
image.src = reader.result;
document.getElementsByTagName('body')[0].appendChild(image);
console.log('Done');
});
reader.readAsDataURL(inputFile.files[0]);
Il est important de savoir ce qui déclenche l'évènement. Dans le cas présent, c'est l'exécution de readAsDataURL à la ligne 11. Ce qui signifie que pour que le code de rappel dans 'load' puisse être exécuté, il faut qu'il ait été déclaré avant readAsDataURL sinon au moment ou celui-ci lèvera l'évènement 'load', il n'y aura pas encore d'écouteur d'évènement de déclaré.
Avec fonction de rappel asynchrone
Ré-écrivons ce code afin d'appeler les instructions d'exécution principale (dans le champ lexical global) par intention. Nous ajoutons donc au côté de selectImage une nouvelle fonction que nous appellerons readImage.
Déclaration
/* ... */
/**
* Convert an Image from device to a Data Url Base64 string.
* @param {HTMLInputElement} inputFile - Element used to read the source image.
* @param {readImage~callback} afterConvertion - Allow you to set what do after convertion.
*/
function readImage(inputFile, afterConvertion) {
var reader = new FileReader();
reader.addEventListener('load', function () {
var image = document.createElement('img');
image.src = reader.result;
if (afterConvertion) {
/**
* What do after converting image.
* @callback readImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {FileReader} reader - The `FileReader` used to convert the original Image.
*/
afterConvertion(image, reader);
}
});
reader.readAsDataURL(inputFile.files[0]);
}
Nous fournissons pour la fonction de rappel la possibilité de manipuler l'image avec en premier paramètre l'objet HTMLImageElement et en second paramètre l'objet FileReader. Nous choisissons cet ordre car il est plus probable qu'à l'utilisation, on souhaite manipuler l'image lue que réellement l'objet qui à permis sa conversion.
Exécution
var body = document.getElementsByTagName('body')[0];
body.appendChild(
selectImage(function (inputFile) {
readImage(inputFile, function (image) {
body.appendChild(image);
console.log(this.toString());
});
})
);
Après avoir mis la référence à <body> dans la variable body ligne 1, on peut comprendre à la lecture des ligne 4 et 5 que le code va nous permettre de choisir une image, de la lire puis de faire autre chose. En l'occurrence ici de l'ajouter au DOM. On peut également voir à la ligne 5 que nous avons uniquement décidé d'utiliser le paramètre image pour la suite.
Raccourci et conservation des contextes d'exécution avec ES6
On utilise la notation function () { /* ... */ } pour utiliser une fonction anonyme comme fonction de rappel dans le code destiné à l'exécution dans le champ lexical global (le contexte d'exécution le plus haut). Ré-écrire ce terme function est assez verbeux et re-crée un nouveau contexte d'exécution entre chaque fonction et redéfinie la valeur de this à chaque niveau. Il est possible de conserver le this du contexte d'exécution important en changeant la manière de nommer les fonctions de rappel avec =>. Le code précédent donnerait donc :
var body = this.document.getElementsByTagName('body')[0]; body.appendChild( selectImage(inputFile => { readImage(inputFile, image => { body.appendChild(image); console.log(this.toString()); }); }) );
Dans l'exemple avec =>, this vaut window alors que dans celui avec function, this vaut undefined.
Redimensionner l'image
L'image sélectionnée est parfois grande alors que nous souhaitons l'afficher dans une zone réduite. De plus si nous souhaitons l'uploader sur le serveur plus tard, il serait intéressant qu'elle soit moins lourde. On souhaiterait qu'elle fasse au maximum 800 pixels de large ou au maximum 600 pixels de haut tout en conservant son ratio initial.
Voici un petit algorithme capable de transformer notre image en utilisant pour cela un objet HTMLCanvasElement. Nous allons cette fois directement le créer dans une fonction, et l'utiliserons dans la partie exécution.
Déclaration
/* ... */
/**
* Reduced size of image and kept the ratio.
* @param {HTMLImageElement} imageSource - Element used as original image.
* @param {reduceImage~callback} afterResizing - Allow you to set what do after resizing.
*/
function reduceImage(imageSource, afterResizing) {
var canvas = document.createElement('canvas'),
imageResult = document.createElement('img'),
context,
maxWidth = 800,
maxHeight = 600,
width = imageSource.width,
height = imageSource.height;
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
context = canvas.getContext('2d');
context.drawImage(imageSource, 0, 0, width, height);
imageResult.src = canvas.toDataURL('image/jpg', 0.8);
/**
* What do after resizing image.
* @callback reduceImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {FileReader} reader - The `FileReader` used to convert the original Image.
*/
afterResizing(imageResult, canvas);
}
Nous pouvons lire comment cela fonctionne :
- Après avoir fixé les largeur et hauteur maximales aux lignes 12 et 13,
- nous trouvons quelle est la dimension la plus importante pour appliquer la valeur maximale à celle-ci ligne 17
- et affectons à l'autre dimension le ratio approprié pour réduire l'image (ligne 19 et 24).
- Après avoir défini la taille du canvas qui va nous permettre de créer l'image aux dimensions finales nous dessinons l'image à la ligne 33,
- puis nous affectons l'image dans un nouvel élément de type HTMLImageElement en transferrant dans sa source le contenu du canvas au format « jpeg » compressé à « 80% » ligne 35.
Note : actuellement ce code produira une image noire. C'est normal et cela va être expliqué dans la prochaine section.
Il ne nous reste plus qu'à utiliser notre nouvelle fonction.
Exécution
var body = document.getElementsByTagName('body')[0];
body.appendChild(
selectImage(function (inputFile) {
readImage(inputFile, function (image) {
reduceImage(image, function (imageResult) {
body.appendChild(imageResult);
console.log('Done');
});
});
})
);
Nous pouvons voir que les lignes 7 et 8 (anciennement 6 et 7) sont restées inchangées et qu'une nouvelle ligne d'intention a été ajoutée, à savoir reduceImage (image est tout de même devenu imageResult).
Pourquoi la cascade de fonction de rappel
Sachez que si vous trouvez la cascade de fonction de rappel difficile à lire, vous pouvez toujours nommer et affecter chaque étape dans des variables pour tout rendre à plat. Pour ma part, je ne trouve pas cela plus simple puisque cela inverse le sens de lecture par rapport à ce qui est fait en premier et ce qui apparaît dans le fichier de code.
var body, inputFile, readImageCallback, selectImageCallback; body = document.getElementsByTagName('body')[0]; readImageCallback = function (image) { body.appendChild(image); console.log('Done'); }; selectImageCallback = function (inputFile) { readImage(inputFile, readImageCallback); }; inputFile = selectImage(selectImageCallback); body.appendChild(inputFile);
Il serait possible de rétablir le sens de lectrue non plus en associant les fonctions en tant qu'expression (dans une variable) mais en tant que déclaration de fonction afin qu'elle soit analysée à l'entrée dans la fonction et non à la ligne de l'affectation à une variable.
var body, inputFile; body = document.getElementsByTagName("body")[0]; inputFile = selectImage(selectImageCallback); function selectImageCallback(inputFile) { readImage(inputFile, readImageCallback); }; function readImageCallback(image) { body.appendChild(image); console.log("Done"); }; body.appendChild(inputFile);
Ce que je n'aime pas personnellement car dans ce cas nous mélangeons déclaration et exécution. C'est à dire qu'il faudrait déclarer toutes les fonctions à partir de la troisième ligne et nous perdrions de nouveau le sens de lecture.
Éviter l'image noire
Si votre image réduite apparaît en noire dans le canvas c'est tout simplement parce qu'elle n'est pas chargée au moment ou elle est appliquée dans celui-ci. Il faut alors, comme c'était le cas avec l'objet FileReader attendre que l'image soit chargée avant d'exécuter la fonction de rappel.
/* ... */
/**
* Convert an Image from device to a Data Url Base64 string.
* @param {HTMLInputElement} inputFile - Element used to read the source image.
* @param {readImage~callback} afterConvertion - Allow you to set what do after convertion.
*/
function readImage(inputFile, afterConvertion) {
var reader = new FileReader();
reader.addEventListener('load', function () {
var image = document.createElement('img');
image.addEventListener('load', function () {
if (afterConvertion) {
/**
* What do after converting image.
* @callback readImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {FileReader} reader - The `FileReader` used to convert the original Image.
*/
afterConvertion(image, reader);
}
});
image.src = reader.result;
});
reader.readAsDataURL(inputFile.files[0]);
}
/* ... */
/**
* Reduced size of image, kept the ratio and return a miniature of original image.
* @param {HTMLImageElement} imageSource - Element used as original image.
* @param {reduceImage~callback} afterResizing - Allow you to set what do after resizing.
*/
function reduceImage(imageSource, afterResizing) {
var canvas = document.createElement('canvas'),
imageResult = document.createElement('img'),
context,
maxWidth = 800,
maxHeight = 600,
width = imageSource.width,
height = imageSource.height;
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
canvas.width = width;
canvas.height = height;
context = canvas.getContext('2d');
context.drawImage(imageSource, 0, 0, width, height);
imageResult.addEventListener('load', function () {
/**
* What do after resizing image.
* @callback reduceImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {FileReader} reader - The `FileReader` used to convert the original Image.
*/
afterResizing(imageResult, canvas);
});
imageResult.src = canvas.toDataURL('image/jpg', 0.8);
}
/* ... */
vous constaterez donc, ligne 14 pour readImage et ligne 67 pour reduceImage, que nous avons ajouté addEventListener pour l'évènement 'load' afin d'attendre que l'image soit complètement chargée avant de passer à la suite.
Externaliser des mécanismes réutilisables
Notre fonction reduceImage fait deux choses :
- elle nous délivre de nouvelles dimentions à partir de dimentions originales et
- elle crée une « miniature » de l'image originale avec un canvas.
Ces deux actions pourraient être scindées pour ajouter un niveau d'intention à la relecture.
/* ... */
/**
* Reduce size of 2D item and kept the ratio based on max width and height.
* @param {number} width - Original with of item.
* @param {number} height - Original height of item.
* @param {number} maxWidth - Maximal width of the output item.
* @param {number} maxHeight - Maximal height of the output item.
* @param {resizeWithSameRatio~callback} afterResizing - Allow you to set what do after resizing.
*/
function resizeWithSameRatio(width, height, maxWidth, maxHeight, afterResizing) {
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
/**
* What do after find the reduced value.
* @callback resizeWithSameRatio~callback
* @param {number} width - New with of item.
* @param {number} height - New height of item.
*/
afterResizing(width, height);
}
/**
* Create a reduced image from a more large image.
* @param {HTMLImageElement} imageSource - Element used as original image.
* @param {number} width - New image width.
* @param {number} height - New image height.
* @param {thumbnailWithCanvas~callback} afterResizing - Allow you to set what do after generating new image.
*/
function thumbnailWithCanvas(imageSource, width, height, afterResizing) {
var canvas = document.createElement('canvas'),
imageResult = document.createElement('img'),
context;
canvas.width = width;
canvas.height = height;
context = canvas.getContext('2d');
context.drawImage(imageSource, 0, 0, width, height);
imageResult.addEventListener('load', function () {
/**
* What do after render the reduced image.
* @callback resizeWithSameRatio~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {HTMLCanvasElement} canvas - The `HTMLCanvasElement` used to reduce the image.
*/
afterResizing(imageResult, canvas);
});
imageResult.src = canvas.toDataURL('image/jpg', 0.8);
}
/**
* Reduce size of image, keept the ratio and return a miniature of original image.
* @param {HTMLImageElement} imageSource - Element used as original image.
* @param {reduceImage~callback} afterResizing - Allow you to set what do after resizing.
*/
function reduceImage(imageSource, afterResizing) {
resizeWithSameRatio(imageSource.width, imageSource.height, 800, 600, function (width, height) {
thumbnailWithCanvas(imageSource, width, height, function (imageResult, canvas) {
/**
* What do after resizing image.
* @callback reduceImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {HTMLCanvasElement} canvas - The `HTMLCanvasElement` used to reduce the image.
*/
afterResizing(imageResult, canvas);
})
});
}
/* ... */
Dette technique ou comment trouver le bon niveau d'intention
Vous me direz : « Pourquoi ne pas encore re-scinder les fonctions resizeWithSameRatio ou thumbnailWithCanvas ? Dans ce cas là, où s'arrête le travail pour faciliter la relecture ? Il faut pour cela repérer en quoi la fonction pourrait se suffire à elle-même. En général les « inputs » / « outputs » sont une bonne indication car si vous êtes capable en sortie de produire un élément de même type que celui en entrée, c'est que vos instructions sont bonnes pour être groupées.
Il n'est pas non plus nécessaire de scinder une fonction qui pourrait l'être tant que la nécessité ne s'en fait pas ressentir. Il faut trouver le bon équilibre entre temps et utilité.
Certain outils vous permette de jauger la complexité des fonctions à l'aide de la complexité cyclomatique (« Cyclomatic Complexity ») ou du Ratio d'endettement qui donne un poids à chaque instruction et qui vous aide à grouper celle-ci en sous fonctions.
Paramètres d'entrée par passage d'objet
Vous aurez remarqué que pour la fonction resizeWithSameRatio il y a un nombre conséquent de paramètre à passer. Là première question à se poser est : « Est-il possible de diminuer ce nombre de paramètre ? » Ici on pourrait estimer que si la maxWidth et la maxHeight sont undefined alors on pourrait assigner des valeurs par défaut comme ceci :
/* ... */
function resizeWithSameRatio(width, height, maxWidth, maxHeight, afterResizing) {
var maxWidth = options.maxWidth || 800,
maxHeight = options.maxHeight || 600/* ... */
/* ... */
}
/* ... */
ce qui ne nous oblige plus à préciser réellement les valeurs en utilisant notre fonction comme ceci :
resizeWithSameRatio(1024, 768, undefined, undefined, function (width, height) {
/* ... */
});
Cependant vous noterez deux problèmes :
- Bien qu'il ne soit plus nécessaire d'indiquer les tailles maximales choisis pour la miniature, il est nécessaire de placer des valeurs à vide pour pouvoir appeler en dernier lieu, à la bonne position, la fonction de rappel.
- Il est impossible à la relecture de la partie « Exécution » de comprendre à quoi servent chaque paramètres passés. Il faut alors se référer à la partie « Déclaration ».
Note : vous remarquerez que j'ai utilisé undefined et non null pour signifier qu'il n'y avait pas de valeur de précisée. Si j'avais réellement voulu être en accord avec le type de paramètre attendu, j'aurais dû mettre NaN. Pour en savoir plus sur la différence entre null et undefined je vous propose de lire l'article Est-il si null cet undefined ?.
La manière la plus simple de rendre lisible les paramètres d'entrée, et de les rendre optionnels à l'appel est de n'utiliser pour unique entrée qu'un objet de la manière suivante :
/* ... */
/**
* Reduce size of 2D item and kept the ratio based on max width and height.
* @param {Object} options - All input values.
* @param {number} [options.width] - Original with of item.
* @param {number} [options.height] - Original height of item.
* @param {number} [options.maxWidth] - Maximal width of the output item.
* @param {number} [options.maxHeight] - Maximal height of the output item.
* @param {resizeWithSameRatio~callback} afterResizing - Allow you to set what do after resizing.
*/
function resizeWithSameRatio(options, afterResizing) {
var width = options.width || 0,
height = options.height || 0,
maxWidth = options.maxWidth || 800,
maxHeight = options.maxHeight || 600
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
/**
* What do after find the reduced value.
* @callback resizeWithSameRatio~callback
* @param {number} width - New with of item.
* @param {number} height - New height of item.
*/
afterResizing(width, height);
}
/* ... */
/**
* Create a reduced image from a more large image.
* @param {Object} options - All input values.
* @param {HTMLImageElement} [options.imageSource] - Element used as original image.
* @param {number} [options.width] - New image width.
* @param {number} [options.height] - New image height.
* @param {thumbnailWithCanvas~callback} afterResizing - Allow you to set what do after generating new image.
*/
function thumbnailWithCanvas(options, afterResizing) {
var imageSource = options.imageSource || document.createElement('img'),
width = options.width || 0,
height = options.height || 0,
canvas = document.createElement('canvas'),
imageResult = document.createElement('img'),
context;
canvas.width = width;
canvas.height = height;
context = canvas.getContext('2d');
context.drawImage(imageSource, 0, 0, width, height);
imageResult.addEventListener('load', function () {
/**
* What do after render the reduced image.
* @callback resizeWithSameRatio~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {HTMLCanvasElement} canvas - The `HTMLCanvasElement` used to reduce the image.
*/
afterResizing(imageResult, canvas);
});
imageResult.src = canvas.toDataURL('image/jpg', 0.8);
}
/* ... */
/**
* Reduce size of image, kept the ratio and return a miniature of original image.
* @param {HTMLImageElement} imageSource - Element used as original image.
* @param {reduceImage~callback} afterResizing - Allow you to set what do after resizing.
*/
function reduceImage(imageSource, afterResizing) {
resizeWithSameRatio({
height: imageSource.height,
width: imageSource.width
}, function (height, width) {
thumbnailWithCanvas({
imageSource: imageSource,
width: width,
height: height
}, function (canvas, imageResult) {
/**
* What do after resizing image.
* @callback reduceImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {HTMLCanvasElement} canvas - The `HTMLCanvasElement` used to reduce the image.
*/
afterResizing(imageResult, canvas);
})
});
}
/* ... */
Vous remarquerez ainsi que lors de l'appel des fonctions resizeWithSameRatio ligne 85 et thumbnailWithCanvas ligne 89 nous ne passons que les paramètres nécessaires dans l'ordre voulu et que nous savons à présent côté « Exécution » à quoi correspond chaque paramètre d'entrée puisqu'ils sont nommés.
Passage de paramètres d'entrée par décomposition avec ES6
Vous constaterez dans l'exemple précédent qu'il est nécessaire de consacrer plusieurs lignes afin de constituer des fonction de substitution (« fallback ») au cas où la propriété n'ait pas été passée dans l'objet des entrées (ligne 13 à 16 pour resizeWithSameRatio). Les fonctions de substitution sont assignées comme raccourci grâce à || : si la partie de gauche une fois convertie en booléen renvoi true, alors on affecte cette valeur dans sa forme originale, si elle renvoi false alors on passe à l'élément de droite et on l'affecte.
Il existe un meilleur raccourci en ES6 qui consiste à directement créer les éléments vides lors de la définition de la fonction par décomposition :
/* ... */ function resizeWithSameRatio({ width = 0, height = 0, maxWidth = 800, maxHeight = 600 } = {}, afterResizing) { if (width > height) { if (width > maxWidth) { height *= maxWidth / width; width = maxWidth; } } else { if (height > maxHeight) { width *= maxHeight / height; height = maxHeight; } } /* ... */ } /* ... */
afin de l'appeler comme ceci :
resizeWithSameRatio({ height: imageSource.height, width: imageSource.width }, function (width, height) { /* ... */ });
Arguments de sortie avec référence par nom au lieu de référence par position
De la même manière que l'on souhaite uniquement passer les paramètres d'entrée dont nous avons besoin pour utiliser une fonction, on souhaiterait pouvoir utiliser uniquement les arguments de sortie dont nous avons besoin sans avoir à invoquer des variables que nous n'utiliserons pas dans la fonction de rappel. Cela est possible en utilisant des références par nom comme dans AngularJS au lieu de références par position.
Paramètres vs. arguments
Dans la littérature JavaScript, vous trouverrez souvent les termes paramètres et arguments comme interchangeables car ils représentent les éléments à passer aux fonctions ou fournie par celle-ci. En réalité, ces deux termes sont bien différent puisque les paramètres désignent ce que l'on passe à la fonction lors de son exécution alors que les arguments désignent ce qui est fourni par la fonction lors de sa déclaration. Aussi dans le code ci-dessous, 1024, 768, undefined et undefined sont les paramètres passées et width et height sont les arguments fournis.
resizeWithSameRatio(1024, 768, undefined, undefined, function (width, height) { /* ... */ });
Notre code de la partie précédente devient alors celui-ci :
/* ... */
/**
* Reduce size of 2D item and kept the ratio based on max width and height.
* @param {Object} options - All input values.
* @param {number} [options.width] - Original with of item.
* @param {number} [options.height] - Original height of item.
* @param {number} [options.maxWidth] - Maximal width of the output item.
* @param {number} [options.maxHeight] - Maximal height of the output item.
* @param {resizeWithSameRatio~callback} afterResizing - Allow you to set what do after resizing.
*/
function resizeWithSameRatio(options, afterResizing) {
var width = options.width || 0,
height = options.height || 0,
maxWidth = options.maxWidth || 800,
maxHeight = options.maxHeight || 600
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
/**
* What do after find the reduced value.
* @callback resizeWithSameRatio~callback
* @param {number} [width] - New with of item.
* @param {number} [height] - New height of item.
*/
Function.namedParameters(afterResizing, {
width: width,
height: height
});
}
/* ... */
/**
* Create a reduced image from a more large image.
* @param {Object} options - All input values.
* @param {HTMLImageElement} [options.imageSource] - Element used as original image.
* @param {number} [options.width] - New image width.
* @param {number} [options.height] - New image height.
* @param {thumbnailWithCanvas~callback} afterResizing - Allow you to set what do after generating new image.
*/
function thumbnailWithCanvas(options, afterResizing) {
var imageSource = options.imageSource || document.createElement("img"),
width = options.width || 0,
height = options.height || 0,
canvas = document.createElement('canvas'),
imageResult = document.createElement('img'),
context;
canvas.width = width;
canvas.height = height;
context = canvas.getContext('2d');
context.drawImage(imageSource, 0, 0, width, height);
imageResult.addEventListener('load', function () {
/**
* What do after render the reduced image.
* @callback resizeWithSameRatio~callback
* @param {HTMLImageElement} [image] - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {HTMLCanvasElement} [canvas] - The `HTMLCanvasElement` used to reduce the image.
*/
Function.namedParameters(afterResizing, {
imageResult: imageResult,
canvas: canvas
});
});
imageResult.src = canvas.toDataURL('image/jpg', 0.8);
}
/* ... */
/**
* Reduce size of image, kept the ratio and return a miniature of original image.
* @param {HTMLImageElement} imageSource - Element used as original image.
* @param {reduceImage~callback} afterResizing - Allow you to set what do after resizing.
*/
function reduceImage(imageSource, afterResizing) {
resizeWithSameRatio({
height: imageSource.height,
width: imageSource.width
}, function (height, width) {
thumbnailWithCanvas({
imageSource: imageSource,
width: width,
height: height
}, function (canvas, imageResult) {
/**
* What do after resizing image.
* @callback reduceImage~callback
* @param {HTMLImageElement} image - The `HTMLImageElement` that content the correct Data Url Base64 source.
* @param {HTMLCanvasElement} canvas - The `HTMLCanvasElement` used to reduce the image.
*/
afterResizing(imageResult, canvas);
})
});
}
/* ... */
Vous constaterez que nous faisons alors appel à Function.namedParameters qui nous permet de créer des fonctions de rappel qui nous renvoi les arguments dans l'ordre que nous le souhaitons et cela en s'appuyant sur le nom du paramètre. Ainsi nous pouvons comme c'est le cas ligne 94 et ligne 99 intervertir les arguments ou seulement appeler le second en premier sans que cela ne brise notre code.
Comment simuler une fonction de rappel par nom de paramètre
Basiquement le code utiliser par Function.namedParameters pourrait être le suivant.
Function.prototype.namedParameters = function (type, list, error) { var params, callback = type, regex = /^(?:function *[a-zA-Z0-9_$]*)? *\(? *([a-zA-Z0-9_$, ]*) *\)?/g, functions = list || {}; if (type instanceof Array) { callback = type.pop(); params = type; } else { params = ((regex.exec(callback.toString()) || [1]).slice(1)[0] || "").split(',') } params = params.map(function (item) { var key = item.trim(); if (functions.hasOwnProperty(key)) { return functions[key]; } else { return (error && error(key)) || new Error('Named parameter `' + key + "` doesn't exist."); } }); callback.apply(this, params); };
Vous trouverez tout ce qu'il vous faut dans l'article JavaScript et fonction de rappel par nom de paramètre comme dans AngularJS pour manipuler Function.prototype.namedParameters et comprendre comment il fonctionne.
Uploader l'image
La dernière étape va être d'uploader l'image pour permettre à un code hébergé sur le serveur de la récupérer et de l'enregistrer. Dans notre exemple, nous allons supposer que nous avons un code côté serveur qui attend une requête XMLHttpRequest (Ajax) en POST et qui s'attend à recevoir dans la variable image le contenu de l'image en base64. Si tout se passe bien, le contenu "Done" est renvoyé. (Pour les besoins de l'article nous avons simulé cela avec l'adresse https://www.mocky.io/v2/5773cc3c0f0000950c597af9).
Upload, gestion d'erreur et de succès
Nous allons de nouveau créer une fonction pour gérer l'upload comme ceci :
Déclaration
/* ... */
/**
* Upload an image with an XHR POST request via the `image` variable.
* @param {Object} options - All input values.
* @param {string} options.url - Url of service that handle the image POST request.
* @param {HTMLImageElement} options.image - The `HTMLImageElement` that contain the image.
* @param {uploadImage~callback} afterUploading - Allow you to set what do after upload of image.
*/
function uploadImage(options, afterUploading) {
var xhr = new XMLHttpRequest(),
formData = new FormData();
url = options.url || new Error('`options.url` parameter invalid for `uploadImage` function.');
image = options.image || new Error('`options.image` parameter invalid for `uploadImage` function.');
if (url instanceof Error) {
throw url;
}
if (image instanceof Error) {
throw image;
}
formData.append('image', image.src);
xhr.open('POST', url, true);
xhr.addEventListener('load', function () {
if (xhr.status < 200 && xhr.status >= 400) {
return Function.namedParameters(afterUploading, {
error: new Error('XHR connection error for `uploadImage` function.'),
response: null
});
}
/**
* What do after upload the image.
* @callback uploadImage~callback
* @param {Error} [error] - Return `null` if no error occur else return an `Error` object.
* @param {string} [response] - Return the content of XHR response if no error occur, else return `null`.
*/
Function.namedParameters(afterUploading, {
error: null,
response: xhr.responseText
});
});
xhr.addEventListener('error', function (test) {
Function.namedParameters(afterUploading, {
error: new Error('XHR connection error for `uploadImage` function.'),
response: null
});
});
xhr.send(formData);
}
/* ... */
Nous créons donc :
- une instance xhr de XMLHttpRequest ligne 11 que
- nous paramétrons en POST à la ligne 25 à laquelle nous ajoutons
- une écoute en cas de réussite (ligne 27)
- et une écoute en cas d'échec (ligne 47)
- avant d'envoyer les données ligne 54 à l'aide d'un container FormData.
La nouveauté ici c'est la gestion des erreurs, nous pouvons constater que cette fois, les propriétés options.url et options.image de l'argument options sont obligatoires ce qui signifie qu'il n'ai pas possible de décider d'une valeur par défaut ligne 14 et 15. C'est pour cela que nous créons des Error afin de les lancer et ainsi créer des exceptions aux lignes 18 et 21. Le mot clé throw arrête toute suite au script et renvoi des exceptions qui sont ainsi récupérables via un mécanisme de try / catch mais, plus intéressant, qui permettent au développeur utilisant la fonction de voir ce qui ne va pas.
Exécution
Et voici le code final à relire pour comprendre rapidement ce qui se passe ici. Il ne sera pas nécessaire d'entrer dans le détail à moins d'améliorer des fonctionnalités.
var body = document.getElementsByTagName('body')[0];
body.appendChild(
selectImage(function (inputFile) {
readImage(inputFile, function (image) {
reduceImage(image, function (imageResult) {
uploadImage({
url: 'https://www.mocky.io/v2/5773cc3c0f0000950c597af9',
image: imageResult
}, function (error, response) {
var data = document.createElement('div');
data.textContent = (error) ? error : response;
body.appendChild(data);
body.appendChild(imageResult);
});
});
});
})
);
Nous utilisons donc notre nouvelle fonction à la ligne 6 avec nos deux paramètres obligatoires. Dans notre exemple nous utilisons l'error pour définir quel message nous allons renvoyer dans le DOM (ligne 11).
Erreur ou exception ?
En JavaScript les erreurs sont une déclinaison d'objet à elles seules et se créent en utilisant la syntaxe suivante new Error(). Elles se manipulent comme un objet et peuvent être retournées avec return ou mises dans des variables.
Les exceptions quant à elles sont des erreurs qui sont lancées ou jetées avec le mot clé throw soit throw new Error() et ne peuvent plus être manipulées. Elles mettent ainsi fin aux contextes d'exécution les un après les autres en remontant jusqu'à afficher une erreur dans la console. Elles peuvent être interceptées avec try et l'erreur qu'elles remontent peut être manipulée via catch (exception).
Vous trouverez tout ce qu'il vous faut dans l'article Gérer les erreurs et les exceptions en JavaScript à propos des erreurs.
Isoler son code
Depuis le début, nous créons tout dans le champ lexical global ce qui a pour conséquence d'écraser d'éventuelles variables déjà existantes à travers tous les scripts. Il convient donc d'isoler chaque code dans un contexte d'exécution dédié. Cela est possible en utilisant une fonction que nous exécutons immédiatement, ou en créant une classe (« instantiate object ») spécifique pour ranger toutes les fonctions, ou encore en rangeant notre fonction dans un espace de nom (« namespace ») dédié.
Fonction anonyme auto-exécutée
Nous allons nous contenter du premier point ici en créant une simple fonction anonyme (sans nom) et en l'exécutant immédiatement :
;(function () {
/* Intégralité du code déjà existant ici. */
}());
ainsi, aucune fonctions et variables crées dans ce contexte d'exécution ne sera accessible ailleurs et n'ira écraser des variables déjà existante.
Note : le bout de code que nous avons attaché au prototype de Function sera quand à lui accessible partout car Function fait déjà partie du contexte d'exécution global (champ lexical global).
Classe spécifique
Il est également possible d'accrocher le code utile dans une classe JavaScript que nous instancierons afin d'exécuter le code. Nous pourrions créer alors deux types de fonctions :
- Les fonctions privées qui ne seront accessibles que dans le champ lexical de la classe.
- Les fonctions publiques qui seront attachées à this et qui seront donc accessibles depuis l'objet instancié.
Cela pourrait ressemblé à ceci :
var UploadImage = function () {
var privates = {},
publics = this;
privates.selectImage = function (afterSelectCallback) { /* ... */ };
privates.readImage = function (inputFile, callback) { /* ... */ };
privates.resizeWithSameRatio = function (options, callback) { /* ... */ };
privates.thumbnailWithCanvas = function (options, callback) { /* ... */ };
privates.reduceImage = function (imageSource, callback) { /* ... */ };
privates.uploadImage = function (options, callback) { /* ... */ };
publics.init = function () {
var body = document.getElementsByTagName("body")[0];
body.appendChild(
privates.selectImage(function (inputFile) {
privates.readImage(inputFile, function (image) {
privates.reduceImage(image, function (imageResult) {
privates.uploadImage({
url: "https://www.mocky.io/v2/5773cc3c0f0000950c597af9",
image: imageResult
}, function (error, response) {
var data = document.createElement("div");
data.textContent = (error) ? error : response;
body.appendChild(data);
body.appendChild(imageResult);
});
});
});
})
);
};
};
Avec la ligne d'instanciation :
new UploadImage().init();
Exemple complet sur Codepen
Je finirais par vous fournir un exemple fonctionnel de ce que nous venons de voir (avec une fonction anonyme comme champ lexical). C'est à vous de jouer maintenant !
Vous pouvez lire et tester le résultat final sur ce Codepen
Et vous ?
Je vous laisse décider lesquels de ces conseils peuvent vous apporter de l'aide et lesquels ne le feront pas et vous donne rendez-vous dans les commentaires pour partager votre expérience sur la question ou éclaircir des zones d'ombre dans cet article.