Web Components : un élément <TR> personnalisé (2/2)

Cet article est le second, et donc le dernier, d'un série de deux consacrés à la réalisation d'un élément <tr> personnalisé exploitant des technologies de la famille Web Components.
Dans le premier article, il s'agissait de passer en revue l'intégralité du code requis pour créer un élément <tr> personnalisé <param-number> permettant d'ajouter un spinner précédé d'un libellé dans un tableau, comme ici :
Un élément personnalisé
Ce spinner est lié à une propriété quelconque que le développeur pointe à l'élément personnalisé en fournissant un chemin d'accès, comme par exemple shader.particle.start.delay. Ainsi, une fois qu'il a rajouté un élément <param-number> dans la page Web, le développeur n'a plus qu'à réagir aux modifications de la propriété via son getter. Le chemin est réévalué à chaque accès, si bien que le développeur peut toujours changer l'objet sur lequel il débouche.
Dans ce second article, il s'agit de revenir sur certains choix techniques qui ont présidé à l'écriture du code de l'élément personnalisé. En particulier, on s'interroge sur les avantages et les inconvénients d'une personnalisation d'un élément HTML existant tel que <tr>. Il s'agit aussi de pointer quelques enjeux des technologies de la famille Web Components pour l'avenir, notamment le recours aux classes en JavaScript et la prise de distance avec les éléments HTML standards.
A écouter en lisant l'article...

Un choix de <tr> contraint... et contraignant

Comme cela a été mentionné dans le premier article, le développement de <param-number> s'est inscrit dans celui d'une bibliothèque d'éléments utilisés pour composer des IHM de configuration riches en paramètres. Ces paramètres devaient être groupés, et un tableau est apparu comme une disposition adaptée, une colonne consacrée aux libellés, une colonne consacrée aux IHM des différents . Comme ici, où plusieurs tableaux de ce type sont empilés et juxtaposés, leurs bords étant rendus visibles pour bien visualiser l'agencement :
Regroupement de <param-> dans des tableauxa
Comment procéder pour parvenir à ce résultat ? Ajouter une ligne à un <table> vient immédiatement à l'esprit, l'idée étant de s'appuyer sur la mécanique des éléments existants. Il s'agirait donc de créer un élément personnalisé qui serait remplacé par une ligne dans un tableau, c'est-à-dire par un élément <tr>.
Le problème est que dans ces conditions, il est impossible d'utiliser un élément autonome - pour un rappel sur la différence entre élément autonome et élément personnalisé, se reporter au premier article.
En effet, la syntaxe HTML suivante d'un élémént autonome ne permet pas d'accéder à l'élément <table>depuis le constructeur de la classe de l'élément : this.parentNode renvoie sur l'élément <div>:
<div>
<table>
	<ma-ligne></ma-ligne>
</table>
</div>

<script>
class MaLigne extends HTMLElement {
	constructor () {
		super ();
	}
	connectedCallback () {
		console.log (this.parentNode);
	}
}
customElements.define ("ma-ligne", MaLigne);
</script>
Tout se passe comme si le navigateur considérait que l'élément autonome n'a pas sa place dans le <able> - en fait, le <tbody>, mais c'est un de ces éléments que l'on peut se dispenser de mentionner, car le navigateur le rajoute automatiquement - et le remontait d'un niveau dans la hiérarchie des éléments, sans doute jusqu'à lui trouver un parent légitime.
Que le navigateur s'autorise à manipuler le DOM qu'on lui demande de créer peut sembler étonnant, mais c'est un comportement standard. Par exemple, cliquer sur "Click me!" ici affiche bien le <div> dans la console :
<div>
<table>
	<span onclick="console.log (this.parentNode)">Click me!</span>
</table>
</div>
Il en résulte que l'élément à créer ne peut être un élément autonome. Ce doit être une version personnalisée d'un élément dont le navigateur autorise la présence à l'endroit où l'on souhaite qu'il figure. Plus précisément, ce ne peut être qu'un élément <tr> personnalisé, que le navigateur considérera ainsi comme un enfant légitime de <tbody>, et qu'il n'en sortira donc pas.
Dès lors, la classe de l'élément doit dériver de HTMLTableRowElement, et la syntaxe HTML devient la suivante:
<div>
<table>
	<tr is="ma-ligne"></tr>
</table>
</div>

