Pull to refresh

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

Reading time 14 min
Views 13K
todos

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

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


Итак, посмотрим, что у нас есть в ангуляровском варианте, и как оно работает (потратьте минут 5, чтобы разобраться в функционале):

  • новая задача вводится в верхнем поле ввода, в список попадает при нажатии на 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/ результат:



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

Проектируем 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, по которому шел рендеринг. Загляните в консоль еще раз.

Итог


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



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

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

Если нравится derbyjs — не сочтите за труд поставить звездочку на github
Only registered users can participate in poll. Log in, please.
Стоит ли продолжать серию статей?
94.35% Да 217
5.65% Нет 13
230 users voted. 48 users abstained.
Only registered users can participate in poll. Log in, please.
Что разобрать в следующий раз?
54.5% Авторизацию через passportjs (в т.ч. через соц-сети и т.д.) 121
45.5% Компоненты — офигенный механизм derby по разбиению функционала на слабосвязанные модули 101
0% Свой вариант (опишу в комментарии) 0
222 users voted. 56 users abstained.
Tags:
Hubs:
+32
Comments 7
Comments Comments 7

Articles