Я уже рассказывал про свой UI фреймворк Chorda в предыдущей статье. Интереса он (ожидаемо) не вызвал, но остался как полигон для испытания идей и подходов, которые кажутся мне интересными.
Фреймворк немного подрос, преобразился, но в его основе так и осталась концепция смешивания (перекрытия) конфигураций и упор на декларативность
При разработке UI библиотеки на базе Chorda я столкнулся с неожиданной проблемой: без постоянного обращения к коду сложно понимать, как увязывать компоненты друг с другом. Была универсальная конфигурация, заданная фреймворком, но отсутствовал специфичный API. А для библиотеки, которую ты хочешь передать другим разработчикам, это просто приговор.
Как удалось исправить ситуацию и что в итоге получилось можно узнать, заглянув под кат
Небольшой дисклеймер. Я не буду вдаваться во внутреннее устройство Chorda, сосредоточусь лишь на некоторых внешних особенностях фреймворка, с которыми непосредственно сталкивается разработчик
Обычно (у меня) приложение разделяется на библиотеку компонентов и собственно приложение. Естественно, мне хотелось, чтобы мои компоненты были максимально гибкими, чтобы при желании их можно легко менять без переписывания библиотеки. Для этого нужна открытая конфигурация.
Такую конфигурацию я и сделал на основе расширяемого набора опций. Однако этот подход не был лишен недостатков:
- Переопределение базовых опций сбивало с толку. Без заглядывания в класс компонента невозможно было понять, какие значения могут принимать опции и какой эффект они производят
- Возникал соблазн скрыть работу со скоупом (данными) с помощью кастомных опций. Это приводило к появлению взаимного влияния опций и переменных скоупа, исчезала простота однонаправленного потока данных
- Собственные опции классов требовали собственных правил слияния и контроль порядка применения
Однако самым губительным стало то, что размылось и фактически исчезло понятие "API компонента" как набора ограничений, накладываемых проектировщиком. Поскольку опции API определялись вперемешку с html опциями, отделить "бизнес" параметры от "технических" не представлялось возможным.
В качестве решения я попробовал переписать все на Typescript и ввести понятия чертежа и дизайн-функции
Переход на Typescript
С одной стороны это дань моде, но с другой — я всегда считал, что принципы, на которых строится фреймворк, не должны зависеть от языка. А тут появилась хорошая возможность избавиться от "всемогущих" функций и получить "бесплатные" посдказки при вводе.
Поскольку фреймворк активно меняется, простота рефакторинга стала играть большое значение. Писать тесты на все подряд невозможно, а отлавливать ошибки после даже мелких правок в ядре утомительно. Контроль типов здесь становится как нельзя кстати
Кроме методов и классов подвергся типизации и набор опций (конфигурация) компонента. Пришлось отказаться от динамических опций (иначе какой смысл в типизации?) и "шорткатов", которые хоть и уменьшали объем кода, но от них веяло потусторонней магией. Как следствие вернулась избыточность.
Чертеж (blueprint)
Чертеж — это типизированная конфигурация, полностью описывающая компонент (состояние). Чертеж иммутабелен, но он может быть определен как смесь (или перекрытие) других чертежей.
На основе чертежа создается дерево компонентов, которое по сути своей является состоянием приложения. Тут важно отметить пару моментов:
- Компонент (состояние) собирается только на основе чертежа
- Компонент (состояние) можно изменить только созданием нового перекрытия, применяя его как патч к текущему компоненту (состоянию)
Чертеж можно назвать "техническим" описанием компонента
Дизайн-функция
Дизайн-функция является отображением набора свойств на чертеж.
Дизайн-функция закрывает (инкапсулирует?) чертеж, позволяя, во-первых, управлять им свойствами "бизнес" уровня, а, во-вторых, предоставляя более компактную форму записи сложных чертежей. Аргументы функции начинают играть роль спецификации (ограничений) чертежа, т.е. его API
Отображение свойств на иммутабельную структуру роднит описываемый подход с React. Однако, если рендер-функция вызывается каждый раз при изменении свойств, дизайн-функция вызывается лишь единожды при формировании чертежа
Как это работает?
Для создания дизайн-функций особых правил нет, главное, чтобы на выходе мы получили чертеж.
Структурирование
Наши компоненты могут иметь иерархическую структуру, когда одни элементы вложены в другие.
Обычно я разделяю дизайн-функцию на опорную и настраиваемую части, т.е. на то, что не зависит от входящих параметров и то, что зависит.
// Button.ts
// эти свойства и есть API компонента
type ButtonProps = {
text?: string
icon?: HtmlBlueprint
}
// наша дизайн-функция
export default (props: ButtonProps) : HtmlBlueprint => {
// чертеж опорной части
const base = {
tag: 'button',
css: 'button',
templates: {
icon: {
tag: 'i',
css: 'icon'
}
}
}
// чертеж настраиваемой части
const ext = props && {
templates: {
icon: props.icon
},
components: {
icon: !!props.icon
},
text: props.text
}
// смешиваем (смесь чертежей это тоже чертеж)
return mix(base, ext)
}
Так создание Button будет выглядеть в основном приложении:
// App.ts
const button = Button({
icon: {
css: 'icon-success'
},
text: 'OK'
})
// комопонент отрисуется в DOM так:
//
// <button class="button">
// <i class="icon icon-success" />
// OK
// </button>
Свойство icon определено в открытом виде, т.е как чертеж. Здесь я жертвую строгостью в угоду гибкости. Можно, конечно, наложить ограничения, но я предполагаю, что кнопка не содержит полного описания иконки, а лишь ее опорную часть. Это как бы слот (привет html templates), у которого уже есть некоторое базовое содержимое
Если мы говорим о проектировании библиотеки, то следующий очевидный шаг это создание дизайн-функции Icon для чертежа иконки. Пример с Button может стать таким:
const button = Button({
icon: Icon({
type: IconType.SUCCESS
}),
text: 'OK'
})
Ух, прям Flutter какой-то получился :)
Композиция
Скрещивать ежа с ужом мы, конечно, не будем, но иногда компоненты приходится объединять в угоду "семантической корректности" или "исторически сложившимся обстоятельствам".
// Link.ts
type LinkProps = {
as?: HtmlBlueprint
href?: string
text?: string
}
export default (props: LinkProps) : HtmlBlueprint => {
return mix({
tag: 'a',
css: 'link'
},
// здесь мы добавляем еще одно перекрытие
props && props.as,
props && {
dom: {
href: props.href
},
text: props.text
})
}
// Button.ts
type ButtonProps = {
text?: string
as?: HtmlBlueprint
}
export default (props: ButtonProps) : HtmlBlueprint => {
return mix({
tag: 'button',
css: 'button'
},
props && props.as,
props && {
text: props.text
})
}
Я добавляю специальное свойство as для объединения чертежей в определенном порядке. Мне так нравится больше, чем явный вызов mix
// App.ts
const linkButton = Button({
as: Link({href: '/go'}),
text: 'Go'
})
// <a class="button link" href="/go">Go</a>
// или так
const submitButton = Button({
as: Input({
type: 'submit',
value: 'Send'
})
})
// <input class="button" type="submit" value="Send">
Реактивность
Выше приводились примеры создания чертежей статических компонентов, т.е. тех, в которых состояние задается во время сборки и остается неизменным. А теперь немного о динамических компонентах и о данных, через которые меняется состояние.
Собственно, подходы к работе с данными не сильно изменились с первой версии Chorda и в целом похожи на Composition API Vue, правда со своими особенностями.
// Input.ts
type InputProps = {
value$?: Injector // имена инжекторов помечаем для удобства постфиксом $
}
export default (props: InputProps) : HtmlBlueprint => {
return mix({
tag: 'input',
bindings: {
// при изменении свойства скоупа создаем патч
value: (v) => patch({
dom: {
value: v
}
})
}
}, props && {
events: {
// обрабатываем событие ввода текста - меняем свойство скоупа
change: (evt, {value}) => {
value.$value = evt.currentTarget.value
}
},
scope: {
// задаем свойство скоупа
value: props.value$
}
})
}
Создание Input в приложении:
// App.ts
const input = Input({
input$: () => observable('Some text')
})
const form = Form({
scope: {
firstName: () => observable('Петр'),
lastName: () => observable('Иванов'),
subscribed: () => observable(false),
},
fields: [
Field({
label: 'Имя',
control: Input({
value$: (scope) => scope.firstName
})
}),
Field({
label: 'Фамилия',
control: Input({
value$: (scope) => scope.lastName
})
}),
Field({
label: 'Согласен на рассылку новостей',
control: Check({
value$: (scope) => scope.subscribed
})
}),
Button({
as: Input({
type: 'submit',
text: 'Submit'
})
})
]
})
Этот пример можно сделать еще более компактным, создав композиции InputField и CheckField, но я обычно стараюсь искать компромисс и не плодить лишние сущности
Выводы
Если посмотреть внимательно, то все, что я сделал, это обернул блоки конфигурации в функции. Однако в сочетании с Typescript и парой простых правил это позволило мне не только уменьшить количество кода приложения, но и повысить его документируемость.
Впрочем, и тут не обошлось без ложки дегтя. Отвязка API от чертежей облегчает проектирование компонентов, но вместе с тем фреймворк теряет возможность указывать best way to do it. А это как если бы перед вами поставили коробку с лего и попросили без инструкции собрать Millenium Falcon.