<script>
class MaLigne extends HTMLTableRowElement {
	constructor () {
		super ();
	}
	connectedCallback () {
		console.log (this.parentNode);
	}
}
customElements.define ("ma-ligne", MaLigne, { extends: "tr" });
</script>
Au passage, attention à ne pas oublier de préciser l'extension via l'option extedslors de l'appel à customElements.define (), où cela génère une erreur fort peu explicite Type Error: Illegal constructor.
Toutefois, le fait que l'élément personnalisé dérive de <tr> induit une limitation. Tout comme un élément ne peut figurer dans <table> que si c'est un <tr>, un élément ne peut figurer dans un <tr> que si c'est un <td>. Dans ces conditions, il est impossible de permettre de transmettre des données au constructeur autrement que sous une forme sérialisée, via des attributs.
Ainsi, dans l'exemple qui suit, rien ne s'affiche dans la console, car les éléments <mon-item> ne dérivant pas de <td>, le navigateur les sort du <tr> puis du <table>, si bien qu'ils ne figurent pas dans .childNodes:
<div>
<table>
	<tr is="ma-ligne">
		<mon-item>Chocolate</mon-item>
		<mon-item>Tea</mon-item>
	</tr>
</table>
</div>
<script>
class MaLigne extends HTMLTableRowElement {
	constructor () {
		super ();
	}
	connectedCallback () {
		var i;
		for (i = 0; i != this.childNodes.length; i ++) {
			if (this.childNodes[i].tagName == "MON-ITEM") {		// Le nom d'un élément HTML est toujours passé en majuscules
				console.log (this.childNodes[i].innerText);
			}
		}
	}
}
customElements.define ("ma-ligne", MaLigne, { extends: "tr" });
</script>
Pour que cela fonctionne, il faut recourir la sérialisation, par exemple à l'aide de JSON :
<div>
<table>
	<tr is="ma-ligne" items='["Chocolate", "Tea", "Coffee"]'></tr>	<!-- JSON n'accepte que les doubles guillemets -->
</table>
</div>
<script>
class MaLigne extends HTMLTableRowElement {
	constructor () {
		super ();
	}
	connectedCallback () {
		var items;
		items = JSON.parse (this.getAttribute ("items"));
		items.forEach ((e) => console.log (e));
	}
}
customElements.define ("ma-ligne", MaLigne, { extends: "tr" });
</script>
Toutefois, sérialiser ainsi pas considéré comme une bonne pratique, et cela pour de bonnes raisons :
Aim to only accept rich data (objects, arrays) as properties
Generally speaking, there are no examples of built-in HTML elements that accept rich data (plain JavaScript objects and arrays) through their attributes. Rich data is instead accepted either through method calls or properties. There are a couple obvious downsides to accepting rich data as attributes: it can be expensive to serialize a large object to a string, and any object references will be lost in this stringification process. For example, if you stringify an object which has a reference to another object, or perhaps a DOM node, those references will be lost.
Mélanger une initialisation à base d'attributs en HTML et de propriétés en JavaScript : la solution peut ne pas du tout convenir à qui s'est orienté vers les Web Components pour justement faire oublier JavaScript...
Adoptez les bonnes pratiques !
Dans ses bonnes pages consacrées aux technologies de la famille Web Components, Google préconise une série de bonnes pratiques.
Le meilleur conseil qu'on puisse donner à celui qui veut se lancer dans le développement d'éléments autonomes ou personnalisés s'appuyant sur ces technologies, c'est d'en prendre connaissance aussitôt qu'il a fait un premier tour du sujet !
Une autre solution pourrait être de se passer de <table>, <tr> et <td>, pour utiliser plutôt des <div> agencés en grlle à l'aide de styles CSS pour produire un résultat similaire. En effet, le contenu d'un <div> est nettement moins contraint : on peut y mettre n'importe quel élément. Dès lors, il serait peut-être même envisageable de revenir à un élément autonome ?
Alors, élément autonome ou personnalisé ? En fait, la question qui se pose est celle du rôle que l'on entend faire jouer à cet élément dans le DOM. L'idée est-elle de remplacer l'élément par son contenu généré, ou d'y rattacher ce contenu ? L'idée de la substitution n'apparaît pas raisonnable : ce serait faire disparaître du DOM de la page Web un élément que le développeur a mentionné dans son code HTML, et qui constitue le seul intérmédiaire par lequel il peut contrôler le contenu de cet élément. Bref, l'élément à créer doit toujours jouer au moins un rôle conteneur.
La question devient : en quoi doit consister ce rôle de conteneur ? Ici, il faut de nouveau pointer une bonne pratique - parfaitement cohérente avec la porosité toute relative aux styles CSS du DOM fantôme, pointée dans le premier article :
Do not self-apply classes
Elements that need to express their state should do so using attributes. The class attribute is generally considered to be owned by the developer using your element, and writing to it yourself may inadvertently stomp on developer classes.
De cela, il résulte que l'idée de faire jouer un rôle à l'élément dans l'apparence de son contenu n'est pas praticable. En effet, cela se ferait nécessairement en modifiant les styles CSS de cet élément, ce qui reviendrait à empiéter sur la prérogative du développeur, lequel s'attend légitimement à disposer du contrôle total des styles CSS de cet élément.
L'élément doit donc être un conteneur dont la présentation n'est pas altérée à l'occasion de la création de son contenu. Bref, un simple conteneur.
Reste à savoir si cet élément peut jouer ce rôle de simple conteneur. Dans le cas présent, si l'élément est un élément <div> personnalisé, il jouera un rôle dans la présentation de son contenu dans la grille où il figure, étant considéré comme une cellule. Or ce n'est pas souhaitable, car il s'agit de rajouter une ligne à la grille, et non du contenu à l'intérieur d'une cellule de cette dernière.
Pour autant, il faut constater que c'est le cas de tout élément figurant dans une grille, même quand il ne correspond à aucun élément connu du navigateur. Par exemple... :
<div style="display:grid;grid-template-columns:50px 50px">
	<div>0</div>
	<div>1</div>
	<div>2</div>
