Изучаем Derby 0.6, пример #2

в 14:20, , рубрики: derby.js, Derbyjs, fullstack development, javascript, Веб-разработка, реактивное программирование, метки: , ,

todos

Этот пост продолжение серии, начатой здесь. Сегодня мы создадим, так называемый, «список дел» (Todo-
list из проекта TodoMVC). За основу возьмем варинт, сделанный на Angular, и попробуем воссоздать функционал на derby.

Исследуем рабочий вариант

Итак, посмотрим, что у нас есть в ангуляровском варианте, и как оно работает (поиграйтесь сами — все проверьте):

  • новая задча вводится в верхнем поле ввода, в список попадает при нажатии на enter
  • любую задачу в списке можно удалить, кликнув на «крестик» справа от задачи (появляется, если навести мышку на задачу)
  • задачи можно помечать как «выполненные», кликнув на «галочку» слева от задачи (отметку можно снимать)
  • при двойном клике мышкой на задаче, она переходит в режим редактирования, привим, жмем enter — она обновляется
  • если у нас есть выполненные задачи, справа снизу появляется кнопка «clear completed», если нажать на нее выполненные задачи удалятся
  • ведется подсчет (и отображается) выполненных и активных задач. Снизу в статусной строке
  • так же снизу, в статусной строке есть 3 ссылки (all, active, completed, меняющие url на '#/', '#/active' и '#/completed' соответственно), кликнув по ним мы меняем фильт задач: либо отображаются все задачи, либо только активные (не выполненные), либо только выполненные

Что возьмем за основу

Исходя из наших целей (узнать лучше derbyjs), мы не будем здесь придумывать стили, они уже написаны и используются без изменений в большинстве реализаций TodoMVC. Просто возьмем css-файл. Беглый взгляд по нему показывает, что нам нужно будет взять еще и картинку для фона bg.png. Так же, в качетстве каркаса возьмем сгенерированный ангуляром html (я скопировал его с помощью инструментов разработчика в браузере и немного почистил от ангуляровских деректив).

Базовый html-код

  <section id="todoapp">
    <header id="header">
      <h1>todos</h1>
      <form id="todo-form">
        <input id="new-todo" placeholder="What needs to be done?" autofocus">
      </form>
    </header>
    <section id="main">
      <input id="toggle-all" type="checkbox">
      <label for="toggle-all">Mark all as complete</label>
      <ul id="todo-list">
        <li>
          <div class="view">
            <input class="toggle" type="checkbox">
            <label>hello</label>
            <button class="destroy"> </button>
          </div>
          <form >
            <input class="edit">
          </form>
        </li>
      </ul>
    </section>
    <footer id="footer">
      <span id="todo-count"><strong>0</strong>
        <span>items left</span>
      </span>
      <ul id="filters">
        <li><a href="/" class="selected">All</a></li>
        <li><a href="/active">Active</a></li>
        <li><a href="/completed">Completed</a></li>
      </ul>
      <button id="clear-completed">Clear completed (0)</button>
    </footer>
  </section>

Как видно, наш html состоит из 3-х основных блоков:

  1. header — здесь находтся главный input. Он нужен для ввода новых задач;
  2. main — основной блок, здесь хранится сам список задач;
  3. footer — статусная строка, здесь информация, переключение между фильтрами и кнопка 'Clear completed'

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

Так, давайте рассуждать. Что у нас будет в проекте? Будет файл стилей, будет отдача статичесих данных (какртинки фона), будут html-шаблоны, так же будет как минимум 2-файла — две части дерби приложения (серверная часть, и само дерби-приложение). Исходя из этого всего, я накидал такую файловую структуру приложеня (можете сделать любую другую):

public/
  bg.png
app  # Дерби-приложение
  views/
    index.html
  css/
    index.css
  index.js  # Код дерби-приложения
server.js  # Серверная часть дерби
package.json

Обратите внимании, css-файл лежит внутри папки app, а не внутри public. Это связано с тем, что дерби работает с стилями по особому. В итоге они будут будут вставлены напрямую в head страницы в теги style — как показали исследования google-а (со слов создателей derby) — это лучший по скорости способ размещения стилей.

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

Вообще (на будущее), можно разбивать проект на несколько дерби-приложений, например, клиентская часть и админка. Это оправдано по двум причинам, чтобы не отдавать лишние данные (шаблоны, стили, код), и чтобы уменьшить связанность. То есть будет так: в проекте будет одна серверная часть и несколько дерби-приложений (в данном случае два).

