Dap в действии. Пишем TodoMVC. Часть 1 +3


Первая статья про dap, очевидно, не стала моим писательским успехом: подавляющее большинство коментов к ней свелись к «ниасилил» и «ниасилил, но осуждаю». А приз за самый единственный конструктивный комментарий верхнего уровня достается OldVitus, за совет продемонстрировать dap на примере TodoMVC, чтобы было с чем сравнить. Чем я в этой статье и займусь.

TodoMVC, если кто не знает, это такой стандартный UI-хелловорлд, позволяющий сравнить решения одной и той же задачи — условного «Списка дел» — средствами разных фреймворков. Задачка, при всей своей простоте (ее решение на dap влезает «в один экран»), весьма иллюстративна. Поэтому на ее примере я попробую показать, как типичные для веб-фронтенда задачи реализуются с помощью dap.

Искать и изучать формальное описание задачи я не стал, а решил просто среверсить один из примеров. Бэкенд в рамках этой статьи нам не интересен, поэтому сами мы его писать не будем, а воспользуемся одним из готовых с сайта www.todobackend.com, оттуда же возьмем и пример клиента и стандартный CSS-файл.

Для использования dap вам не нужно ничего скачивать и устанавливать. Никаких npm install и вот этого всего. Не требуется создавать никаких проектов с определенной структурой каталогов, манифестами и прочей атрибутикой IT-успеха. Достаточно текcтового редактора и браузера. Для отладки XHR-запросов может еще потребоваться веб-сервер — достаточно простейшего, типа вот этого расширения для Chrome. Весь наш фронтенд будет состоять из одного-единственного .html-файла (разумеется, ссылающегося на скрипт dap-движка и на стандартный CSS-файл TodoMVC)

Итак, с чистого листа.

1. Создаем .html файл


<!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>

Обычная html-заготовка, в которой подключаем CSS-файл, любезно предоставляемый сайтом www.todobackend.com и dap-движок, не менее любезно предоставляемый сайтом dap.js.org

2. Копируем DOM-структуру оригинального примера


Чтобы пользоваться стандартным CSS-файлом без переделок, будем придерживаться той же DOM-структуры, что и оригинальный пример. Открываем его в браузере Chrome, жмем Ctr+Shift+I, выбираем вкладку Elements и видим, что собственно приложение находится в элементе section id="todo-app">



Последовательно раскрывая это поддерево, переписываем его структуру в наш .html файл. Сейчас мы просто срисовываем по-быстренькому, а не пишем код, поэтому просто пишем сигнатуры элементов в 'одинарных кавычках', а в скобках их детей. Если детей нет — рисуем пустые скобочки. Следим за индентами и балансом скобок.

// здесь будет 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'()
  )
)

Oбратите внимание: повторяющиеся элементы (например, здесь это элементы LI) мы пишем в структуру по одному разу, даже если в оригинале их несколько; очевидно, что это массивы из одного и того же шаблона.

Формат сигнатур, думаю, понятен любому, кто писал руками HTML и CSS, поэтому останавливаться на нем подробно пока не буду. Скажу лишь, что теги пишутся ЗАГЛАВНЫМИ буквами, а отсутствие тега равносильно наличию тега DIV. Обилие здесь #-элементов (имеющих id) обусловлено спецификой подключаемого CSS-файла, в котором используются в основном как раз id-селекторы.

3. Вспоминаем, что dap-программа — это Javascript


Чтобы избавить нас от лишних скобочек в коде, dap-движок внедряет прямо в 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(). Этот метод, как понятно из его названия, рендерит полученный шаблон.

Итак, на данном этапе имеем .html-файл вот с таким содержанием:

<!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>

Можно открыть его в браузере, чтобы убедиться, что DOM-элементы генерятся, CSS-стили применяются, осталось только наполнить этот шаблон данными.

4. Получаем данные


Идем на страничку-оригинал, открываем в инструментах вкладку Network, включаем фильтр XHR, и смотрим, откуда берутся данные, и в каком виде.





Окей, понятненько. Список дел берется прямо из todo-backend-express.herokuapp.com в виде json-массива объектов. Замечательно.

