Реализация ToDoMVC на Jiant

в 13:01, , рубрики: architecture, javascript, jiant, Программирование, метки: , ,

Благодарю всех кто прочитал, отозвался и оценил первый пост!

Для демонстрации как работает идеология Jiant — реализовал ToDoMVC, проект, созданный для оценки различных MVC фреймворков. Jiant не MVC фреймворк, а скорее подход к разработке с набором вспомогательных инструментов. На разработку у меня ушло порядка 8 часов с учетом чтения и понимания спецификации, изучения референтной реализации, localStorage, с которым не имел дела (очень простая штука) ну и всего прочего. Не знаю много это или мало, но вот столько. Результаты лежат по адресу: github.com/vecnas/todomvc-jiant. В Chrome и Firefox работает прямо с файловой системы, в IE — с сервера.

Спецификация

github.com/addyosmani/todomvc/wiki/App-Specification — спецификация на английском. Кратко изложу ключевые пункты здесь.
Нужно разработать инструмент для управления задачами (ToDo), следующие пункты должны быть реализованы:

  1. Хранение. Все задачи и их состояние должны сохраняться в локальном хранилище (HTML5 localStorage) и восстанавливаться при например перезапуске браузера.
  2. Навигация. Приложение должно поддерживать хэш-навигацию в браузере, более детально см. оригинал. Там задан формат навигации, но у Jiant-а немного отличный от этого формат, связано это с возможность задавать навигационные узлы и затем возвращаться к ним. Управляется кнопками на панели навигации. Есть три состояния: показать все, показать активные и показать завершенные
  3. Новые задачи. Вводятся заполнением поля ввода наверху страницы и нажатием клавиши Enter
  4. Массовые манипуляции. Специальная кнопка отмечает все задачи как выполненные или не выполненные. Состояние кнопки также синхронизируется с текущим состоянием всех задач. То есть если мы добавим новую когда все уже выполнены — индикатор перестанет показывать что все сделано
  5. Задача. Представляет три интерактивных элемента — переключатель сделано-не сделано работает по щелчку, кнопку удаления задачи, и по двойному щелчку на названии — дает возможность редактировать текст задачи
  6. Редактирование. Открывается поле ввода, остальные элементы прячутся, по нажатию Enter или потере фокуса — сохраняет текст, по нажатию Escape — отменяет все изменения, если при сохранении текст пустой — удаляет задачу
  7. Счетчик активных задач. Показывает количество текущих активных задач, синхронизируется на любые изменения, текст должен быть грамотным («1 задача», но «2 задачи»)
  8. Очистить завершенные. Показывает количество завершенных задач, удаляет их по щелчку и показывается только если есть хотя бы одна завершенная задача
  9. Панель со счетчиками и кнопками навигации показывается только если есть хотя бы одна задача

На входе выдан шаблон проекта, содержащий html код и некоторые интерактивные скрипты, а также все стили.

Структура Jiant проекта

На текущий момент идеальная структура выглядит следующим образом:

  1. Файл определения приложения — объявление json переменной, описывающей API приложения
  2. «Зажигание» — это вызов bindUi где-либо где удобно пользователю
  3. Набор «плагинов» — логики приложения, каждая из которых инкапсулирована в своей песочнице и работает через API приложения

Начальное проектирование

Первая стадия в подходе Jiant это максимально абстрактное проектирование. Самое важное и возможно непривычное (по-крайней мере для меня самого) — это полностью абстрагироваться от того как все будет реализовываться. Создаю первую версию описания приложения, глядя на описание заказчика. Здесь я излагаю первые соображения, после чтения спецификации, а не подготовленные по результатам проекта идеальные рассуждения. То есть все жизненно, как оно происходит.

Состояния

Раз нужна хэш навигация, значит у приложения будут состояния. Перечисляем их, прямо берем список из спецификации, не надо думать:

    states: {

      "": {
        go: function () {},
        start: function(cb) {},
        end: function(cb) {}
      },

      active: {
        go: function () {},
        start: function(cb) {},
        end: function(cb) {}
      },

      completed: {
        go: function () {},
        start: function(cb) {},
        end: function(cb) {}
      }

    }

Пояснение: согласно формату, если переменная описания приложения содержит секцию states — Jiant загружает перечисленные в ней состояния и реализует для каждого три метода: go, start, end. go служит для перехода в состояние, start и end для реакции на начало или конец состояния. Наиболее краткая запись выглядит так:

    states: {

      "": {},

      active: {},

      completed: {}

    }

Но в этом случае у нас не будет автозавершения в IDE, так что для собственного удобства и лучшего документирования я использую первую нотацию. Пустое состояние соответствует «неопределенным» ситуациям — например, когда просто загружено окно браузера без любых хэшей.
Все, это все что нужно для работы хэш-навигации. Весь функционал будет добавлен при инициализации приложения Jiant'ом.

Чтобы полностью закрыть тему проектирования состояний — можно сделать одно состояние с двумя параметрами, например:

  states: {
    "": {
      go: function(showActive, showCompleted) {}
    }
  }