</div>
...produit cela... :
0 1
2
...et l'introduction d'un élément qui ne correspond à rien de connu... :
<div style="display:grid;grid-template-columns:50px 50px">
	<div>0</div>
	<some-thing></some-thing>
	<div>1</div>
	<div>2</div>
</div>
...décale tout... :
0
1 2
...à moins de demander à ce que l'élément ne soit pas affiché... :
<div style="display:grid;grid-template-columns:50px 50px">
	<div>0</div>
	<some-thing style="display:none"></some-thing>
	<div>1</div>
	<div>2</div>
</div>
...ce qui revient à enfreindre la doctrine énoncée plus tôt, selon laquelle le développeur qui a introduit <some-thing> devrait avoir le contrôle total des styles CSS de cet élément.
En fait, tout ce problème de rajouter une ligne dans un tableau débouche sur une impasse, quelle que soit la technique utilisée pour créer ce tableau : <table>, ou <div> dont l'agencement est un grille. Car que veut-on finalement, sinon simultanément rattacher le contenu au conteneur pour permettre au développeur de la manipuler via ce dernier, et le rattacher au tableau dans lequel ce conteneur figure pour en faire une de ses lignes ? On le voit, c'est contradictoire :
  • si le contenu est rattaché au tableau et l'élément dissimulé, le contenu est bien une ligne dans le tableau mais les styles CSS appliqués par le développeur à l'élément n'en affectent pas la présentation ;
  • si le contenu est rattaché à l'élément, les styles CSS appliqués par le développeur à l'élément en affectent la présentation, mais le contenu est une cellule dans le tableau.
En fait, il existe une porte de sortie. C'est d'adopter la première solution, car elle répond au besoin essentiel, et d'intercepter dans la fonction de rappel attributeChangedCallback () de l'élément toute modification des syles CSS de cet élément, pour les répercuter sur le contenu qu'il a créé.

Pour résumer...