В файле package.json в качестве зависимостей будут все те же два модуля: derby@0.6.0-alpha5 и derby-starter.

Начинаем

Создем файловую структуру. Фоновую картику и стили качаем по ссылкам, которые я указал вначале, package.json создаем при помощи npm init (можете посмотреть в предыдущем уроке).

Html немножко подправим, во-первых, как и в предыдущем примере, он должен находиться в предопределенном шаблоне Body:, во-вторых вынесем header, main и footer в отдельные derby-шаблоны.

Итоговый index.html
<Body:>
  <section id="todoapp">
    <view name="header"/>
    <view name="main"/>
    <view name="footer"/>
  </section>
  
<header:>
  <header id="header">
    <h1>todos</h1>
    <form id="todo-form">
      <input id="new-todo" placeholder="What needs to be done?" autofocus">
    </form>
  </header>

<main:>
  <section id="main">
    <input id="toggle-all" type="checkbox">
    <label for="toggle-all">Mark all as complete</label>
    <ul id="todo-list">
      <li>
        <div class="view">
          <input class="toggle" type="checkbox">
          <label>hello</label>
          <button class="destroy"> </button>
        </div>
        <form >
          <input class="edit">
        </form>
      </li>
    </ul>
  </section>

<footer:>
  <footer id="footer">
      <span id="todo-count"><strong>0</strong>
        <span>items left</span>
      </span>
    <ul id="filters">
      <li><a href="/" class="selected">All</a></li>
      <li><a href="/active">Active</a></li>
      <li><a href="/completed">Completed</a></li>
    </ul>
    <button id="clear-completed">Clear completed (0)</button>
  </footer>

Как вы могли заметить, вызов собственных шаблонов происходит при помощи тега view, где в атрибуте name, задается имя шаблона.

Для начала, создадим минимальный работающий код, чтобы иметь возможность видеть в браузере результат и наращивать функционал.

Файл server.js из прошлого примера немного расширен, чтобы учесть структуру проекта и отдавать статические файлы.

server.js

var server = require('derby-starter');

var appPath = __dirname + '/app';

var options = {
  static: __dirname + '/public'
};

server.run(appPath, options);

Напомню, что из-за учебной природы проекта, в качестве серверной части, мы используем модуль derby-starter. Если заглянуть внутрь, то отдача статических файлов там — это классическое испльзование express-овского static-middlware. Посмотрите сами.

Минимальный index.js:

var derby = require('derby');
var app = module.exports = derby.createApp('todos', __filename);

// Делаем app глобальной, чтобы иметь к ней доступ в консоле браузера
// (конечно только на время разработки)
global.app = app;

app.loadViews (__dirname+'/views');
app.loadStyles(__dirname+'/css');

app.get('/', getTodos);

function getTodos(page, model){
  page.render();
}

Все, запускаем npm start (или напрямую node server.js), видим в браузере http://localhost:3000/ результат:

Изучаем Derby 0.6, пример #2

Стили с версткой подцепились. Начало положено.

Проектируем url

В прошлом уроке я говорил, что дерби-разработчик должен начинать разработку с разбиения проекта на url-адреса. Это связано с возмжностью дерби генерировать страницы как на клиенте так и на сервере, что очень любят поисковые системы. Итак, изучая ангуляровский вариант мы заметили, что в футере есть 3 ссылки, меняющие url и соответственно фильтр по задачам. Здесь мы понимаем, что у нас в приложении должно быть 3 обработчика get-запросов. Что-то типа:

app.get('/', getAllTodos);
app.get('/active', getActiveTodos);
app.get('/completed', getCompletedTodos);

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

Проектируем данные

Сами задачи у нас будут храниться в коллекции todos. Каждая задача будет представленна двумя полями:

  1. text — описание задачи
  2. completed — признак того, что задача выполнена

К этому нужно добавить, что у каждой задачи еще, конечно же, будет поле id — derby добавит его автоматически при добавлении элемента в коллекцию.

Итак, в соответствии с методологией дерби, в контроллере (функции, обрабатывающей запрос к url) до вызова render мы должны подготовить данные и зарегистрировать подписки на обновление данных. Получается обработчик, схематично должен быть примерно таким:

function getTodos(page, model){
  model.subscribe('todos', function(){
    page.render();
  });
}

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

  • «пути», начинающиеся с символа подчеркивания (например, "_session", "_page" и т.д.)
  • в чем особенность "_page"
  • что такое в дерби фильтры
  • что такое ref к определенным данным в коллекции

