Первая статья про dap, очевидно, не стала моим писательским успехом: подавляющее большинство коментов к ней свелись к «ниасилил» и «ниасилил, но осуждаю». А приз за самый единственный конструктивный комментарий верхнего уровня достается OldVitus, за совет продемонстрировать dap на примере TodoMVC, чтобы было с чем сравнить. Чем я в этой статье и займусь.
TodoMVC, если кто не знает, это такой стандартный UI-хелловорлд, позволяющий сравнить решения одной и той же задачи — условного «Списка дел» — средствами разных фреймворков. Задачка, при всей своей простоте (ее решение на dap влезает «в один экран»), весьма иллюстративна. Поэтому на ее примере я попробую показать, как типичные для веб-фронтенда задачи реализуются с помощью dap.
Искать и изучать формальное описание задачи я не стал, а решил просто среверсить один из примеров. Бэкенд в рамках этой статьи нам не интересен, поэтому сами мы его писать не будем, а воспользуемся одним из готовых с сайта www.todobackend.com, оттуда же возьмем и пример клиента и стандартный CSS-файл.
Для использования dap вам не нужно ничего скачивать и устанавливать. Никаких npm install
и вот этого всего. Не требуется создавать никаких проектов с определенной структурой каталогов, манифестами и прочей атрибутикой IT-успеха. Достаточно текcтового редактора и браузера. Для отладки XHR-запросов может еще потребоваться веб-сервер — достаточно простейшего, типа вот этого расширения для Chrome. Весь наш фронтенд будет состоять из одного-единственного .html-файла (разумеется, ссылающегося на скрипт dap-движка и на стандартный CSS-файл TodoMVC)
Итак, с чистого листа.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Todo -- dap sample</title>
<link rel="stylesheet" href="https://www.todobackend.com/client/css/vendor/todomvc-common.css"/>
<script src="https://dap.js.org/0.4.js"></script>
</head>
<body>
<script>
// здесь будет dap
</script>
</body>
</html>
section id="todo-app">
// здесь будет dap
'#todoapp'(
'#header'(
'H1'()
'INPUT#new-todo placeholder="What needs to be done?" autofocus'()
)
'#main'(
'#toggle-all type=checkbox'()
'UL#todo-list'(
'LI'(
'INPUT.toggle type=checkbox'()
'LABEL'()
'BUTTON.destroy'()
)
)
)
'#footer'(
'#todo-count'()
'UL#filters'(
'LI'()
)
'#clear-completed'()
)
)
LI
) мы пишем в структуру по одному разу, даже если в оригинале их несколько; очевидно, что это массивы из одного и того же шаблона. String.prototype
несколько методов (я в курсе, что внедрять свои методы в стандартные объекты — это айяйяй, но… короче, проехали), которые преобразует строку-сигнатуру в dap-шаблон. Один из таких методов — .d(rule, ...children)
. Первым аргументом он принимает правило генерации (d-правило), и остальными аргументами — произвольное число чайлдов..d(""
, а перед каждой открывающей одинарной кавычкой, кроме самой первой, была запятая. Лайфхак: можно воспользоваться автозаменой.'#todoapp'.d(""
,'#header'.d(""
,'H1'.d("")
,'INPUT#new-todo placeholder="What needs to be done?" autofocus'.d("")
)
,'#main'.d(""
,'#toggle-all type=checkbox'.d("")
,'UL#todo-list'.d(""
,'LI'.d(""
,'INPUT.toggle type=checkbox'.d("")
,'LABEL'.d("")
,'BUTTON.destroy'.d("")
)
)
)
,'#footer'.d(""
,'#todo-count'.d("")
,'UL#filters'.d(""
,'LI'.d("")
)
,'#clear-completed'.d("")
)
)
.d
, которое уже готово трансформироваться в dap-шаблон. Пустые строки ""
— это зародыши будущих d-правил, а чайлды стали перечисленными через запятую аргументами. Формально, это уже валидная dap-программа, хоть пока и не совсем с тем выхлопом, который нам нужен. Но ее уже можно запустить! Для этого после закрывающей корневой скобки дописываем метод .RENDER()
. Этот метод, как понятно из его названия, рендерит полученный шаблон.<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Todo -- dap sample</title>
<link rel="stylesheet" href="https://www.todobackend.com/client/css/vendor/todomvc-common.css"/>
<script src="https://dap.js.org/0.4.js"></script>
</head>
<body>
<script>
'#todoapp'.d(""
,'#header'.d(""
,'H1'.d("")
,'INPUT#new-todo placeholder="What needs to be done?" autofocus'.d("")
)
,'#main'.d(""
,'#toggle-all type=checkbox'.d("")
,'UL#todo-list'.d(""
,'LI'.d(""
,'INPUT.toggle type=checkbox'.d("")
,'LABEL'.d("")
,'BUTTON.destroy'.d("")
)
)
)
,'#footer'.d(""
,'#todo-count'.d("")
,'UL#filters'.d(""
,'LI'.d("")
)
,'#clear-completed'.d("")
)
)
.RENDER() // рендерим полученный dap в документ
</script>
</body>
</html>
:query
который асинхронно «конвертирует» URL в данные, с него полученные. Сам URL мы не будем писать прямо в правиле, а обозначим его константой todos
; тогда вся конструкция по добыче данных будет выглядеть так:todos:query
todos
пропишем словаре — в секции .DICT
, прямо перед .RENDER()
:'#todoapp'.d(""
...
)
.DICT({
todos : "https://todo-backend-express.herokuapp.com/"
})
.RENDER()
todos
, строим из него список дел: для каждого дела берем название из поля .title
и пишем его в элемент LABEL
, а из поля .completed
берем признак «завершенности» — и пишем в свойство checked
элемента-чекбокса INPUT.toggle
. Делается это так: ,'UL#todo-list'.d("*@ todos:query" // Оператор * выполняет повтор для всех элементов массива
,'LI'.d(""
,'INPUT.toggle type=checkbox'.d("#.checked=.completed") // # обозначает "этот элемент"
,'LABEL'.d("! .title") // Оператор ! просто добавляет текст в элемент
,'BUTTON.destroy'.d("")
)
)
*
и !
, конвертор :query
, константы и доступ к полям текущего элемента массива. Посмотрите еще раз на получающийся код. Он вам все еще кажется нечитаемым?LI
не меняет свой стиль («завершенное дело» должно становиться серым и зачеркнутым, но этого не происходит) — дела не меняют свое состояние. А никакого состояния эти элементы пока и не имеют и, соответственно, не могут его менять. Сейчас мы это поправим.LI
состояние «завершенности». Для этого определим в его d-правиле переменную состояния $completed
. Элементу INPUT.toggle
, который может это состояние менять, назначим соответствующее правило реакции (ui-правило), которое будет устанавливать переменную $completed
в соответствии с собственным признаком checked
(«галка включена»). В зависимости от состояния $completed
элементу LI
будем либо включать, либо выключать CSS-класс «completed». ,'UL#todo-list'.d("*@ todos:query"
,'LI'.d("$completed=.completed"// Переменная состояния, инициализируем из поля .completed
,'INPUT.toggle type=checkbox'
.d("#.checked=.completed") // Начальное состояние галочки берем из данных
.ui("$completed=#.checked") // при нажатии обновляем $completed
,'LABEL'.d("! .title")
,'BUTTON.destroy'.d("")
)
.a("!? $completed") // в зависимости от значения $completed, включаем или выключаем css-класс completed
)
!?
LI
не требует перестройки остального его содержимого, поэтому рациональней это делать именно в a-правиле.LI
оператор a!
,'LI'.d("$completed=.completed; a!" // Сразу же после инициализации переменной $completed используем ее в a-правиле
$completed
разобрались. Завершенные дела стилизуются корректно и при начальной загрузке, и при последующих ручных переключениях.INPUT class="edit"
. Мы сделаем чуть иначе — прятать будем только элемент LABEL
, т. к. остальные два элемента нам при редактировании не мешают. Просто допишем класс view
элементу LABEL
LI
переменную $editing
. Изначально оно (состояние) сброшено, включается по dblclick
на элементе LABEL
, а выключается при расфокусе элемента INPUT.edit
. Так и запишем: ,'LI'.d("$completed=.completed $editing=; a!" // Теперь у нас две переменные состояния
,'INPUT.toggle type=checkbox'
.d("#.checked=.completed")
.ui("$completed=#.checked")
,'LABEL.view'
.d("? $editing:!; ! .title") // Если $editing сброшена, то показываем этот элемент
.e("dblclick","$editing=`yes") // По dblclick включаем $editing
,'INPUT.edit'
.d("? $editing; !! .title@value") // Если $editing непустой
.ui(".title=#.value") // обновляем .title по событию change (ui событие по умолчанию для INPUT)
.e("blur","$editing=") // сбрасываем $editing по событию blur
,'BUTTON.destroy'.d("")
).a("!? $completed $editing") // отображаем состояния $completed и $editing в css-классе элемента 'LI'
http://todo-backend-express.herokuapp.com/28185
, который, очевидно, является уникальным для каждого дела. Этот URL указывается сервером в поле .url
для каждого дела, присутствующего в списке. То есть все, что от нас требуется для обновления дела на сервере — это отправить PATCH-запрос по адресу, указанному в поле .url
, с измененными данными в формате JSON: ,'INPUT.edit'
.d("? $editing; !! .title@value")
.ui(".title=#.value; (@method`PATCH .url (@Content-type`application/json)@headers (.title):json.encode@body):query")
.e("blur","$editing=")
:query
, но в более развернутом варианте. Когда :query
применяется к простой строке, эта строка трактуется как URL и выполняется GET-запрос. Если же :query
получает сложный объект, как в данном случае, он трактует его как детальное описание запроса, содержащее поля .method
, .url
, .headers
и .body
, и выполняет запрос в соответствии с ними. Здесь мы сразу после обновления .title
отправляем серверу PATCH-запрос c этим обновленным .title
.url
мы получаем от сервера, оно выглядит примерно так: http://todo-backend-express.herokuapp.com/28185
, то есть в нем жестко прописан протокол http:// Если наш клиент тоже открыт по http://, то все нормально. Но если клиент открыт по https:// — то возникает проблема: по соображениям безопасности браузер блокирует http-трафик от https-источника..url
протокол, то запрос будет проходить по протоколу страницы. Так и сделаем: напишем соответствующий конвертер — dehttp
, и будем пропускать .url
через него. Собственные конверторы (и прочий функционал) прописывается в секции .FUNC
: .ui(".title=#.value; (@method`PATCH .url:dehttp (@Content-type`application/json)@headers (.title):json.encode@body):query")
...
.FUNC({
convert:{ // конверторы - это функции с одним входом и одним выходом
dehhtp: url=>url.replace(/^https?\:/,'')// удаляем протокол из URL
}
})
.ui(".title=#.value; (@method`PATCH .url:dehttp headers (.title):json.encode@body):query")
...
.DICT({
todos : "//todo-backend-express.herokuapp.com/",
headers: {"Content-type":"application/json"}
})
:query
— автоматическим кодированием тела запроса в json в соответствии с заголовком Content-type:application/json
. В итоге правило будет выглядеть так: .ui(".title=#.value; (@method`PATCH .url:dehttp headers (.title)):query")
completed
. Значит, его тоже нужно отправлять серверу.INPUT.toggle
дописать аналогичный PATCH-запрос, просто вместо (.title)
отправлять (.completed)
: ,'INPUT.toggle type=checkbox'
.d("#.checked=.completed")
.ui("$completed=#.checked; (@method`PATCH .url:dehttp headers (.completed:?)):query")
,'LI'.d("$completed=.completed $editing= $patch=; a!" // $patch - "посылка" для сервера
,'INPUT.toggle type=checkbox'
.d("#.checked=.completed")
.ui("$patch=($completed=#.checked)") // кладем в $patch измененный completed
,'LABEL.view'
.d("? $editing:!; ! .title")
.e("dblclick","$editing=`yes")
,'INPUT.edit'
.d("? $editing; !! .title@value")
.ui("$patch=(.title=#.value)") // кладем в $patch измененный title
.e("blur","$editing=")
,'BUTTON.destroy'.d("")
)
.a("!? $completed $editing")
// если $patch не пустой, отправляем его серверу, потом сбрасываем
.u("? $patch; (@method`PATCH .url:dehttp headers $patch@):query $patch=")
К сожалению, не доступен сервер mySQL