Donc pour résumer :
  • Pour rajouter une ligne dans un <table>à l'aide d'un élément sur mesure, il faut utiliser un élément <tr> personnalisé, car autrement le navigateur sortira l'élément du <table> au prétexte qu'il n'a rien à y faire, et il sera donc impossible d'accéder au <table> dans le code de l'élément custom pour rajouter la ligne :
    <table>
    	<some-thing></some-thing>	<!-- Sera sorti du <table> par le navigateur, car pas attendu ici -->
    </table>
    
  • Pour passer des données complexes à la classe de cet élément, il ne faut pas utiliser les attributs car cela implique un coûteux recours à la sérialisation :
    <!-- Non : sérialisation coûteuse -->
    <tr is="some-thing" value='["1st": 7, "2nd": 666]'></some-thing>
    
  • Une idée serait de les passer via des élément enfants supplémentaires, mais il est impossible de rajouter des enfants à un <tr> car le navigateur les en sort au prétexte qu'ils n'ont rien à y faire :
    <tr is="some-thing">
    	<param>		<!-- Sera sorti du <tr> par le navigateur, car pas attendu ici -->
    		<item name="first" value="7"></item>
    		<item name="first" value="666"></item>
    	</param>
    </tr>
    
  • Une solution serait de ne pas utiliser un <table>, mais un grille à base de <div>, car le navigateur accepte qu'un <div> contienne n'importe quoi.
    Toutefois, comme l'élément sera considéré comme une cellule dans la grille, il faut imposer :
    • la dissimulation de cet élément - ce qui est mal, car la bonne pratique est que le développeur qui ajoute l'élément dans son code HTML devrait en avoir le contrôle total des styles CSS ;
    • le rattachement du contenu qu'il permet de créer au <div>qui définit la grille ;
    • la prise en compte des modifications apportées aux styles CSS de l'élément en les interceptant via attributeChangedCallback () pour les répercuter sur le contenu - pour la même raison : le développeur qui ajoute l'élément dans son code HTML devrait en avoir le contrôle total des styles CSS.
    <div style="display:grid">
    	<some-thing style="display:none">		<!-- C'est mal, car le développeur devrait avoir le contrôle total des styles CSS -->
    		<param>
    			<item name="first" value="7"></item>
    			<item name="first" value="666"></item>
    		</param>
    	</some-thing>
    </div>
    
  • Quant à adopter un élément autonome ou personnalisé dérivé de <div>, autant en faire un élément autome, car de toute manière il est dissimulé.
    Mais alors, il faut noter qu'en cherchant à éviter la sérialisation, on fait pire. En effet il est encore plus coûteux d'aller lire des attributs d'éléments supplémnentaires. Simplement, on évite au développeur d'utiliser du JSON. Bref c'est mal.
    Pour conclure, autant sérialiser et utiliser un élément personnalisé dérivé de <tr>. Un tel élément ne pose pas le problème d'avoir à dissimuler l'élément une fois son contenu généré, comme ce serait le cas si c'était un élément <div> personnalisé ou un élément autonome agencé à l'aide d'une grille CSS.
Il n'en reste pas moins que c'est mal, car la bonne pratique serait que toute donnée complexe soit transmis à l'élément custom via les propriétés de sa classes et non ses attributs ou ses enfants. Néanmoins, comme déjà mentionné, si l'idée est de simplifier la vie du développeur, faut-il vraiment le contraindre à écrire du code JavaScript en plus du code HTML pour initialiser un élément custom ?

Des conséquences à anticiper sur la pratique de JavaScript

L'aspect polémique des éléments autonomes ou personnalisés, c'est qu'ils s'appuient sur les classes JavaScript. En effet, comme chacun l'aura remarqué, l'objet qui représente un tel élément se présente sous la forme d'une classe, et non d'un objet, c'est-à-dire une variable héritant à un degré quelconque de Object, ou de l'objet Function qui permet de le construire.
Pour comprendre l'enjeu, il faut se rappeler que l'introduction du concept de classes dans la spécification ECMAScript 2015, dite ES6, sur laquelle JavaScript est fondé, a suscité une levée de boucliers de la part des puristes, dont la tête de file est le très fameux Douglas Crockford.
C'est que class en JavaScript, ce n'est que du sucre syntaxique, comme clairement expliqué dans la spécification évoquée :
Although ECMAScript objects are not inherently class-based, it is often convenient to define class-like abstractions based upon a common pattern of constructor functions, prototype objects, and methods. The ECMAScript built-in objects themselves follow such a class-like pattern. Beginning with ECMAScript 2015, the ECMAScript language includes syntactic class definitions that permit programmers to concisely define objects that conform to the same class-like abstraction pattern used by the built-in objects.
Les puristes y ont vu une perversion du langage, car l'introduction du concept de langage dans un langage orienté objet où l'héritage est basé sur le chaînage de prototypes n'aurait rien à y faire. C'est notamment la position soutenue ici par Douglas Crockford, dès 2014 :
There are lots and lots of other features in ES6. We don't have enough experience with all of them. We have to know which is going to be good, which is going to be bad. There are few that we know are going be bad. The worst is class. Class was the most requested new feature in JavaScript and all the requests came from Java programmers who have to program in JavaScript and don't want to have to learn how to do that. So they wanted something that looks like Java so they could be more confortable. Those people will go to their graves, never knowing how miserable they are.
Cette question sera abordée dans ces colonnes plus longuement dans le cadre d'un autre article - avec, on l'espère, un interview de Douglas Crockford à l'occasion de la publication de How JavaScript Works. Pour l'heure, il faut simplement noter que l'introduction des classes va peser sur la pratique JavaScript en le donnant à voir pour ce qu'il n'est pas, et que cette évolution est portée par des intérêts dont la logique n'est pas seulement technique, ce qui pose la question - sociologique - de savoir qui standardise.
Or ans le fil d'une discussion publique sur GitHub, une question très claire a été posée sur cette nécessité de recourir aux classes :
I'd like for there to be an available, working examples of autonomous and customized Custom Elements made without use of the class syntax.
A quoi un contributeur à l'élaboration de la spécification ECMAScript 2015 a répondu :
It's not possible to use custom elements without ES6 classes. That was a design decision necessary to achieve consensus at the January face-to-face meeting.
Et à la demande de réouvrir le sujet dans le cadre de la version future de la spécification des éléments custom, la porte a été définitivement fermée :
Sorry, this was a condition of getting consensus, and is now fully built in to the architecture of the feature. It cannot be changed.
Ce qu'il faut retenir, c'est qu'en imposant d'utiliser les classes en JavaScript pour créer des éléments sur mesure, les Web Components vont nécessairement en favoriser plus l'adoption, et donc transformer toujours la manière dont JavaScript peut être perçu - encore une fois, pour ce qu'il n'est pas. Il sera intéressant de noter la manière dont les puristes réagiront à cette pression grandissante.