И это тоже будет работать. Но, так как у нас по контролу на состояние, то из чисто утилитарных соображений — завести три состояния кажется удобней. Опять же это первое интуитивное решение. Кстати, в итоговой версии так и остались эти состояния.

События

Теперь определяем события уровня приложения, снова абстрактно. Интуитивно кажется что следующий список правилен:

    events: {

      todoAdded: {
        fire: function(todo) {},
        on: function(cb) {}
      },

      todoRemoved: {
        fire: function(todo) {},
        on: function(cb) {}
      },

      todoStateChanged: {
        fire: function(todo) {},
        on: function(cb) {}
      }

    }

Единственное размышление вызвало последнее событие — надо ли разбить его на два — на завершение или ре-активацию todo. Снова весь код поддержки событий работает внутри Jiant, пользователю нужно только определить абстрактный список событий и воспользоваться им. Каждое событие имеет два метода — достаточно очевидных. Функция cb (callback, параметр метода on) принимает в точности те же аргументы что вызов fire.

Интерфейс

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

    views: {

      main: {
        batchToggleStateCtl: ctl,
        newTodoTitleInput: input,
        todoList: container
      },

      controls: {
        activeCountLabel: label,
        clearCompletedCtl: ctl,
        completedCountLabel: label,
        showAllCtl: ctl,
        showActiveCtl: ctl,
        showCompletedCtl: ctl
      }

    },

— полез в html и обнаружил что идентификаторы на элементы уже прописаны, структура задана, поэтому логичней будет просто ее применить. Итог:

    views: {

      header: {
        newTodoTitleInput: input
      },

      main: {
        batchToggleStateCtl: ctl,
        todoList: container
      },

      footer: {
        activeCountLabel: label,
        clearCompletedCtl: ctl,
        completedCountLabel: label,
        showAllCtl: ctl,
        showActiveCtl: ctl,
        showCompletedCtl: ctl
      }

    }

и эта структура, созданная на 10й минуте проектирования, уже не менялась до конца проекта.

Шаблоны

Исходя из спецификации видим один крайне динамичный элемент — визуальное представление задачи. Количество их разное, они добавляются и убираются, значит для этого согласно идеологии Jiant просто необходимо использовать шаблон, определим его:

    templates: {

      tmTodo: {
        deleteCtl: ctl,
        editCtl: ctl,
        stateMarker: label,
        toggleStateCtl: ctl,
        titleInput: input,
        hiddenInEditMode: collection,
        titleLabel: label
      }

    }

Снова дабы не утруждать лишний раз мозг — просто переносим все из описания заказчика, по принципу «кнопка удаления задачи» — deleteCtl.
Здесь следует кое-что сказать о шаблонах. Первое, можно увидеть что для шаблона определены поля — они во-первых проверяются на валидность при запуске приложения, во-вторых привязываются после создания элемента из шаблона. Каждый шаблон имеет два метода: parseTemplate, parseTemplate2Text, принимающие параметры для подстановки. Шаблон не содержит никакой логики, только подстановку значений, и это намеренно — место логики в javascript коде. Позднее в приложении появился еще один шаблон, введенный больше для того чтобы показать как подставляются значения:

  templates: {
    ....
      itemsLeft: {},
      itemsLeft1: {}

  }

реализация:

        <div id="itemsLeft1" style="display: none;">
            <strong>!!count!!</strong> item left
        </div>

        <div id="itemsLeft" style="display: none;">
            <strong>!!count!!</strong> items left
        </div>

и использование:

    tm.parseTemplate2Text({count: count})

Модель данных пока не проектируем, об этом ниже. Хочется поскорей запустить и увидеть что ничего не ломается, для этого нужен стартер.

Стартер

Исходя из идеальной структуры, стартер приложения поместим в app.js файл (имеющийся в базовом шаблоне «от заказчика») и его код следующий:

  jQuery(function() {

    jiant.bindUi("", todomvcJiant, true);

  });

Так как код html уже задан заказчиком (и менять не хочется, чтобы не менять css), то префикс здесь используется пустой (первый параметр), переменную todomvcJiant определяем в файле определения приложения (она содержит views, templates, states, events) и включаем режим разработки.

Реализация в HTML

Ну все, у нас есть только файл определения и стартер, никакой логики, но уж больно хочется запустить приложение. Запускаю html в Chrome и вижу:

  1. Алерт с сообщением от Jiant, в котором написано «No history plugin and states configured. Don't use states or add $.History plugin»
  2. Еще один алерт с сообщением о нереализованных элементах интерфейса
  3. И повтор текста второго алерта в консоли: non existing object referred by class under object id, non existing object referred by id, check stack trace for details, expected obj id:

Добавляю history — идет в комплекте. Теперь остается реализовать абстрактное определение на уровне html, например так:


			<section id="main">
				<input id="toggle-all" class="batchToggleStateCtl" type="checkbox" style="display: none;">
				<label for="toggle-all">Mark all as complete</label>
				<ul id="todo-list" class="todoList">
				</ul>
			</section>

