Mécanismes d'extensibilité extensible en JavaScript

Bonjour collègues!

Nous vous rappelons qu'il n'y a pas si longtemps, nous avons publié la 3e édition du livre légendaire «JavaScript expressif » (Eloquent JavaScript) - il a été imprimé en russe pour la première fois, bien que des traductions de haute qualité des éditions précédentes aient été trouvées sur Internet.



Cependant, ni JavaScript ni les travaux de recherche de M. Haverbeke, bien sûr, ne sont pas immobiles. Poursuivant le thème du JavaScript expressif, nous proposons une traduction de l'article sur la conception d'extensions (en utilisant le développement d'un éditeur de texte comme exemple), publié sur le blog de l'auteur fin août 2019


De nos jours, il est devenu à la mode de structurer de grands systèmes sous la forme de nombreux packages séparés. L'idée directrice sous-jacente à cette approche est qu'il est préférable de ne pas limiter les gens à une fonctionnalité spécifique (que vous proposez) en implémentant une fonctionnalité, mais de fournir cette fonctionnalité sous la forme d'un package séparé qu'une personne peut télécharger avec le package système de base.
Pour ce faire, en termes généraux, vous aurez besoin de ...

  • La possibilité de ne même pas charger les fonctionnalités dont vous n'avez pas besoin, ce qui est particulièrement utile lorsque vous travaillez avec des systèmes côté client.
  • La possibilité de remplacer par une autre implémentation la fonctionnalité qui ne répond pas à vos besoins. De cette façon, la pression est également réduite sur les modules nucléaires, qui autrement devraient couvrir toutes sortes de cas pratiques.
  • Vérification des interfaces du noyau en conditions réelles - en implémentant des fonctionnalités de base au-dessus de l'interface orientée client; vous devrez rendre l'interface suffisamment puissante pour qu'elle puisse au moins supporter la prise en charge de ces fonctionnalités - et vous assurer que des éléments fonctionnellement similaires peuvent être assemblés à partir de code tiers.
  • Isolement entre les composants du système. Les participants au projet devront rechercher le package spécifique qui les intéresse. Les packages peuvent être versionnés, obsolètes ou remplacés par d'autres, et tout cela n'affectera pas le noyau.


Cette approche implique certains coûts, qui se résument à une complexité supplémentaire. Pour que les utilisateurs puissent commencer, vous pouvez leur fournir un package de wrapper, qui est tout compris, mais à un moment donné, ils devront probablement supprimer ce wrapper et s'attaquer à l'installation et à la configuration de packages auxiliaires par eux-mêmes, ce qui s'avère plus difficile que d'inclure Nouvelle fonctionnalité livrée dans la bibliothèque monolithique.
Dans cet article, j'essaierai d'explorer différentes façons de concevoir des mécanismes d'extensibilité qui impliquent une «extensibilité à grande échelle» et de poser immédiatement de nouveaux points pour une expansion future.

Extensibilité

De quoi avons-nous besoin d'un système extensible? Tout d'abord, bien sûr, vous devez pouvoir créer de nouveaux comportements sur du code externe.

Mais cela ne suffit pas. Permettez-moi de m'écarter, de vous parler d'un problème stupide que j'ai rencontré une fois. Je développe un éditeur de texte. Dans une première version de l' éditeur de code, le client pouvait spécifier l' apparence d'une ligne particulière. C'était excellent - l'utilisateur pouvait sélectionner sélectivement telle ou telle ligne de cette façon.

De plus, si vous essayez de lancer la mise en page d'une ligne à partir de deux fragments de code mutuellement indépendants, ils commencent à marcher l'un sur l'autre. La deuxième extension, qui est appliquée à une ligne particulière, remplace les modifications apportées via la première. Ou, si à un moment ultérieur nous essayons de supprimer par le premier code ces changements dans la conception que nous avons faits avec son aide, alors nous écraserons la conception faite à partir du deuxième fragment de code.

La solution était de permettre au code d'être ajouté (et supprimé ) plutôt qu'installé, afin que les deux extensions puissent interagir avec la même ligne sans interrompre le travail de l'autre.

Dans une formulation plus générale, il est nécessaire de s'assurer que les extensions peuvent être combinées, même si elles sont «totalement inconscientes» les unes des autres - et afin qu'aucun conflit ne survienne lors de leurs interactions.

Pour que cela fonctionne, chaque extension doit être exposée à un nombre illimité d'agents à la fois. La façon dont chacun des effets sera traité diffère selon le cas spécifique. Voici quelques stratégies qui pourraient vous être utiles:

  • Toutes les modifications prennent effet. Par exemple, si nous ajoutons une classe CSS à un élément ou affichons un widget à une position donnée dans le texte, ces deux choses peuvent être faites immédiatement. Bien sûr, une sorte de classement sera nécessaire: les widgets doivent être affichés dans une séquence prévisible et bien définie.
  • Les changements s'alignent dans le pipeline. Un exemple de cette approche est un gestionnaire qui peut filtrer les modifications apportées à un document avant qu'elles ne prennent effet. Chaque gestionnaire reçoit une modification effectuée par le gestionnaire précédent et le gestionnaire suivant peut poursuivre ces modifications. La commande ici n'est pas cruciale, mais peut être importante.
  • L'approche premier arrivé, premier servi. Cette approche est applicable, par exemple, aux gestionnaires d'événements. Chaque gestionnaire a la possibilité de bricoler l'événement jusqu'à ce que l'un des gestionnaires annonce que tout est déjà fait, puis le gestionnaire suivant ne sera pas dérangé.
  • Il est parfois nécessaire de sélectionner une seule valeur, par exemple, pour déterminer la valeur d'un paramètre de configuration spécifique. Ici, il serait approprié d'utiliser une sorte d'opérateur (par exemple, logique ou, logique et, minimum, maximum) afin de réduire l'entrée potentielle à une seule valeur. Par exemple, un éditeur peut basculer en mode lecture seule si au moins une extension l'exige, ou la longueur maximale d'un document peut être le minimum de toutes les valeurs fournies pour cette option.


Dans de nombreuses situations de ce type, l'ordre est important. Ici, je veux dire le respect de l'ordre dans lequel les effets sont appliqués, cette séquence doit être contrôlée et prévisible.

C'est l'une de ces situations où les systèmes d'extension impératifs ne sont généralement pas très bons, dont le fonctionnement dépend des effets secondaires. Par exemple, l'opération addEventListener d'un modèle DOM de addEventListener nécessite que les gestionnaires d'événements soient appelés dans l'ordre dans lequel ils sont enregistrés. Ceci est normal si tous les appels sont contrôlés par un seul système ou si l'ordre des appels n'est pas si important. Cependant, si vous disposez de nombreux composants logiciels qui ajoutent des gestionnaires indépendamment les uns des autres, il peut être très difficile de prédire lesquels seront appelés en premier lieu.

Une approche simple

Permettez-moi de vous donner un exemple concret: j'ai d'abord appliqué une telle stratégie modulaire lors du développement de ProseMirror, un système d'édition de texte riche. Le noyau de ce système en lui-même est essentiellement inutile: il s'appuie sur des packages supplémentaires pour décrire la structure des documents, pour lier les clés, pour conserver un historique des annulations. Bien qu'il soit vraiment un peu difficile d'utiliser ce système, il a trouvé des applications dans des programmes où vous devez configurer des choses qui ne sont pas prises en charge dans les éditeurs classiques.

Le mécanisme d'extension de ProseMirror est relativement simple. Lors de la création d'un éditeur, un seul tableau d'objets connectés est spécifié dans le code client. Chacun de ces objets plug-in peut affecter divers aspects de l'éditeur et effectuer des opérations telles que l'ajout de bits de données d'état ou la gestion des événements d'interface.
Tous ces aspects ont été conçus pour fonctionner avec un tableau ordonné de valeurs de configuration en utilisant l'une des stratégies décrites ci-dessus. Par exemple, lorsque vous devez spécifier un grand nombre de dictionnaires avec des valeurs, la priorité des instances d'extension suivantes pour la liaison de clé dépend de l'ordre dans lequel vous spécifiez ces instances. La première extension pour la liaison de touches, sachant quoi faire avec cette frappe, le traite.

Habituellement, un tel mécanisme s'avère assez puissant et ils peuvent le mettre à leur avantage. Mais tôt ou tard, le système d'extension atteint une complexité telle qu'il devient peu pratique de l'utiliser.

  • Si le plugin a de nombreux effets, vous ne pouvez qu'espérer qu'ils ont tous besoin de la même priorité par rapport aux autres plugins, ou vous devez les diviser en plus petits plugins afin de bien organiser leur commande.
  • En général, l'organisation des plugins devient très scrupuleuse, car l'utilisateur final n'est pas toujours clair quels plugins peuvent interférer avec quels autres plugins s'ils obtiennent une priorité plus élevée. Les erreurs commises dans de tels cas ne se produisent généralement qu'au moment de l'exécution, lorsqu'une opportunité spécifique est utilisée, elles sont donc faciles à ignorer.
  • Si les plugins sont construits sur la base d'autres plugins, ce fait doit être documenté et espérons que les utilisateurs n'oublient pas d'inclure les dépendances appropriées (à cette étape de tri, si nécessaire).


CodeMirror version 6 est une version réécrite de l' éditeur de code du même nom. Dans ce projet, j'essaie de développer une approche modulaire. Pour ce faire, j'ai besoin d'un système d'extension plus expressif. Discutons de certains des défis auxquels nous avons dû faire face lors de la conception d'un tel système.

Commande

Il est facile de concevoir un système qui vous donne un contrôle complet sur la commande des extensions. Cependant, il est beaucoup plus difficile de concevoir un système avec lequel il sera agréable de travailler et qui, en même temps, vous permettra de combiner le code de différentes extensions sans de nombreuses interventions de la catégorie «now watch your hands».
Quand il s'agit de passer commande, ça arrive, j'ai vraiment envie de recourir à travailler avec des valeurs prioritaires. Par exemple, la propriété CSS z-index indique le numéro de la position occupée par cet élément dans la profondeur de la pile.

Comme vous pouvez le voir sur l'exemple de valeurs d' z-index ridiculement grandes, qui se trouvent parfois dans les feuilles de style, cette façon d'indiquer la priorité est problématique. Le module lui-même ne connaît pas les valeurs de priorité des autres modules. Les options ne sont que des points au milieu d'une plage numérique indifférenciée. Vous pouvez définir une valeur énorme (ou profondément négative) pour essayer d'atteindre les extrémités de ce spectre, mais le reste du travail se résume à la bonne aventure.

La situation peut être un peu améliorée si vous définissez un ensemble limité de catégories de priorités clairement définies, afin que les extensions puissent caractériser le «niveau» général de leur priorité. De plus, vous aurez besoin d'une certaine façon de rompre les liens au sein des catégories.

Regroupement et déduplication

Comme je l'ai mentionné ci-dessus, dès que vous commencez à compter sérieusement sur les extensions, une situation peut se produire lorsque certaines extensions en utiliseront d'autres lors du travail. Si vous gérez les dépendances manuellement, cette approche n'est pas adaptée à l'échelle; par conséquent, ce serait bien si vous pouviez afficher un groupe d'extensions en même temps.

Cependant, cette approche aggrave non seulement le problème de priorité, mais introduit également un autre problème: de nombreuses autres extensions peuvent dépendre d'une extension particulière, et si les extensions sont présentées sous forme de valeurs, il se pourrait bien que la même extension soit chargée plusieurs fois . Pour certains types d'extensions, tels que les gestionnaires d'événements, cela est normal. Dans d'autres cas, comme avec un historique d'annulation et une bibliothèque d'infobulles, cette approche sera un gaspillage et peut même tout casser.

Donc, si nous autorisons la disposition des extensions, cela introduit une complexité supplémentaire dans notre système liée à la gestion des dépendances. Vous devez pouvoir reconnaître ces extensions qui ne doivent pas être dupliquées et les télécharger exactement une à la fois.

Cependant, comme dans la plupart des cas, les extensions peuvent être configurées et que, par conséquent, toutes les instances de la même extension ne seront pas exactement les mêmes, nous ne pouvons pas prendre une seule instance et travailler avec. Nous devrons envisager une fusion significative de ces cas (ou signaler une erreur si la fusion qui nous intéresse est impossible).

Projet

Ici, je vais décrire en termes généraux ce que nous faisons dans CodeMirror 6. Ce n'est qu'un croquis, pas une solution ayant échoué. Il est possible que ce système se développe davantage lorsque la bibliothèque se stabilise.

La principale primitive utilisée dans cette approche est appelée comportement. Les comportements ne sont que les fonctionnalités que vous pouvez développer avec des extensions, en spécifiant des valeurs pour celles-ci. Un exemple est le comportement d'un champ d'état où, à l'aide d'extensions, vous pouvez ajouter de nouveaux champs, en fournissant une description du champ. Un autre exemple est le comportement des gestionnaires d'événements dans un navigateur; dans ce cas, à l'aide d'extensions, nous pouvons ajouter nos propres gestionnaires.

Du point de vue du consommateur de comportements, les comportements eux-mêmes, configurés d'une certaine manière dans une instance particulière de l'éditeur, donnent une séquence ordonnée de valeurs, et les valeurs qui précèdent ont une priorité plus élevée. Chaque comportement a un type et les valeurs fournies doivent correspondre à ce type.
Le comportement est représenté comme une valeur utilisée à la fois pour déclarer une instance du comportement et pour accéder aux valeurs du comportement. Il existe un certain nombre de comportements intégrés dans la bibliothèque, mais le code externe peut définir ses propres comportements. Par exemple, dans l'extension définissant l'intervalle entre les numéros de ligne, un comportement peut être défini qui permet à un autre code d'ajouter des marqueurs supplémentaires dans cet intervalle.

Une extension est une valeur qui peut être utilisée lors de la configuration de l'éditeur. Un tableau de ces valeurs est transmis lors de l'initialisation. Chaque extension est autorisée dans zéro ou plusieurs comportements.

Une telle extension simple peut être considérée comme une instance de comportement. Si nous spécifions une valeur pour le comportement, alors le code nous renvoie la valeur de l'extension qui génère ce comportement.

Une séquence d'extensions peut également être regroupée en une seule extension. Par exemple, dans la configuration de l'éditeur pour travailler avec un langage de programmation spécifique, vous pouvez afficher plusieurs autres extensions - par exemple, la grammaire pour l'analyse et la mise en surbrillance du texte, des informations sur l'indentation nécessaire, une source de saisie semi-automatique qui affichera correctement les invites pour compléter les lignes dans ce langage. Ainsi, nous pouvons créer une extension de langue unique dans laquelle nous collectons simplement toutes ces extensions correspondantes et les regroupons, résultant en une valeur unique.

En créant une version simple d'un tel système, nous pourrions nous arrêter à cela en alignant simplement toutes les extensions imbriquées dans un tableau d'extensions de comportement. Ensuite, ils pourraient être regroupés par type de comportement, puis construire des séquences ordonnées de valeurs de comportement.

Cependant, il reste à gérer la déduplication et à fournir un meilleur contrôle sur la commande.

Les valeurs des extensions liées au troisième type, des extensions uniques, aident simplement à réaliser la déduplication. Les extensions qui ne doivent pas être instanciées deux fois dans le même éditeur sont de ce type. Pour définir une telle extension, vous devez spécifier le type de spécification , c'est-à-dire le type de valeur de configuration attendu par le constructeur de l'extension, et également spécifier une fonction d'instanciation qui prend un tableau de ces valeurs spécifiées et renvoie l'extension.

Les extensions uniques compliquent le processus de résolution d'un ensemble d'extensions en un ensemble de comportements. S'il y a des extensions uniques dans l'ensemble aligné d'extensions, le mécanisme de résolution doit sélectionner le type d'extension unique, collecter toutes ses instances et appeler la fonction d'instanciation correspondante ainsi que les spécifications, puis les remplacer toutes par le résultat (en une seule copie).

(Il y a un autre problème: ils doivent se résoudre dans le bon ordre. Si vous activez d'abord l'extension unique X, mais que vous obtenez ensuite un autre X à la suite de la résolution, alors ce sera faux, car toutes les instances de X doivent être réunies. Depuis la fonction d'instanciation l'expansion est propre, le système résout cette situation par essais et erreurs, redémarre le processus et enregistre des informations sur ce qu'il était possible d'étudier, étant dans cette situation.)

Enfin, vous devez résoudre le problème avec les règles de suivi. L'approche de base reste la même: maintenir l'ordre dans lequel les extensions ont été proposées. Les extensions composées s'alignent dans le même ordre au point où elles se produisent. Le résultat de la résolution d'une extension unique est inséré lors de sa première mise sous tension.

Cependant, les extensions peuvent associer certaines de leurs sous-extensions à des catégories qui ont une priorité différente. Le système prévoit quatre de ces catégories: repli (prend effet après que d'autres choses se produisent), par défaut (par défaut), extension (priorité plus élevée que le volume) et remplacement (devrait probablement être le premier). En pratique, le tri se fait d'abord par catégorie, puis par position de départ.

Ainsi, une extension de liaison de clé, qui a une faible priorité, et un gestionnaire d'événements avec une priorité régulière, il est basé sur eux qu'il est à la mode d'obtenir une extension composée construite à partir du résultat d'une extension de liaison de clé (ne nécessitant pas de savoir de quel comportement elle se compose) avec un niveau de priorité de secours et d'une instance avec le comportement du gestionnaire d'événements.

Cette approche, qui vous permet de combiner des extensions sans penser à ce qu'elles font «à l'intérieur», semble être une grande réussite. Les extensions que nous avons modélisées plus haut dans cet article incluent: deux systèmes d'analyse qui présentent le même comportement au niveau de la syntaxe, le service de mise en évidence de la syntaxe, le service d'indentation intelligent, l'historique des annulations, le service d'espacement des lignes, les crochets à fermeture automatique, la liaison de touches et la sélection multiple - tous fonctionne bien.

Il existe plusieurs nouveaux concepts qu'un utilisateur doit apprendre pour utiliser ce système. De plus, travailler avec un tel système est en effet un peu plus compliqué qu'avec les systèmes impératifs traditionnels adoptés dans la communauté JavaScript (nous appelons une méthode pour ajouter / supprimer un effet). Cependant, si les extensions sont correctement agencées, les avantages de celle-ci l'emportent sur les coûts associés.

Source: https://habr.com/ru/post/fr468219/


All Articles