У TypeScript есть встроенная поддержка JSX синтаксиса и компилятор TypeScript'а предоставляет годные инструменты по настройке процесса компиляции JSX. По сути, это создает возможность писать типизированный DSL используя JSX. В этой статье речь пойдет именно про это — как написать DSL из г с помощью JSX. Заинтересовавшихся прошу под кат.
→ Репозиторий с готовым примером.
В этой статье я не буду показывать возможности на примерах, относящихся к вебу, React'у и подобным. Пример не из веба позволит продемонстрировать что возможности JSX не ограничены React'ом, его компонентами и генерацией html в общем случае. В этой статьи я покажу как реализовать DSL для генерации объектов сообщений для Slack.
Вот код который мы возьмем за основу. Это маленькая фабрика сообщений одного и того же типа:
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}_.`
})
})
Вроде бы выглядит неплохо, но тут есть один момент который можно значительно улучшить — читабельность. Например, обратите внимание на непонятно к чему относящееся свойство color
, на два поля для заголовка (title
и title_link
) или на подчеркивания в text
(текст внутри _
будет курсивом). Все это мешает нам разделять контент от стилистических деталей, усложняя поиск того что важно. И вот с такими проблемами DSL и должны помогать.
Вот тот же пример только уже написанный в 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>
Намного лучше! Все что должно жить вместе объединилось, стилистические детали и контент четко разделены — красота одним словом.
Пишем DSL
Настраиваем проект
Сначала необходимо включить JSX в проекте и сказать компилятору что мы не используем React, что наш JSX нужно компилировать иначе.
// tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "Template.create"
}
}
"jsx": "react"
включает поддержку JSX в проекте и компилятор компилирует все JSX элементы в вызовы React.createElement
. А опция "jsxFactory"
настраивает компилятор на использование нашей фабрики JSX элементов.
После этих нехитрых настроек код вида:
import * as Template from './template'
const JSX = <message>Text with <i>italic</i>.</message>
будет компилироваться в
const Template = require('./template');
const JSX = Template.create('message', null,
'Text with ',
Template.create('i', null, 'italic'),
'.');
Опишем JSX теги
Теперь, когда компилятор знает во что компилировать JSX, нам нужно объявить сами теги. Для этого мы задействуем одну из классных возможностей TypeScript'а — а именно локальные декларации пространств имен. Для случая с JSX TypeScript ожидает что в проекте есть пространство имен JSX
(конкретная локация файла не имеет значения) с интерфейсом IntrinsicElements
в котором и описаны сами теги. Компилятор их цепляет и использует для проверки типов и для подсказок.
// jsx.d.ts
declare namespace JSX {
interface IntrinsicElements {
i: {}
message: {}
author: { icon: string }
title: { link?: string }
attachment: {
color?: string
}
}
}
Здесь мы объявили все JSX теги для нашего DSL и все их атрибуты. По сути, имя ключа в интерфейсе это название самого тега который будет доступен в коду. Значение это описание доступных атрибутов. У некоторых тегов (i
в нашем случае) может и не быть никаких атрибутов, у других опциональные или вообще необходимые.
Собственно фабрика — Template.create
Наша фабрика из tsconfig.json
и есть предмет разговора. Она будет использоваться в рантайме для создания объектов.
В самом простом случае она может выглядеть как-то так:
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: // ...
}
}
Теги которые добавляют только стили к тексту внутри легко написать (i
в нашем случае): наша фабрика просто заворачивает содержимое тега в строку с _
с обеих сторон. Проблемы начинаются со сложными тегами. Большую часть времени я провозился именно с ними, в поисках решения почище. В чем же собственно проблема?
А она в том, что компилятор выводит тип <message>Text</message>
в any
. Что и близко не стояло с типизированным DSL, ну это ладно, вторая часть проблемы в том что тип у всех тегов будет один после прохода через фабрику — это ограничение самого JSX (у React'а все теги преобразуются в ReactElement).
Дженерики идут на помощь!
// 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
}
}
}
Добавился только Element
и теперь компилятор будет выводить все JSX теги в тип Element
. Это тоже стандартное поведение компилятора — использовать JSX.Element
как тип для всех тегов.
У нашего Element
есть только один общий метод — приведение его к типу объекта сообщения. К сожалению он будет работать не всегда, только на самом верхнеуровневом теге <message/>
и это будет в райнтайме.
А под спойлером полная версия нашей фабрики.
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)
}
}
→ Репозиторий с готовым примером.
Вместо заключения
Когда я делал эти свои опыты у команды TypeScript'а только появлялось понимание мощи и ограничений того что они сделали с JSX. Сейчас его возможностей еще больше и фабрику можно написать чище. Если появится желание покопаться и улучшить репозиторий с примером — велкам с пулл реквестами.