DSL typé en TypeScript de JSX


TypeScript prend en charge la syntaxe JSX et le compilateur TypeScript fournit des outils utiles pour configurer le processus de compilation JSX. Essentiellement, cela permet d'écrire DSL typé en utilisant JSX. Cet article discutera exactement de cela - comment écrire un DSL de r en utilisant jsx. Intéressé, je demande chat.


Référentiel avec un exemple prêt à l'emploi.


Dans cet article, je ne montrerai pas les possibilités avec des exemples liés au Web, React, etc. Un exemple non issu du Web montrera que les capacités de JSX ne se limitent pas à React, ses composants et la génération de html dans le cas général. Dans cet article, je vais montrer comment implémenter DSL pour générer des objets de message pour Slack .


Voici le code que nous prenons comme base. Il s'agit d'une petite fabrique de messages du même type:


interface Story { title: string link: string publishedAt: Date author: { name: string, avatarURL: string } } const template = (username: string, stories: Story[]) => ({ text: `:wave:  ${username},    .`, attachments: stories.map(s => ({ title, color: '#000000', title_link: s.link, author_name: s.author.name, author_icon: s.author.avatarURL, text: `  _${s.publishedAt}_.` }) }) 

Il semble bien paraître, mais il y a un point qui peut être considérablement amélioré - la lisibilité . Par exemple, faites attention à la propriété de color incompréhensible, aux deux champs pour le titre ( title et title_link ), ou aux title_link soulignement dans le text (le texte à l'intérieur de _ sera en italique ). Tout cela nous empêche de séparer le contenu des détails stylistiques, ce qui rend difficile de trouver ce qui est important. Et avec de tels problèmes, DSL devrait aider.


Voici le même exemple qui vient d'être écrit en JSX:


 const template = (username: string, stories: Story[]) => <message> :wave:  ${username},    . {stories.map(s => <attachment color='#000000'> <author icon={s.author.avatarURL}>{s.author.name}</author> <title link={s.link}>{s.title}</title>   <i>{s.publishedAt}</i>. </attachment> )} </message> 

Bien mieux! Tout ce qui devrait vivre ensemble est uni, les détails stylistiques et le contenu sont clairement séparés - la beauté en un mot.


Écrire une DSL


Personnalisez le projet


Vous devez d'abord activer JSX dans le projet et dire au compilateur que nous n'utilisons pas React, que notre JSX doit être compilé différemment.


 // tsconfig.json { "compilerOptions": { "jsx": "react", "jsxFactory": "Template.create" } } 

"jsx": "react" inclut la prise en charge JSX dans le projet et le compilateur compile tous les éléments JSX en appels React.createElement . Et l'option "jsxFactory" configure le compilateur pour utiliser notre usine d'éléments JSX.


Après ces simples réglages, le code du formulaire:


 import * as Template from './template' const JSX = <message>Text with <i>italic</i>.</message> 

compilera en


 const Template = require('./template'); const JSX = Template.create('message', null, 'Text with ', Template.create('i', null, 'italic'), '.'); 

Décrire les balises JSX


Maintenant que le compilateur sait dans quoi compiler JSX, nous devons déclarer les balises elles-mêmes. Pour ce faire, nous utilisons l'une des fonctionnalités intéressantes de TypeScript, à savoir les déclarations d'espaces de noms locaux. Pour le cas avec JSX, TypeScript s'attend à ce que le projet dispose d'un espace de noms JSX (l'emplacement spécifique du fichier n'a pas d'importance) avec l'interface IntrinsicElements dans laquelle les balises elles-mêmes sont décrites. Le compilateur les capture et les utilise pour la vérification de type et pour des conseils.


 // jsx.d.ts declare namespace JSX { interface IntrinsicElements { i: {} message: {} author: { icon: string } title: { link?: string } attachment: { color?: string } } } 

Ici, nous avons déclaré toutes les balises JSX pour notre DSL et tous leurs attributs. En fait, le nom de la clé dans l'interface est le nom de la balise elle-même, qui sera disponible dans le code. La valeur est une description des attributs disponibles. Certaines balises ( i dans notre cas) peuvent ne pas avoir d'attributs, d'autres peuvent être facultatives ou même nécessaires.


Usine elle-même - Template.create


Notre usine de tsconfig.json fait l'objet de conversations. Il sera utilisé lors de l'exécution pour créer des objets.


Dans le cas le plus simple, cela pourrait ressembler à ceci:


 type Kinds = keyof JSX.IntrinsicElements //    type Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] //    export const create = <K extends Kinds>(kind: K, attributes: Attrubute<K>, ...children) => { switch (kind) { case 'i': return `_${chidlren.join('')}_` default: // ... } } 

Les balises qui n'ajoutent que des styles au texte à l'intérieur sont faciles à écrire ( i dans notre cas): notre usine enveloppe simplement le contenu de la balise dans une chaîne avec _ deux côtés. Les problèmes commencent avec des balises complexes. La plupart du temps, je l'ai passé avec eux à la recherche d'une solution plus propre. Quel est le vrai problème?


Et c'est que le compilateur imprime le type <message>Text</message> à any . Ce qui n'est pas venu avec un DSL typé, eh bien, d'accord, la deuxième partie du problème est que le type de toutes les balises sera un après être passé par l'usine - c'est une limitation de JSX lui-même (dans React, toutes les balises sont converties en ReactElement).


Les génériques à la rescousse!


 // jsx.d.ts declare namespace JSX { interface Element { toMessage(): { text?: string attachments?: { text?: string author_name?: string author_icon?: string title_link?: string color?: string }[] } } interface IntrinsicElements { i: {} message: {} author: { icon: string } title: { link?: string } attachment: { color?: string } } } 

Seul Element ajouté et maintenant le compilateur affichera toutes les balises JSX dans le type Element . C'est également le comportement standard du compilateur - utilisez JSX.Element comme type pour toutes les balises.


Notre Element n'a qu'une seule méthode commune: le convertir en un type d'objet de message. Malheureusement, cela ne fonctionnera pas toujours, uniquement sur la <message/> niveau <message/> et ce délai sera dépassé.


Et sous le spoiler se trouve la version complète de notre usine.


Code d'usine lui-même
 import { flatten } from 'lodash' type Kinds = keyof JSX.IntrinsicElements //    type Attrubute<K extends Kinds> = JSX.IntrinsicElements[K] //    const isElement = (e: any): e is Element<any> => e && e.kind const is = <K extends Kinds>(k: K, e: string | Element<any>): e is Element<K> => isElement(e) && e.kind === k /*         () */ const buildText = (e: Element<any>) => e.children.filter(i => !isElement(i)).join('') const buildTitle = (e: Element<'title'>) => ({ title: buildText(e), title_link: e.attributes.link }) const buildAuthor = (e: Element<'author'>) => ({ author_name: buildText(e), author_icon: e.attributes.icon }) const buildAttachment = (e: Element<'attachment'>) => { const authorNode = e.children.find(i => is('author', i)) const author = authorNode ? buildAuthor(<Element<'author'>>authorNode) : {} const titleNode = e.children.find(i => is('title', i)) const title = titleNode ? buildTitle(<Element<'title'>>titleNode) : {} return { text: buildText(e), ...title, ...author, ...e.attributes } } class Element<K extends Kinds> { children: Array<string | Element<any>> constructor( public kind: K, public attributes: Attrubute<K>, children: Array<string | Element<any>> ) { this.children = flatten(children) } /* *          `<message/>` */ toMessage() { if (!is('message', this)) return {} const attachments = this.children.filter(i => is('attachment', i)).map(buildAttachment) return { attachments, text: buildText(this) } } } export const create = <K extends Kinds>(kind: K, attributes: Attrubute<K>, ...children) => { switch (kind) { case 'i': return `_${children.join('')}_` default: return new Element(kind, attributes, children) } } 

Référentiel avec un exemple prêt à l'emploi.


Au lieu d'une conclusion


Quand j'ai fait ces expériences, l'équipe TypeScript ne comprenait que la puissance et les limites de ce qu'elles faisaient avec JSX. Maintenant, ses capacités sont encore plus grandes et l'usine peut être écrite plus propre. Si vous voulez fouiller et améliorer le référentiel avec un exemple - Bienvenue avec les requêtes pull.

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


All Articles