В прошлом уроке я говорил о так-называемых «путях». Мы исопльзуем их в операциях с моделями. Например при подписке на данные: model.subscribe('путь'), при получении и при записи данных в модель: model.get('путь'), model.set('путь', значение). Примеры путей:

  • 'todos' — ссылаемся на всю коллекцию todos
  • 'users.42' — ссылаемся на запись в коллекции users c id = 42

Так вот. Первый сергмент пути — это, как вы поняли, имя коллекции. Это имя в дерби может начинаться либо с латинской буквы, либо с сиволов $ или _. Все коллекции, начинающиеся с $ и _ особенные, они не синхронизируются с сервером (являются локальными для модели, а модель в приложении-дерби создается всего одна). Коллекции начинающиеся с $ зарезервированы дерби для собственных нужд. Коллекции же, начинающиеся с символа подчеркивания используются разработчиками.

Давайте, проведем небольшой эксперимент. Откройте в браузере консоль разработчика и наберите app.model.get() — поизучайте вывод.

Среди "_"-коллекций есть одна особенная — _page, она затирается каждый раз при смене url — это делает ее очень удобной для хранения всевозможных рабочих данных. В этом уроке вы еще увидите примеры.

Перейдем к фильтрам. Если вы читали документацию по моделям, вы знаете, что в дерби, есть различные механизмы, позволяющие облегчить работу с реактивными данными. Это, например, реактивные функции, подписки на различные события, происходящие с данными, фильтры-данных, сортировщики даннх.

Обсудим фильтры. Как нам реализовать, например фильтр, показывающий только активные задачи:

Регистрируем по определенному имени функцию-фильтр (имя обязательно для сериализации в бандл). Документация говорит, что регистрировать их нужно строго в app.on('model')

app.on('model', function(model) {
  model.fn('completed', function(item) { 
    return  item.completed;
  });
});

И далее в контроллере, используем этот фильтр для фильтрации коллекции todos:

function getPage(page, model){
  model.subscribe('todos', function() {
    var filter = model.filter('todos', 'completed')
    filter.ref('_page.todos');
    page.render();
  });
}

Очень важна здесь строка filter.ref('_page.todos');, в ней отфильтрованный «todos» становится доступным по пути _page.todos. Собрав все вместе, я предлагаю вот такой код фильтров с контроллерами:

app.on('model', function(model) {
  model.fn('all',       function(item) { return true; });
  model.fn('completed', function(item) { return  item.completed;});
  model.fn('active',    function(item) { return !item.completed;});
});

app.get('/',          getPage('all'));
app.get('/active',    getPage('active'));
app.get('/completed', getPage('completed'));

function getPage(filter){
  return function(, page, model){
    model.subscribe('todos', function() {
      model.filter('todos', filter).ref('_page.todos');
      page.render();
    });
  }
}

Как вы, наверное, заметили, чтобы все унифицировать, пришлось сделать фальш-фильтр «all», но думаю это не большая плата за отсутствие дублей.

Ладно мы немножно отвлеклись. Давайте оживим приложение.

Добавление и вывод задач

Инпут для ввода данных в верстке у нас выглядет так:

    <form id="todo-form">
      <input id="new-todo" placeholder="What needs to be done?" autofocus">
    </form>

Классический паттерн в дерби (как и во многих современных фреймворках) — рективное связывание. Свяжем значение, вводимое в input, с каким-нибудь путем в _page. Так же зарегистрируем обработчик события submit формы, для того, чтобы обрабатывать нажание на enter:

    <form id="todo-form" on-submit="addTodo(_page.newTodo)">
      <input id="new-todo" placeholder="What needs to be done?" autofocus value="{{_page.newTodo}}">
    </form>

Вместо, on-submit мы естественно могли бы написать on-click, on-keyup, on-focus — то есть это стандартный способ обработки событий в дерби. Обротчик помещаем в app.proto (когда будем обсуждать дерби-компоненты, увидем, что каждая компонента хранит свои обработчики в себе, но пока делаем так):

app.proto.addTodo = function(newTodo){

  if (!newTodo) return;

  this.model.add('todos', {
    text: newTodo,
    completed: false
  });

  this.model.set('_page.newTodo', '');
};

