![Search icon](https://hackernoon.imgix.net/search-new.png?w=19&h=19)
En 2013, j'ai décidé de créer un ensemble d'outils minimalistes pour le développement d'applications Web. Le meilleur résultat de ce processus est peut-être gotoB , un framework frontend JS pur côté client écrit en 2 000 lignes de code.
J'ai été motivé à écrire cet article après avoir lu des articles intéressants rédigés par des auteurs de frameworks frontend très réussis :
Ce qui m'a enthousiasmé dans ces articles, c'est qu'ils parlent de l'évolution des idées derrière ce qu'ils construisent ; la mise en œuvre n'est qu'un moyen de les rendre réelles, et les seules fonctionnalités abordées sont celles qui sont si essentielles qu'elles représentent les idées elles-mêmes.
De loin, l'aspect le plus intéressant de ce qui est ressorti de gotoB est les idées qui ont émergé suite aux défis de sa construction. C'est ce que je veux aborder ici.
Parce que j'ai construit le framework à partir de zéro et que j'essayais d'atteindre à la fois le minimalisme et la cohérence interne, j'ai résolu quatre problèmes d'une manière qui, je pense, est différente de la manière dont la plupart des frameworks résolvent les mêmes problèmes.
Ces quatre idées sont celles que je souhaite partager avec vous maintenant. Je ne le fais pas pour vous convaincre d'utiliser mes outils (même si vous êtes le bienvenu !), mais plutôt dans l'espoir que ces idées pourraient vous intéresser.
Toute application Web doit créer du balisage (HTML) à la volée, en fonction de l'état de l'application.
Cela s'explique mieux avec un exemple : dans une application de liste de tâches ultra-simple, l'état pourrait être une liste de tâches : ['Item 1', 'Item 2']
. Étant donné que vous écrivez une application (par opposition à une page statique), la liste de tâches doit pouvoir changer.
Étant donné que l'état change, le code HTML qui constitue l'interface utilisateur de votre application doit changer en fonction de l'état. Par exemple, pour afficher vos tâches, vous pouvez utiliser le code HTML suivant :
<ul> <li>Item 1</li> <li>Item 2</li> </ul>
Si l'état change et qu'un troisième élément est ajouté, votre état ressemblera désormais à ceci : ['Item 1', 'Item 2', 'Item 3']
; ensuite, votre HTML devrait ressembler à ceci :
<ul> <li>Item 1</li> <li>Item 2</li> <li>Item 3</li> </ul>
Le problème de la génération de HTML en fonction de l'état de l'application est généralement résolu avec un langage de création de modèles , qui insère des constructions de langage de programmation (variables, conditions et boucles) dans du pseudo-HTML qui est ensuite étendu en HTML réel.
Par exemple, voici deux manières de procéder dans différents outils de création de modèles :
// Assume that `todos` is defined and equal to ['Item 1', 'Item 2', 'Item 3'] // Moustache <ul> {{#todos}} <li>{{.}}</li> {{/todos}} </ul> // JSX <ul> {todos.map((item, index) => ( <li key={index}>{item}</li> ))} </ul>
Je n'ai jamais aimé ces syntaxes qui apportaient de la logique au HTML. Réalisant que la création de modèles nécessitait de la programmation et voulant éviter d'avoir une syntaxe distincte pour cela, j'ai décidé d'apporter du HTML au js, en utilisant des littéraux d'objet . Ainsi, je pouvais simplement modéliser mon HTML sous forme de littéraux d'objet :
['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]]
Si je voulais ensuite utiliser l'itération pour générer la liste, je pourrais simplement écrire :
['ul', items.map ((item) => ['li', item])]
Et ensuite, utilisez une fonction qui convertirait cet objet littéral en HTML. De cette façon, tout le template peut être réalisé en JS, sans aucun langage de template ou de transpilation. J'utilise le nom liths pour décrire ces tableaux qui représentent du HTML.
À ma connaissance, aucun autre framework JS n'aborde la création de modèles de cette manière. J'ai fait quelques recherches et j'ai trouvé JSONML , qui utilise presque la même structure pour représenter le HTML dans les objets JSON (qui sont presque les mêmes que les littéraux d'objet JS), mais je n'ai trouvé aucun framework construit autour de lui.
Mithril et Hyperapp se rapprochent assez de l’approche que j’ai utilisée, mais ils utilisent toujours des appels de fonction pour chaque élément.
// Mithril m("ul", [ m("li", "Item 1"), m("li", "Item 2") ]) // hyperapp h("ul", [ h("li", "Item 1"), h("li", "Item 2") ])
L’approche consistant à utiliser des littéraux d’objet a bien fonctionné pour HTML, je l’ai donc étendue au CSS et je génère désormais également tout mon CSS via des littéraux d’objet.
Si, pour une raison quelconque, vous vous trouvez dans un environnement où vous ne pouvez pas transpiler JSX ou utiliser un langage de création de modèles et que vous ne souhaitez pas concaténer de chaînes, vous pouvez utiliser cette approche à la place.
Je ne suis pas sûr que l'approche Mithril/Hyperapp soit meilleure que la mienne ; je trouve que lorsque j'écris de longs littéraux d'objet représentant des liths, j'oublie parfois une virgule quelque part et cela peut parfois être difficile à trouver. À part cela, je n'ai vraiment rien à redire. Et j'aime le fait que la représentation pour HTML soit à la fois 1) des données et 2) en JS. Cette représentation peut en fait fonctionner comme un DOM virtuel, comme nous le verrons lorsque nous arriverons à l'idée n°4.
Détail bonus : si vous souhaitez générer du HTML à partir de littéraux d'objet, vous n'avez qu'à résoudre les deux problèmes suivants :
Je n'ai jamais été fan des composants. Structurer une application autour de composants nécessite de placer les données appartenant au composant à l'intérieur du composant lui-même. Cela rend difficile, voire impossible, le partage de ces données avec d'autres parties de l'application.
Dans chaque projet sur lequel j'ai travaillé, j'ai constaté que j'avais toujours besoin que certaines parties de l'état de l'application soient partagées entre des composants assez éloignés les uns des autres. Un exemple typique est le nom d'utilisateur : vous pourriez en avoir besoin dans la section compte, et également dans l'en-tête. Alors, où se trouve le nom d'utilisateur ?
J'ai donc décidé très tôt de créer un objet de données simple ( {}
) et d'y placer tout mon état. Je l'ai appelé le store . Le store contient l'état de toutes les parties de l'application et peut donc être utilisé par n'importe quel composant.
Cette approche était quelque peu hérétique en 2013-2015, mais elle a depuis gagné en prévalence et même en domination.
Ce qui me semble encore assez nouveau, c'est que j'utilise des chemins pour accéder à n'importe quelle valeur à l'intérieur du magasin. Par exemple, si le magasin est :
{ user: { firstName: 'foo' lastName: 'bar' } }
Je peux utiliser un chemin pour accéder (par exemple) au lastName
, en écrivant B.get ('user', 'lastName')
. Comme vous pouvez le voir, ['user', 'lastName']
est le chemin vers 'bar'
. B.get
est une fonction qui accède au store et renvoie une partie spécifique de celui-ci, indiquée par le chemin que vous passez à la fonction.
Contrairement à ce qui précède, la méthode standard pour accéder aux propriétés réactives consiste à les référencer via une variable JS. Par exemple :
// Svelte let { firstName, lastName } = $props(); firstName = 'foo'; lastName = 'bar'; // Knockout const firstName = ko.observable('foo'); const lastName = ko.observable('bar'); // mobx class UserStore { firstName = 'foo'; lastName = 'bar'; constructor() { makeAutoObservable(this); } } const userStore = new UserStore(); // SolidJS const [firstName, setFirstName] = createSignal('foo'); const [lastName, setLastName] = createSignal('bar');
Cela nécessite cependant que vous conserviez une référence à firstName
et lastName
(ou userStore
) partout où vous avez besoin de cette valeur. L'approche que j'utilise nécessite uniquement que vous ayez accès au magasin (qui est global et disponible partout) et vous permet d'y avoir un accès précis sans définir de variables JS pour eux.
Immutable.js et la base de données en temps réel Firebase font quelque chose de beaucoup plus proche de ce que j'ai fait, bien qu'ils fonctionnent sur des objets distincts. Mais vous pourriez potentiellement les utiliser pour tout stocker dans un seul endroit qui pourrait être adressable de manière granulaire.
// Immutable.js let store = Map({ user: Map({ firstName: 'foo', lastName: 'bar' }) }); const firstName = store.getIn(['user', 'firstName']); // 'foo' // Firebase const db = firebase.database(); db.ref('user').set({ firstName: 'foo', lastName: 'bar' }); db.ref('user/firstName').once('value').then(snapshot => { const firstName = snapshot.val(); // 'foo' });
Avoir mes données dans un magasin accessible globalement et auquel on peut accéder de manière granulaire via des chemins est un modèle que j'ai trouvé extrêmement utile. Chaque fois que j'écris const [count, setCount] = ...
ou quelque chose comme ça, cela me semble redondant. Je sais que je pourrais simplement faire B.get ('count')
chaque fois que j'ai besoin d'y accéder, sans avoir à déclarer et à transmettre count
ou setCount
.
Si l'idée n°2 (un magasin global accessible via des chemins) libère les données des composants, l'idée n°3 est la façon dont j'ai libéré le code des composants. Pour moi, c'est l'idée la plus intéressante de cet article. La voici !
Notre état est une donnée qui, par définition, est mutable (pour ceux qui utilisent l'immuabilité, l'argument tient toujours : vous voulez toujours que la dernière version de l'état change, même si vous conservez des instantanés des anciennes versions de l'état). Comment pouvons-nous changer l'état ?
J'ai décidé d'utiliser des événements. J'avais déjà des chemins vers le magasin, donc un événement pourrait être simplement la combinaison d'un verbe (comme set
, add
ou rem
) et d'un chemin. Donc, si je voulais mettre à jour user.firstName
, je pourrais écrire quelque chose comme ceci :
B.call ('set', ['user', 'firstName'], 'Foo')
C'est certainement plus verbeux que d'écrire :
user.firstName = 'Foo';
Mais cela m'a permis d'écrire du code qui répondrait à un changement dans user.firstName
. Et c'est là l'idée cruciale : dans une interface utilisateur, il existe différentes parties qui dépendent de différentes parties de l'état. Par exemple, vous pourriez avoir ces dépendances :
user
et currentView
user
items
La grande question à laquelle j'ai été confrontée était la suivante : comment mettre à jour l'en-tête et la section du compte lorsque user
change, mais pas lorsque items
changent ? Et comment gérer ces dépendances sans avoir à effectuer des appels spécifiques tels que updateHeader
ou updateAccountSection
? Ces types d'appels spécifiques représentent la « programmation jQuery » dans sa forme la plus difficile à maintenir.
Ce qui me semblait être une meilleure idée était de faire quelque chose comme ceci :
B.respond ('set', [['user'], ['currentView']], function (user, currentView) { // Update the header }); B.respond ('set', ['user'], function (user) { // Update the account section }); B.respond ('set', ['items'], function (items) { // Update the todo list });
Ainsi, si un événement set
est appelé pour user
, le système d'événements notifiera toutes les vues intéressées par ce changement (section en-tête et compte), tout en laissant les autres vues (liste de tâches) intactes. B.respond
est la fonction que j'utilise pour enregistrer les répondeurs (qui sont généralement appelés « écouteurs d'événements » ou « réactions »). Notez que les répondeurs sont globaux et ne sont liés à aucun composant ; ils n'écoutent cependant que les événements set
sur certains chemins.
Maintenant, comment un événement change
est-il appelé en premier lieu ? Voici comment je l'ai fait :
B.respond ('set', '*', function () { // Assume that `path` is the path on which set was called B.call ('change', path); });
Je simplifie un peu, mais c'est essentiellement ainsi que cela fonctionne dans gotoB.
Ce qui rend un système d'événements plus puissant que de simples appels de fonctions, c'est qu'un appel d'événement peut exécuter 0, 1 ou plusieurs morceaux de code, alors qu'un appel de fonction appelle toujours exactement une fonction . Dans l'exemple ci-dessus, si vous appelez B.call ('set', ['user', 'firstName'], 'Foo');
, deux morceaux de code sont exécutés : celui qui modifie l'en-tête et celui qui modifie la vue du compte. Notez que l'appel à update firstName
ne se soucie pas de savoir qui écoute cela. Il fait simplement son travail et laisse le répondeur récupérer les modifications.
Les événements sont si puissants que, d'après mon expérience, ils peuvent remplacer des valeurs calculées, ainsi que des réactions. En d'autres termes, ils peuvent être utilisés pour exprimer tout changement devant se produire dans une application.
Une valeur calculée peut être exprimée avec un répondeur d'événements. Par exemple, si vous souhaitez calculer un fullName
et que vous ne souhaitez pas l'utiliser dans le magasin, vous pouvez procéder comme suit :
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; // Do something with `fullName` here. });
De même, des réactions peuvent être exprimées avec un répondant. Considérez ceci :
B.respond ('set', 'user', function () { var user = B.get ('user'); var fullName = user.firstName + ' ' + user.lastName; document.getElementById ('header').innerHTML = '<h1>Hello, ' + fullName + '</h1>'; });
Si vous ignorez une minute la concaténation gênante de chaînes pour générer du HTML, ce que vous voyez ci-dessus est un répondeur exécutant un « effet secondaire » (dans ce cas, la mise à jour du DOM).
(Remarque : quelle serait une bonne définition d'un effet secondaire, dans le contexte d'une application Web ? Pour moi, cela se résume à trois choses : 1) une mise à jour de l'état de l'application ; 2) une modification du DOM ; 3) l'envoi d'un appel AJAX).
J'ai découvert qu'il n'y avait vraiment pas besoin d'un cycle de vie séparé qui mette à jour le DOM. Dans gotoB, il existe des fonctions de réponse qui mettent à jour le DOM à l'aide de certaines fonctions d'assistance. Ainsi, lorsque user
change, tout répondeur (ou plus précisément, fonction d'affichage , puisque c'est le nom que je donne aux répondeurs chargés de mettre à jour une partie du DOM) qui en dépend s'exécutera, générant un effet secondaire qui finit par mettre à jour le DOM.
J'ai rendu le système d'événements prévisible en lui faisant exécuter les fonctions de réponse dans le même ordre, et une à la fois. Les répondeurs asynchrones peuvent toujours s'exécuter de manière synchrone, et les répondeurs qui viennent « après » eux les attendront.
Des modèles plus sophistiqués, où vous devez mettre à jour l'état sans mettre à jour le DOM (généralement pour des raisons de performances), peuvent être ajoutés en ajoutant des verbes muets , comme mset , qui modifient le magasin mais ne déclenchent aucun répondeur. De plus, si vous devez faire quelque chose sur le DOM après un redessin, vous pouvez simplement vous assurer que ce répondeur a une faible priorité et s'exécute après tous les autres répondeurs :
B.respond ('set', 'date', {priority: -1000}, function () { var datePicker = document.getElementById ('datepicker'); // Do something with the date picker });
L'approche ci-dessus, qui consiste à disposer d'un système d'événements utilisant des verbes et des chemins et un ensemble de répondeurs globaux qui sont mis en correspondance (exécutés) par certains appels d'événements, présente un autre avantage : chaque appel d'événement peut être placé dans une liste. Vous pouvez ensuite analyser cette liste lorsque vous déboguez votre application et suivre les modifications apportées à l'état.
Dans le contexte d'un frontend, voici ce que permettent les événements et les répondeurs :
Voilà ce qu'ils permettent (d'après mon expérience) de faire sans :
En fait, il s'agit simplement d'appels d'événements et de répondeurs, certains répondeurs ne s'occupent que des vues, et d'autres d'autres opérations. Tous les éléments internes du framework utilisent simplement l'espace utilisateur .
Si vous êtes curieux de savoir comment cela fonctionne dans gotoB, vous pouvez consulter cette explication détaillée .
La liaison de données bidirectionnelle semble aujourd'hui assez dépassée. Mais si vous prenez une machine à remonter le temps jusqu'en 2013 et que vous abordez à partir des premiers principes le problème de redessiner le DOM lorsque l'état change, qu'est-ce qui semblerait plus raisonnable ?
En effet, l’option 2, qui est le flux de données unidirectionnel de l’état vers le DOM, semble plus compliquée et inefficace.
Concrétisons maintenant cela : dans le cas d'une <input>
ou <textarea>
interactive qui est focalisée, vous devez recréer des parties du DOM à chaque frappe de touche de l'utilisateur ! Si vous utilisez des flux de données unidirectionnels, chaque changement dans l'entrée déclenche un changement d'état, qui redessine ensuite l' <input>
pour qu'elle corresponde exactement à ce qu'elle devrait être.
Cela place la barre très haut pour les mises à jour du DOM : elles doivent être rapides et ne pas gêner l'interaction de l'utilisateur avec les éléments interactifs. Ce n'est pas un problème facile à résoudre.
Maintenant, pourquoi les données unidirectionnelles de l'état vers le DOM (JS vers HTML) ont-elles gagné ? Parce que c'est plus facile à comprendre. Si l'état change, peu importe d'où vient ce changement (cela peut être un rappel AJAX apportant des données du serveur, une interaction de l'utilisateur, un minuteur). L'état change (ou plutôt, est muté ) de la même manière toujours. Et les changements de l'état se répercutent toujours dans le DOM.
Alors, comment effectuer des mises à jour du DOM de manière efficace sans gêner l'interaction de l'utilisateur ? Cela se résume généralement à effectuer le nombre minimum de mises à jour du DOM qui permettront d'accomplir le travail. C'est ce qu'on appelle généralement « diffing », car vous établissez une liste de différences dont vous avez besoin pour prendre une ancienne structure (le DOM existant) et la convertir en une nouvelle (le nouveau DOM après la mise à jour de l'état).
Lorsque j'ai commencé à travailler sur ce problème vers 2016, j'ai triché en regardant ce que faisait React. Ils m'ont donné l'idée cruciale qu'il n'existait pas d'algorithme généralisé et linéaire pour différencier deux arbres (le DOM est un arbre). Mais, têtu s'il en est, je voulais toujours un algorithme à usage général pour effectuer la comparaison. Ce que je n'ai pas particulièrement apprécié dans React (ou presque n'importe quel framework d'ailleurs), c'est l'insistance sur le fait que vous devez utiliser des clés pour les éléments contigus :
function MyList() { const items = ['Item 1', 'Item 2', 'Item 3']; return ( <ul> {items.map((item, index) => ( <li key={index}>{item}</li> ))} </ul> ); }
Pour moi, la directive key
était superflue, car elle n'avait rien à voir avec le DOM ; c'était juste une indication du framework.
Ensuite, j'ai pensé à essayer un algorithme de comparaison textuelle sur des versions aplaties d'un arbre. Et si j'aplatissais les deux arbres (l'ancien morceau de DOM que j'avais et le nouveau morceau de DOM par lequel je voulais le remplacer) et que je calculais une diff
sur celui-ci (un ensemble minimum de modifications), afin de pouvoir passer de l'ancien au nouveau en un nombre d'étapes plus réduit ?
J'ai donc pris l' algorithme de Myers , celui que vous utilisez à chaque fois que vous exécutez git diff
, et je l'ai mis en œuvre sur mes arbres aplatis. Illustrons cela avec un exemple :
var oldList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ]]; var newList = ['ul', [ ['li', 'Item 1'], ['li', 'Item 2'], ['li', 'Item 3'], ]];
Comme vous pouvez le voir, je ne travaille pas avec le DOM, mais avec la représentation littérale d'objet que nous avons vue sur l'idée 1. Maintenant, vous remarquerez que nous devons ajouter un nouveau <li>
à la fin de la liste.
Les arbres aplatis ressemblent à ceci :
var oldFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'C ul']; var newFlattened = ['O ul', 'O li', 'L Item 1', 'C li', 'O li', 'L Item 2', 'C li', 'O li', 'L Item 3', 'C li', 'C ul'];
Le O
signifie « balise ouverte », le L
signifie « littéral » (dans ce cas, du texte) et le C
signifie « balise fermée ». Notez que chaque arbre est maintenant une liste de chaînes et qu'il n'y a plus de tableaux imbriqués. C'est ce que j'entends par aplatissement.
Lorsque j'exécute une comparaison sur chacun de ces éléments (en traitant chaque élément du tableau comme s'il s'agissait d'une unité), j'obtiens :
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['add', 'O li'] ['add', 'L Item 3'] ['add', 'C li'] ['keep', 'C ul'] ];
Comme vous l'avez probablement déduit, nous conservons la majeure partie de la liste et ajoutons un <li>
vers la fin de celle-ci. Ce sont les entrées add
que vous voyez.
Si nous changions maintenant le texte du troisième <li>
de Item 3
à Item 4
et que nous exécutions une comparaison dessus, nous obtiendrions :
var diff = [ ['keep', 'O ul'] ['keep', 'O li'] ['keep', 'L Item 1'] ['keep', 'C li'] ['keep', 'O li'] ['keep', 'L Item 2'] ['keep', 'C li'] ['keep', 'O li'] ['rem', 'L Item 3'] ['add', 'L Item 4'] ['keep', 'C li'] ['keep', 'C ul'] ];
Je ne sais pas à quel point cette approche est mathématiquement inefficace, mais en pratique, elle fonctionne plutôt bien. Elle ne fonctionne mal que lors de la comparaison de grands arbres qui présentent de nombreuses différences entre eux ; lorsque cela se produit occasionnellement, j'ai recours à un délai d'attente de 200 ms pour interrompre la comparaison et remplacer simplement la partie incriminée du DOM. Si je n'utilisais pas de délai d'attente, l'application entière se bloquerait pendant un certain temps jusqu'à ce que la comparaison soit terminée.
L'un des avantages de l'utilisation de la comparaison Myers est qu'elle donne la priorité aux suppressions plutôt qu'aux insertions : cela signifie que s'il existe un choix tout aussi efficace entre la suppression d'un élément et l'ajout d'un élément, l'algorithme supprimera d'abord un élément. En pratique, cela me permet de récupérer tous les éléments DOM éliminés et de pouvoir les recycler si j'en ai besoin plus tard dans la comparaison. Dans le dernier exemple, le dernier <li>
est recyclé en changeant son contenu de Item 3
à Item 4
. En recyclant les éléments (plutôt qu'en créant de nouveaux éléments DOM), nous améliorons les performances à un point tel que l'utilisateur ne se rend pas compte que le DOM est constamment redessiné.
Si vous vous demandez à quel point il est complexe d'implémenter ce mécanisme d'aplatissement et de différenciation qui applique les modifications au DOM, j'ai réussi à le faire en 500 lignes de javascript ES5, et il fonctionne même dans Internet Explorer 6. Mais, il faut l'admettre, c'était peut-être le morceau de code le plus difficile que j'aie jamais écrit. Être têtu a un prix.
Voilà les quatre idées que je voulais vous présenter ! Elles ne sont pas totalement originales mais j'espère qu'elles seront à la fois novatrices et intéressantes pour certains. Merci de votre lecture !