Le développeur front-end enfin maître du lexique HTML

Ceux qui l'ont connue, car elle remonte à loin, se souviendront que l'apparition de CSS avait déjà marqué un tournant radical dans l'usage des éléments standards du HTML. En permettant au développeur de recomposer l'apparence d'un élément standard du HTML - mais il n'y avait qu'eux à l'époque -, CSS avait réduit grandement l'intérêt pour nombre de ces éléments. Aux oubliettes de l'histoire, les <bold>, <stroke> et autres <font-size>... En fait, il ne restait guère plus que <table> et cie, et <div> pour ce qui concernait la présentation.
Pouvoir enrichir le HTML avec ses propres éléments à volonté, et que ces éléments puissent être aussi capables que n'importe quel composants de l'OS - pour l'affichage, pour le transfert de données, etc. -, c'est depuis toujours le secret désir des développeurs front-end.
Pour ne retenir que le sujet de l'affichage, le développeur a d'abord eu recours à des composants spécifiques comme les ActiveX et les applets Java. Par la suite, l'enrichissement fonctionnel des navigateurs a permis d'adopter des solutions plus sécurisées et plus intégrées au navigateur, comme CSS, SVG, Canvas 2D et WebGL - à ne pas confondre, chacune présentant une utilité particulière.
Toutefois, pour permettre d'utiliser facilement un composant exploitant ces technologies en rajoutant simplement un élément dans une page Web, il fallait développer toute un machinerie en JavaScript. Cela a débouché sur le développement de bibliothèques et de frameworks divers et variés. Pas génial pour rester en maîtrise de son code, ou produire des composants pérennes.
De ce point de vue, la généralisation des technologies regroupées sous l'appelation de Web Components constitue donc un progrès considérable. Le développeur front-end dispose désormais du contrôle total sur le lexique des éléments HTML. Il n'est plus limité au seul détournement du comportement de ceux qui existent. Il peut définir les siens.
Comme toujours, c'est l'adoption de la technologie par les éditeurs de navigateurs qui condition une condition sine qua none de son succès. Or comme souligné au début du premier article, c'est désormais chose faite. Il est donc temps de se pencher sérieusement sur les Web Components pour réviser de fond en comble la manière dont on entreprend de détourner le fonctionnement d'éléments HTML, en s'appuyant éventuellement sur des solutions tierces, pour proposer des composants aussi puissants que ceux de l'OS à intégrer par le simple truchement d'un nouvel élément HTML dans une page Web.
Noter qu'il reste une touche finale à ajouter à la famille des technologies de la famille Web Components. En effet, ceux qui élaborent les standards n'ont visiblement toujours pas fixé leur choix pour ce qui concerne les imports HTML, comme en témoigne Can I Use.... Le développeur d'un élément sur mesure doit donc utiliser une solution de contournement pour charger le modèle sur lequel son élément s'appuie éventuellement. Ce n'est pas très contraignant, mais ça n'en demeure pas moins une contrainte.
Web Components : un élément <TR> personnalisé (2/2)