Проверяем не пустой ли текст, добавляем задачу в коллекцию, очищаем input. Возможно вы заметили, что в обработчике у нас только один параметр, если бы нам, для каких-то нужд, понадобились ссылки на объект-событие или на сам html-элемент, нам нужно было бы явно прописать это в html таким образом: on-submit="addTodo(_page.newTodo, $event, $element)", $event и $element — особые параметры, заполняются самим дерби.

Теперь вывод отфильтрованного списка задач — отредактируем наш ul-элемент:

  <ul id="todo-list">
    {{each _page.todos as #todo, #index}}
    <li class="{{if #todo.completed}}completed{{/}}">

      <div class="view">
        <input class="toggle" type="checkbox" checked="{{#todo.completed}}">
        <label>{{#todo.text}}</label>
        <button class="destroy"> </button>
      </div>
      <form>
        <input class="edit">
      </form>

    </li>
    {{/each}}
  </ul>

Так, что сделали:

  • циклом пробегаемся по всем todos (уже отфильтрованным) — создаем для них элементы-li
  • выводим описание задачи в lable
  • привязали checkbox к todo.completed
  • у тега li устанавливаем класс completed, если задача выполнена.

Удаление элементов

Делается элементарно:

<button class="destroy" on-click="delTodo(#todo.id)"> </button>

app.proto.delTodo = function(todoId){
  this.model.del('todos.' + todoId);
};

Причем можно было еще короче:

<button class="destroy" on-click="model.del('todos.' + #todo.id)"> </button>

Удаление всех «завершенных» задач аналогично (кнопка «Clear completed» снизу справа):

    <button id="clear-completed" on-click="clearCompleted()">
      Clear completed (0)
    </button>

app.proto.clearCompleted = function(){
  var todos = this.model.get('todos');

  for (var id in todos) {
    if (todos[id].completed) this.model.del('todos.'+id);
  }
}

Редактирование элементов

По двойному щелчку мыши, задача должна перейти в режим редактирования. Судя по верстке, при переходе в этот режим, нам нужно будет добавить класс editing соответствующему элементу li. Так же попутно, нужно будет избавиться от выделения, которое возникает при двойном нажатии и правильно поставить фокус на нужный нам input.

Предлагаю сделать следующим образом: информацию о редактируемой задаче будем хранить, используя путь — _page.edit. Там будем хранить id редактируемой задачи, и текст.

Зачем хранить текст отдельно, он же у нас уже хранится в самой задаче?

Все зависит от целей. Если бы мы связали с input-ом текст напрямую из задачи, то пользователь редактировал бы элемент напрямую в базе данных. То есть его правки (каждое нажатие на кнопку) мгновенно бы показывалось у других пользователей в браузере. Более того, несколько пользователей одновременно могли бы править текст и видеть все изменения, но это не то, что нам нужно. Обычным сценарием является фиксация в базе окончательно отредактированных данных, либо отказ от фиксации… То есть у всех все должно обновлятья только тогда, когда пользователь нажал на enter.

Итак, реализуем все это:

  <ul id="todo-list">
    {{each _page.todos as #todo}}
    <li class="{{if #todo.completed}}completed{{/}} {{if _page.edit.id === #todo.id}}editing{{/}}">

      <div class="view">
        <input class="toggle" type="checkbox" checked="{{#todo.completed}}">
        <label on-dblclick="editTodo(#todo)">{{#todo.text}}</label>
        <button class="destroy" on-click="delTodo(#todo.id)"> </button>
      </div>
      <form on-submit="doneEditing(_page.edit)">
        <input id="{{#todo.id}}" class="edit" value="{{_page.edit.text}}" on-keyup="cancelEditing($event)">
      </form>

    </li>
    {{/each}}
  </ul>

app.proto.editTodo = function(todo){

  this.model.set('_page.edit', {
    id: todo.id,
    text: todo.text
  });

  window.getSelection().removeAllRanges();
  document.getElementById(todo.id).focus()
}

app.proto.doneEditing = function(todo){
  this.model.set('todos.'+todo.id+'.text', todo.text);
  this.model.set('_page.edit', {
    id: undefined,
    text: ''
  });
}

app.proto.cancelEditing = function(e){
  // 27 = ESQ-key
  if (e.keyCode == 27) {
    this.model.set('_page.edit.id', undefined);
  }
}

При двойном щелчке срабатыват функция editTodo, в ней мы запоняем _path.edit, снимаем лишнее выделение, переключаем фокус на нужный нам input (здесь я немножко схитрил, дав input-у id = todo.id).

После окончания редактирования, жмем либо enter, либо esq. Соответственно срабатывает один из двух обработчиков: doneEditing, cancelEditing. Изучите код — ничего нового.

Количество активных и выполненных задач — реактивные функции

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

Небольшая ремарка на счет архитектуры проекта

Следует отметить, что тот вариант реализации приложения, который я выбрал не является единственным. Обдумывая данный, конкретный проект с ходу приходит на ум использование live-query — это еще один офигенный механизм дерби, позволяющий сделать mongo-запрос в базу данных, результаты которого будут реаткивно обновляться. В запросах, конечно же, можно использовать различные отборы, сортировки, органичения по количеству ($limit, $skip, $orderby). Можно так же делать запросы, возвращающие количество элементов в коллекции (с какими-нибудь отборами) — это как раз наш случай. «Живые» запросы мы изучем в одном из следующих постов, сейчас же я посчитал уместным показать реализацию через реактивные функции, которые тоже часто используются в реальных приложениях.

Итак, рективная функция — это функция, которая срабатывает каждый раз при изменении каких-то данных. То есть мы должны указать, что вот эта конкретная реактивная функция будет следить за изменением вот этих конкретных данных. Эти данные приходят в эту функцию в качестве параметров. Далее она что-то вычисляет и возвращает результаты. Ее результаты привязывются к какому-то определенному «пути»…

Ладно, это все абстрактно и поэтому тяжело для восприятия. Давайте на нашем примере. У нас есть коллекция todos с активными и выполненными задачами. Хорошо бы, чтобы при любом изменении коллекции, нам, где-нибудь (например, по пути _page.counters), были доступны счетчики активных и выполенных задач. Что-то типа:

_page.counters = {
  active: 2,
  completed: 3
}

Тогда бы мы смогли легко вывести эти данные в футер.

Один из вариантов получить данные счетчики — использовать реактивные функции. Регистрируются они так же, как и фильтры:

app.on('model', function(model) {
  model.fn('all',       function(item) { return true; });
  model.fn('completed', function(item) { return  item.completed;});
  model.fn('active',    function(item) { return !item.completed;});

  model.fn('counters', function(todos){
    var counters = { active: 0, completed: 0 };
    for (var id in todos) {
      if(todos[id].completed) counters.completed++; else counters.active++;
    }
    return counters;
  })
});

Вот как мы зарегистрировали функцию counters, но это еще не все. Ее еще нужно запустить в нужный момент и привязать к путям. Это делается в контроллере, при помощи функции model.start:

    model.subscribe('todos', function () {
      model.filter('todos', filter).ref('_page.todos');
      model.start('_page.counters', 'todos', 'counters');
      page.render();
    });

Все, теперь счетчики доступны в наших шаблонах. Дорабатываем футер:

<footer:>
  <footer id="footer">
      <span id="todo-count"><strong>{{_page.counters.active}} </strong>
        <span>items left</span>
      </span>
    <ul id="filters">
      <li><a href="/"           class="{{if $render.url==='/'         }}selected{{/}}">All</a></li>
      <li><a href="/active"     class="{{if $render.url==='/active'   }}selected{{/}}">Active</a></li>
      <li><a href="/completed"  class="{{if $render.url==='/completed'}}selected{{/}}">Completed</a></li>
    </ul>
    <button id="clear-completed" on-click="clearCompleted()" class="{{if _page.counters.completed==0}}hidden{{/}}">
      Clear completed ({{_page.counters.completed}})
    </button>
  </footer>

Показали нужные счетчики, попутно скрыв кнопку «Clear completed», если завершеных задач нет. Так же добавили класс selected той ссылке, которая активна, воспользовавшись информацией полученной в процессе изучения app.model.get() в консоли браузера. Да, зарезервированная коллекция $render содержит различную полезную информацию, в частности url, по которому шел рендеринг. Загляните в консоль еще раз.

Итог

Давйте поиграимся с тем, что получилось, откроем несколько вкладок, проверим, что все синхронизируется:

Изучаем Derby 0.6, пример #2

Проект на github, на случай, если захотите сравнить код.

P. S.
Если не хотите пропустить следующие статьи по derbyjs, подписывайтесь на обновления в моем профиле: zag2art. Сам так делаю — на хабре же нет возможности добавить в трекер определенных (очень интересный) хаб, чтобы точно ничего не пропустить.

Автор: zag2art

Источник


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


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