— идентификаторы уже расставлены, остается добавить классы на нужные элементы. Реализация шаблона:

        <div id="tmTodo" style="display: none;">
            <li class="stateMarker">
                <div class="view hiddenInEditMode">
                    <input class="toggle toggleStateCtl" type="checkbox">
                    <label class="editCtl titleLabel"></label>
                    <button class="destroy deleteCtl"></button>
                </div>
                <input class="edit titleInput" value="">
            </li>
        </div>

Следует заметить что в отличие от общей практики помещать шаблоны в <script type=«someUnreadableTypeToFoolBrowser»>, Jiant использует правильную html структуру, это связано с тем что внутри script тэга нет DOM модели и невозможно провести валидацию привязки полей шаблона к реализации.

Наконец, Jiant перестал сообщать о несвязанных элементах, абстрактная модель приложения привязана к реализации и готова к использованию.

Плагины

Следуя идеологии Jiant — любая логика добавляется плагинами. Если появляется новая вьюшка или состояние-событие, мы добавляем ее/его к файлу описания приложения и реализацию к html. То есть процесс расширения приложения всегда постоянный и контролируемый, что крайне важно для крупных разработок, начинающихся с малого.

Модель

Встает вопрос — нужна ли данному приложению централизованная модель данных? Теоретически каждый плагин может содержать свою модель и синхронизировать ее на основе происходящих в приложении событий. На практике я для сравнения написал такой вариант и как и ожидалось — получилось много ненужного и повторяющегося кода. Поэтому реализуем модель (практически можно было бы поместить реализацию прямо в json файл описания приложения, но тогда его станет сложнее читать, поэтому вынесем отдельным плагином, следуя идеологии), model.js:

todomvcJiant.model.todo = (function($, app) {

  var todos = [];

  return {
    add: function(title, completed) {
      var todo = {title: title, completed: completed ? true : false};
      todos.push(todo);
      app.events.todoAdded.fire(todo);
      return todo;
    },
    remove: function(todo) {
      todos = $.grep(todos, function(value) {return value != todo;});
      app.events.todoRemoved.fire(todo);
      return todo;
    },
    getAll: function() {
      return todos;
    },
    getCompleted: function() {
      return $.grep(todos, function(value) {return value.completed});
    },
    getActive: function() {
      return $.grep(todos, function(value) {return !value.completed});
    }
  }
})($, todomvcJiant);

Простейшая реализация на основе массива. Стоит заметить только что здесь мы запускаем некоторые события приложения. В данном случае добавление модели в json описание проекта носит косметический характер (например, там нет метода getActive()), Jiant пока никак не занимается моделями данных.

Реализация плагинов

Дальше, для каждого элемента логики из спецификации — пишем свой плагин и добавляем его. В любой момент времени у нас все работает, функционал наращивается, не задевая остальное. Ниже пара примеров, комментарий прямо в коде

stateCtls.js

jiant.onUiBound(function($, app) { // плагин регистрируется на событие когда API приложения проинициализировано

  var ctlsView = app.views.footer,
      ctls = {
        "showActiveCtl": app.states.active, // просто используем ссылки на состояния
        "showCompletedCtl": app.states.completed,
        "showAllCtl": app.states[""]
      };

  $.each(ctls, function(key, state) {
    ctlsView[key].click(function() {
      state.go();
    });
    state.start(function() { // состояние может включаться при переходе на него или при начальной загрузке страницы
      ctlsView[key].addClass("selected");
    });
    state.end(function() {
      ctlsView[key].removeClass("selected");
    });
  });

});
footerVisibility.js
jiant.onUiBound(function($, app) {

  app.events.todoAdded.on(updateView);  // подписываемся на событие
  app.events.todoRemoved.on(updateView);

  function updateView() { // конкретно добавленное todo не интересует, так как все время проверяем текущее состояние модели
    app.model.todo.getAll().length > 0 ? app.views.footer.show() : app.views.footer.hide();
  }

});

Аналогично добавляются другие плагины, используя onUiBound. Когда плагину требуется новый функционал API — создаем абстрактное объявление и реализацию. В данном случае в большинстве плагинов получилось что-то вроде глобального события smthChanged в ответ на которое плагины обновляют свой статус, но это частное совпадение.
Стоит заметить что html используется только как html, никаких кастом атрибутов или тэгов. Вся логика работы с UI написана на jQuery функциях.
Забавный факт — не понадобилось вводить идентификатор объекта. Вся работа ведется через прямые ссылки на объекты. Ссылка на UI реализацию объекта todo сохраняется как поле объекта todo, внутри todoRenderer.js и никем за его пределами не используется, порядок сохраняется внутри модели.

PS

Уже когда все написал на последней строчке понял что после изменения текста задачи — новый текст не сохраняется в localStorage, пока не произойдет какое-нибудь из уже имеющихся событий. Чтобы исправить, добавил новое событие todoTitleModified, кидается в редакторе после установки нового текста (todoRenderer.js) и подписка на событие в модуле сохранения (persistence.js).

Автор: Vecnas

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js