Каждый раз, когда мне нужно сесть за создание нового приложения, я впадаю в легкий ступор. Голова идет кругом от необходимости выбрать, какую же библиотеку или фреймворк взять на этот раз. Вот в прошлый раз я писал на библиотеке X, но теперь уже подрос и хайпанулся фреймворк Y, а еще есть классный UI Kit Z, да и с прошлых проектов осталась куча наработок.
С какого-то момента я понял, что фреймворк не имеет особого значения — то, что мне нужно, я могу сделать на любом из них. Тут вроде следует обрадоваться, взять что-то с макимумом звездочек на гитхабе и успокоиться. Но все-равно постоянно появляется непреодолимое желание сделать что-то свое, свой собственный велосипед. Ну что ж. Немного общих размышлений по этому поводу и фреймворк под названием Chorda ждут вас под катом.
На самом деле беда скорее не в том, что чужое решение плохое или неэффективное. Нет. Беда в том, чужое решение заставляет думать так, как нам может оказаться не удобно. Но подождите. Что значит "удобно-неудобно" и как это вообще может влиять на разработку? Вспомним, что есть такая штука как DX, по сути — комплекс сложившихся личных и общепринятых практик. Отсюда можно сказать, что нам удобно, когда наш собственный DX совпадает с DX автора библиотеки или фреймворка. А в случае, когда они расходятся, возникает тот самый дискомфорт, раздражение и поиски чего-то нового.
Немножко истории
Когда вы разрабатываете UI для корпоративного приложения, то сталкиваетесь с большим количеством пользовательских форм. И однажды голову посещает гениальная мысль: зачем я каждый раз создаю веб-форму, когда можно просто перечислить поля в JSON и скормить получившуюся структуру генератору? И, пусть в мире кровавого энтерпрайза такой подход работает не слишком хорошо (почему так, это отдельный разговор), но идея перейти от императивного стиля к декларативному в целом неплоха. Свидетельством тому является огромное количество генераторов веб-форм, страниц и, даже, целых сайтов, которые можно без труда найти на просторах Сети.
Вот и я в какой-то момент оказался не чужд стремлению улучшить свой код за счет перехода к декларативности. Но как только понадобились не просто стандартные html элементы, а сложные и интерактивные компоненты-виджеты, простым генератором отделаться уже не получилось. К этому быстро добавились требования переиспользуемости кода, интегрируемости, расширяемости и т.п. Разработка собственной библиотеки компонентов с декларативным API не заставила себя ждать.
Но и тут не случилось счастья. Наверно, лучше всего ситуацию будет отражать мнение моего коллеги, которому предстояло пользоваться созданной библиотекой. Он посмотрел на примеры, на документацию и сказал: "Библиотека классная. Красиво, динамично. Но как мне теперь из всего этого сделать приложение?". И он был прав. Оказалось, что сделать один компонент это не то же самое, что объединить несколько компонентов вместе и заставить их работать слаженно.
С тех пор прошло уже много времени. И когда в очередной раз меня посетило желание собрать вместе мысли и наработки, я решил поступить немного по-другому и пойти не снизу вверх, а сверху вниз.
Управление приложением == управление состояниями
Мне привычнее рассматривать приложение как конечный автомат с некоторым клонечным же набором состояний. А работу приложения как множество переходов из одного состояния в другое, при которых изменение модели приводит к созданию новой версии представления. В дальнейшем состоянием я буду называть некоторые фиксированные данные (объект, массив, примитивный тип и т.п.), связанные с их единственным представлением — документом.
Есть очевидная проблема — для множества значений модели необходимо описать множество вариантов документа. Тут обычно используются два подхода:
- Шаблоны. Ипользуем любимый язык разметки и дополняем его директивами ветвления и циклов.
- Функции. Описываем в функции наши ветвления и циклы на любимом языке программирования.
Как правило, оба этих подхода заявляются декларативными. Первый считается декларативным, поскольку в его основе лежат, пусть немного расширенные, но правила языка разметки. Второй — поскольку концентрируется на композиции функций, ряд из которых выступают в роли правил. Что примечательно, четкой границы между шаблонами и функциями сейчас нет.
С одной стороны, мне нравятся шаблоны, но с другой — хотелось как-то использовать возможности javascript. Например, что-то типа такого:
createFromConfig({
data: {
name: 'Alice'
},
tag: 'div',
class: 'clickable box',
onClick: function () {
alert('Click')
}
})
Получется JS-конфигурация, которая описывает целиком одно конкретное состояние. Для описания же множества состояний потребуется добиться расширяемости этой конфигурации. И как удобнее всего сделать набор опций расширяемым? Тут изобретать ничего не будем — перегрузка опций существует уже давно. Как она работает, можно увидеть на примере Vue с его Options API. Но, в отличие от того же Vue, мне стало интересно, можно ли таким же образом описать полное состояние, включая данные и документ.
Структура приложения и декларативность
Термин "компонент" стал уж слишком расплывчатым, особенно после появления т.н. функциональных компонентов. Поскольку дальше речь пойдет о структуре приложения, я буду называть компонент структурным элементом.
Очень быстро я пришел к тому, что структурным элементом (компонентом) является не элемент документа, а некоторая сущность, которая:
- объединяет данные и документ (биндинг и события)
- связана с другими такими же сущностями (древовидная структура)
Как я указывал раньше, если воспринимать приложение как набор состояний, то для этих состояний необходимо иметь способ описания. Причем необходимо найти такой способ, чтобы в нем отсутствовали «паразитные» императивные операторы. Речь идет о тех самых вспомогательных элементах, которые вводятся в шаблоны — #if, #elsif, v-for и т.п. Думаю, многие уже знают решение — необходимо перенести логику в модель, оставив на уровне представления API, который позволит через простые типы данных управлять структурными элементами.
Под управлением я понимаю наличие вариативности и цикличности.
Вариативность (if-else)
Посмотрим как можно управлять вариантами отображения на примере компонента-карточки в Chorda:
const isHeaderOnly = true
const card = new Html({
$header: {
/* шапка */
},
$footer: {
/* подвал */
},
components: {header: true, footer: !isHeaderOnly} // здесь управляем компонентами
})
Задавая значение опции components можно контролировать отображаемые компоненты. А при связывании components с реактивным хранилищем, получим, что наша структура перейдет под управление данными. Есть один нюанс — в качестве значения используется Object, а ключи в нем не упорядочены, что накладывает на components некоторые ограничения.
Цикличность (for)
Работа с данными, количество которых известно только во время выполнения, потребует итерации по спискам.
const drinks = ['Coffee', 'Tea', 'Milk']
const html = new Html({
html: 'ul',
css: 'list',
defaultItem: {
html: 'li',
css: 'list-item'
},
items: drinks
})
Значение опции items это Array, соответственно, мы получаем упорядоченный набор компонентов. Привязка items к хранилищу как и в случае с components передаст управление данным.
Структурные элементы связаны друг с другом в древовидную иерархию. Если мы объединим предыдущие примеры, то для отображения списка в теле карточки получим следующее:
// структура данных
const state = {
struct: {
header: true,
footer: false,
},
drinks: ['Coffee', 'Tea', 'Milk']
}
// документ
const card = new Html({
$header: {
/* шапка */
},
$content: {
html: 'ul',
css: 'list',
defaultItem: {
html: 'li',
css: 'list-item'
},
items: state.drinks
},
$footer: {
/* подвал */
},
components: state.struct
})
Примерно таким образом и создается структура приложения на основе данных. Достаточно иметь два вида генераторов — на основе Object и на основе Array. Остается только разобраться, как происходит преобразование структурных элементов в документ.
Когда все уже придумано за нас
Вообще, я являюсь сторонником того, что система рендеринга документа должна быть реализована на уровне браузера (пусть хоть тот же самый VDOM). И нашей задачей будет только аккуратно подключить ее к дереву компонентов. Ведь сколько ни расти скорость библиотеки, а у браузера она все-равно больше.
Я честно пытался когда-то сделать свою функцию отрисовки, но через некоторое время сдался, поскольку рисовать быстрее, чем на VanillaJS никак не получается (печально!). Сейчас для рендеринга модно использовать VDOM, а уж его реализаций, пожалуй, даже в избытке. Так что плюс еще одну реализацию виртуального дерева в копилку гитхаба я решил не добавлять — хватит и очередного фреймворка.
Изначально в Chorda для отрисовки был создан адаптер к библиотеке Maquette, но как только стали появляться задачи «из реального мира», оказалось, что практичнее иметь отрисовщик на React. В этом случае, к примеру, можно просто использовать существующий React DevTools, а не писать свой.
Для связи VDOM со структурными элементами понадобится такая вещь как компоновка. Ее можно назвать функцией документа от структурного элемента. Что важно — чистой функцией.
Рассмотрим пример с карточкой, у которой заданы шапка, тело и подвал. Ранее уже упоминалось, что компоненты не упорядочены, т.е. если мы начнем включаты/выключать компоненты во время работы, они будут появляться каждый раз в новом поряке. Посмотрим как это решается компоновкой:
function orderedByKeyLayout (h, type, props, components) {
return h(type, props, components.sort((a, b) => a.key - b.key).map(c => c.render()))
}
const html = new Html({
$header: {},
$content: {},
$footer: {},
layout: orderedByKeyLayout // компоненты упорядочиваются по ключу
})
Компоновка позволяет настроить т.н. host-элемент, с которым ассоциирован компонент, и его дочерние элементы (items и components). Обычно хватает и стандартной компоновки, но в ряде случаев верстка предполагает наличие элементов-оберток (например, для сеток) или назначения особых классов, которые мы по смыслу не хотим выносить на уровень компонентов.
Щепотка реактивности
Декларировав и отрисовав структуру компонентов, мы получаем состояние, соответствующее одному конкретному набору данных. Дальше нам понадобится описать множество наборов данных и реакцию на их изменение.
При работе с данными мне не нравились две вещи:
- Иммутабельность. Хорошая штука для отслеживания изменений, такое себе версионирование для бедных, которое превосходно работает на примитивных и плоских объектах. Но как только структура усложняется и количество вложений увеличивается, поддерживать иммутабельность сложного объекта становится непросто.
- Подмена. Если я помещаю в хранилище данных некоторый объект, то, когда я попрошу его обратно, мне может вернуться его копия или вообще другой объект или прокси, имеющий с ним структурное сходство.
Мне захотелось иметь хранилище, которое ведет себя, как иммутабельное, но внутри содержит изменяемые данные, которые к тому же сохраняют ссылочное постоянство. В идеальном случае это выглядело бы так: я создаю хранилище, записываю в него пустой объект, начинаю ввод данных с формы приложения, а после нажатия кнопки submit получаю тот же объект (ссылочно тот же!) с заполненными свойствами. Я назваю этот случай идеальным, поскольку не так часто случается, что модель хранения совпадает с моделью представления.
Еще одна задача, которую небходимо решить — доставить данные из хранилища к структурным элементам. Опять же изобретать ничего не будем и используем подход подключения к общему контексту. В случае Chorda мы не имеем доступ к самому контексту, а только к его отображению, называемому областью видимости. Причем, область видимости компонента является контекстом для его дочерних компонентов. Такой подход позволяет сузить, расширить или подменить связанные данные на любом уровне нашего приложения, причем эти изменения окажутся изолированными.
Пример того, как контекстные данные распространяются по дереву компонентов:
const html = new Html({
// определяем контекст нашего компонента
scope: {
drink: 'Coffee'
},
$component1: {
scope: {
cups: 2
},
$content: {
$myDrink: {
// скоуп здесь содержит те же переменные, что и корневой
drinkChanged: function (v) {
// привязываем значения переменной drink к опции text
this.opt('text', v)
}
},
$numCups: {
cupsChanged: function (v) {
this.opt('text', v + ' cups')
}
}
}
},
$component2: {
scope: {
drink: 'Tea' // подменяем в нашем скоупе переменную drink
},
drinkChanged: function (v) {
// привязываем значения переменной drink к опции text
this.opt('text', v)
}
}
})
// получим в итоге
// <div>
// <div>
// <div>
// <div>Coffee</div>
// <div>2 cups</div>
// </div>
// </div>
// <div>Tea</div>
// </div>
Самый сложный момент для понимания в том, что контекст у каждого компонента свой, а не тот, что объявлен на самом верху структуры, как мы обычно делаем при работе с шаблонами.
Что там было насчет перегрузки опций?
Наверняка вы сталкивались с ситуацией, когда есть большой компонент и в нем необходимо изменить маленький вложенный компонентик где-то глубоко внутри. Говорят, что тут должна помочь грануляция и композиция. А еще, что компоненты и архитектуру надо сразу проектировать правильно. Ситуация становится совсем печальной, если большой компонент не ваш, а является частью библиотеки, разрабатываемой другой командой или вообще независимым комьюнити. Что, если бы могли легко внести изменения в базовый компонент, даже если они не были запланированы изначально?
Обычно компоненты в библиотеках оформляются как классы, тогда их можно использовать в качестве основы для создания новых компонентов. Но здесь скрыта одна маленькая особенность, которая мне никогда не нравилась: иногда мы создаем класс только для того, чтобы применить его в одном единственном месте. Это странно. Я, например, привык использовать классы для типизации, выстраивания отношений между группами объектов, а не решать с их помощью задачу декомпозиции.
Посмотрим как в Chorda классы работают с конфигурацией.
// оформим нашу карточку как класс
class Card extends Html {
config () {
return {
css: 'box',
$header: {},
$content: {},
$footer: {}
}
}
}
const html = new Html({
css: 'panel',
$card: {
as: Card,
$header: {
// добавляем в шапку карточки кастомный компонент title
$title: {
css: 'title',
text: 'Card title'
}
}
}
})
Мне этот вариант нравится больше, чем создание специального класса TitledCard, который будет использован только единожды. А если понадобится вынести часть опций, то можно воспользоваться механизмом примесей. Ну и Object.assign никто не отменял.
В Chorda класс по сути является контейнером для конфигурации и играет роль особого вида примеси.
Почему еще один фреймворк?
Повторюсь, что на мой взгляд фреймворк это скорее про образ мысли и опыт, чем про технологию. Мои привычки и DX просили декларативности на JS, которой я не мог найти в других решениях. Но реализация одной фичи потянула за собой новые, и они через некоторое время просто перестали умещаться в рамках специализированной библиотеки.
На данный момент Chorda находится в активной разработке. Основные направления уже видны, но в деталях происходят постоянные изменения.
Спасибо за то, что дочитали до конца. Буду рад отзывам.