Для получения данных в dap имеется встроенный конвертор :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("")
      )
    )

Обновляем эту нашу страничку в браузере и… если вы запускаете ее из файловой системы, то ничего не происходит. Проблема в том, что современные браузеры не разрешают кросс-доменные XHR-запросы из локальных документов.



Пришло время смотреть нашу страничку через http — с помощью любого локального вебсервера. Ну, или если вы пока не готовы писать dap своими руками, смотрите последовательные версии странички по моим ссылкам (не забывайте смотреть исходники — в Хроме это делается с помощью Ctrl+U)

Итак, заходим на нашу страничку по http:// и видим, что данные приходят, список строится. Отлично! Вы уже освоили операторы * и !, конвертор :query, константы и доступ к полям текущего элемента массива. Посмотрите еще раз на получающийся код. Он вам все еще кажется нечитаемым?

5. Добавляем состояние


Возможно, вы уже попробовали понажимать на галочки в списке дел. Сами галочки меняют цвет, но, в отличие от оригинала, родительский элемент 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
    )

Подобные манипуляции с CSS-классами — вещь довольно частая, поэтому для них в dap имеется специальный оператор !?
Обратите внимание, делаем мы это в а-правиле (от слова accumulate). Почему не в d-правиле? Отличие между этими двумя типами правил в том, что d-правило при обновлении полностью перестраивает содержимое элемента, удаляя старое и генеря все заново, тогда как a-правило не трогает имеющееся содержимое элемента, а «дописывает» результат к тому, что уже есть. Смена отдельного атрибута элемента LI не требует перестройки остального его содержимого, поэтому рациональней это делать именно в a-правиле.

Смотрим на результат. Уже лучше: нажатия на галочки меняют состояние соответствующего элемента списка дел, и в соответствии с этим состоянием меняется и визуальный стиль элемента. Но все еще есть проблема: если в списке изначально присутствовали завершенные дела — они не будут серенькими, т. к. по умолчанию a-правило не исполняется при генерации элемента. Чтобы исполнить его и при генерации, допишем в d-правило элемента LI оператор a!

      ,'LI'.d("$completed=.completed; a!" // Сразу же после инициализации переменной $completed используем ее в a-правиле

Смотрим. Окей. С состоянием $completed разобрались. Завершенные дела стилизуются корректно и при начальной загрузке, и при последующих ручных переключениях.

6. Редактирование названий дел


Вернемся к оригиналу. При двойном клике по названию дела включается режим редактирования, в котором это название можно поменять. Там это реализовано так, что шаблон режима просмотра «view» (с галкой, названием и кнопкой удаления) целиком прячется, а показывается элемент 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'

Теперь мы можем редактировать названия дел.

7. Отправка данных на сервер


Ок, в браузере мы дела редактировать уже можем, но эти изменения нужно еще и передавать на сервер. Смотрим, как это делает оригинал:



Внесенные изменения отправляются на сервер методом PATCH с неким URL вида 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
  }
})

Еще имеет смысл вынести объект headers в словарь, чтобы использовать его и в других запросах:

          .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")

А можно вынести этот PATCH-запрос «за скобки»:

      ,'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=")

Тут дело вот в чем. Правила реакции относятся к группе «up-правил», которые исполняются «снизу вверх» — от потомка к родителю, до самого корня (эта последовательность может быть прервана при необходимости). Это чем-то похоже на «всплывающие» события в DOM. Поэтому какие-то фрагменты реакции, общие для нескольких потомков, можно поручить их общему предку.

Конкретно в нашем случае выигрыш от такого делегирования не особо заметный, но если бы редактируемых полей было больше, то вынос этого громоздкого (по меркам dap, конечно) запроса в одно общее правило сильно помог бы сохранять код простым и читабельным. Так что рекомендую.

Смотрим: Теперь на сервер отправляются и изменения названия, и изменения статуса.

В следующей статье, если будет интерес, рассмотрим добавление, удаление и фильтрацию дел. А пока можно посмотреть финальный результат и другие примеры dap-кода на dap.js.org/docs




К сожалению, не доступен сервер mySQL