15 June 2011

Пример использования KnockoutJS

JavaScript
Sandbox
День добрый, хабрасообщество!В статье будет рассмотрен процесс создания web-страницы редактирования списка пользователей. Готовый пример можно забрать тут. Статья является не обзорной, а приближенной к реальным боевым действиям, потому настоятельно рекомендую ознакомиться с простым примером.Я решил попробовать использовать вышесказанный фреймворк. Необходимость этого шага обусловлена тем, что раз за разом изобретался очередной велосипед для описания rich логики на стороне клиента. Это выглядело примерно так:
(function($)
{
    var initListeners = function() {
        /*Включение слушателей событий на странице, например, click, keypress, focus и т.д.*/
    }

    var updateListeners = function() {
        /*Обновить слушатели на элементах, включить слушатели на вновь созданных элементах*/
    }

    var createUser = function() {
        /*Вызов логики Ajax для создания объекта на сервере и добавлении его на страницу*/
    }

    var updateUser = function() {
        /*Вызов логики Ajax для обновления объекта на сервере и добавлении его на страницу*/
    }

    var parseResponse = function() {
        /*Преобразовать JSON-ответ сервера в объекты с методами*/
    }

    initListeners();
})(jQuery)
Такой подход может показаться неплохим вариантом для javascript-кода длиною в 100 строк. Однако любая его модификация может обойтись дорого при разрастании кода в 200 и более строк. Основными недостатками на мой взгляд являются: наличие рутинных действий (управление событиями, добавление к JSON-объектам методов), плохо формализуемое описание структуры pageController, сильная связность html и javascript. По сути, часто подобный код скатывается в мешанину вложенных обратных вызовов и неуловимой логики выбора уровня описания новой функции. Было решено использовать фреймворк для придания коду структуры. Выбор пал на MVVM фреймворк KnockoutJS и вот почему:
  • Декларативная привязка событий к элементам DOM формализована и работает волшебно (имхо, live от jQuery — операция более низкого уровня)
  • Механизм синхронизации модели на клиенте JSON-данными использовать также просто как вилку
  • Для построения элементов DOM используются шаблоны (их удобство — совсем не новость, однако их использование вместе с KnockoutJS выглядит также естественно как хлеб и масло)
  • Возможность постепенного рефакторинга кода javascript на работу с Knockoutjs даже во время обеденного перерыва

Сформулируем задачу

Пусть необходимо написать часть приложения, ответственную за редактирование списка пользователей. Известно, что каждый пользователь представлен следующим набором полей:
{
 Id : "",
 Surname: "",
 FirstName: "",
 PatronymicName: "",
 Login: "",
 EMail: ""
}
Также известно, что сервер может выполнять такие действия над пользователями, как:
  1. Отправить текущий список пользователей клиенту
  2. Удалить пользователя по его ID
  3. Добавить/обновить запись пользователя
Все эти действия можно описать заглушкой, схематично представленную так:
function DataGate() {
 var modelStub = { users: [...] };
 return {
  Load : function(callback) { callback(modelStub); },
  DeleteUser : function(callback, id) { callback(true); },
  SaveOrUpdateUser : function(callback, user) { callback(user); }
 }
}
var gate = new DataGate();
Определим требования к отображению страницы (я буду называть элементы страницы как их представляет пользователь, а не как они реализованы в html-коде):
  • Список пользователей отображает актуальные данные с точки зрения сервера.
  • Пользователь может быть выбран из списка.
  • Выбранного пользователя можно удалить по кнопке над списком. Убрать пользователя из списка можно только после его успешного удаления с сервера.
  • Можно создать пользователя по кнопке над списком. В списке отобразить пользователя только после его успешного создания на сервере.
  • Любого пользователя списка можно редактировать. В списке обновить строку пользователя только после его успешного обновления на сервере.
  • Первая колонка списка пользователей должна содержать ФИО, что удобнее отдельных колонок на имя, фамилию и отчество.
Редактирование пользователя разумно сделать в отдельном диалоговом окне (так как не все его поля необходимы в списке, а некоторые поля являются составными, прямое редактирование которых — песня-хит, которая, впрочем, имеет решение). Необходимо учесть, что непосредственное изменение полей пользователя внутри диалогового окна приведет к изменению его представления внутри списка. Это нежелательно, так как необходимо сначала сохранить пользователя, и если все прошло успешно, то закрыть диалог и применить изменения на списке. Для организации этого поведения разумно разделить ViewModel страницы на две части. Тогда ViewModel списка будет наложена на html-код непосредственно после получения списка пользователей, а ViewModel редактирования пользователя будет наложена на родительский div диалога в момент его отображения.

Реализация списка пользователей

JavaScript-код

Определим ViewModel для списка пользователей:
var viewModel = {
 selectedUser : ko.observable(null), //Здесь будет ссылка на выбранного пользователя
 deleteSelectedUser : function() { /* Запрос сервера на удаление выбранного пользователя и последующее удаление его из массива users */ },
 createUser : function () { /* Открыть диалог по созданию пользователя */ }
}
Постойте, где же users?viewModel будет расширен утилитой, которая сформирует объекты-наблюдатели из JSON-строки или из POJO-объектов. Так организуется «приведение» простых объектов с сервера к объектам с методами и свойствами-наблюдателями, готовыми для использования совместно с KNockoutJS. Внимательно посмотрите на отрывок кода DataGate:
{ users: [...] }
Да, viewModel после выполнения процедуры маппинга будет иметь поле users, однако это будет не обычный массив, а массив-наблюдатель. Более того, маппинг выполняется глубоко, а это значит, что весь граф объектов с сервера будет приведен к аналогичному графу объектов-наблюдателей.Стоит отметить, что процессом такого маппинга можно гибко управлять:
  1. Определить функцию-конструктор для каждого объекта графа
  2. Если объект является массивом других объектов, то определить среди них ключевое поле
Эту настройку можно выполнить так:
var mapping = {
 users: {
  key: function(data) {
   return ko.utils.unwrapObservable(data.Id);
  },
  create: function(options) {
   return new userMapping(options.data);
  }
 }
}
Как видно, в качестве ключа используется поле Id, а для построения объекта пользователя используется функция конструктор:
var userMapping = function(user) {
 ko.mapping.fromJS(user, {}, this); 
 /*Выполнить привязку простого объекта user 
 к конструируемому объекту this */
 
this.FIO = ko.dependentObservable(function() {
  return this.Surname() + " " + this.FirstName() + " " + this.PatronymicName();
 }, this); 
 /* Выполняем требование отображения ФИО с обязательным указанием this внутри функции,
 вычисляющей ФИО относительно текущего экземпляра user */
 
 var _self = this;
 this.select = function() { viewModel.selectedUser(_self); } 
 /* Выбрать текущего пользователя */

 this.edit = (function() { /* отобразить диалог редактирования текущего пользователя */ })(); 
 /* замыкание необходимо для того, чтобы не позволить открыть 
 два диалога редактирования одного и того же пользователя одновременно */

}
Выполним привязку viewModel к странице:
$(function() {
 gate.Load(function(loadedModel) {
  ko.mapping.fromJS(loadedModel, mapping, viewModel);
  ko.applyBindings(viewModel);
 });
});

HTML-код

Весь код будет расположен внутри тега <body>:
<h2>Список пользователей</h2>

<div data-bind="jqueryui : 'buttonset'">
 <button data-bind="click: createUser">Добавить</button>
 <button data-bind="click: deleteSelectedUser, enable: selectedUser() !== null">Удалить</button>
</div>

<div id="UserListTable">
 <div>
  <table class="list">
   <thead>
    <tr>
     <th>ФИО</th>
     <th>Логин</th>
    </tr>
   </thead>
   <tbody data-bind="template: {name: 'editUsersRow', foreach: users,
       templateOptions: { current: selectedUser } }">
   </tbody>
  </table>
 </div>
</div>

<script type="text/html" id="editUsersRow">
 <tr data-bind="attr: { 'data-id': Id }, click: select, 
      css: { selected: $data == $item.current() }">
  <td><a href="#" data-bind="text: FIO, click: edit"></a></td>
  <td data-bind="text: Login"></td>
 </tr>
</script>
Некоторые замечания по коду:
  • Для автоматического подключения jquery ui используется плагин для KNockoutJS, который был немного допилен мной, чтобы кнопки поддерживали enable/disable. Пример его использования от автора есть тут.
  • Все методы и свойства viewModel доступны в качестве литералов.
  • Внутри шаблонов доступны особые ссылки: $item и $data. Первая представляет собой объект шаблона с его особыми методами и переданными внутрь опциями через templateOptions, вторая — текущий объект, который рассматривается как ViewModel относительно текущего шаблона. Для шаблона editUsersRow ViewModel'ями являются экземпляры user из массива-наблюдателя viewModel.users
  • В примере показано применение биндинга foreach-шаблонов. Стоит учитывать, что использование этого режима гарантирует, что при изменении конкретно экземпляра повлечет к перерисовке только конкретного шаблона конкретного объекта, а не всей коллекции целиком, что очень важно на коллекциях >100 элементов.
Как видите, связанность минимальна, html-код компактен, динамичен и строен. В принципе, можно сделать ko-плагины для таких вещей, как динамические таблицы редактирования/удаления/вставки, однако подобный концепт уже есть.

Реализация диалога создания/редактирования пользователя

JavaScript-код

Как говорилось выше, для адекватного поведения интерфейса необходимо реализовать отдельную ViewModel диалога (желательно универсальную как для создания, так и для редактирования). Однако сам механизм отображения диалога редактирования можно представить такой последовательностью действий:
  1. Получить POJO объект пользователя из выбранного (эта операция также может быть выполнена при помощи маппинга) или создать пустой POJO-объект пользователя (если требуется не отредактировать, а создать нового)
  2. Построить HTML-код по шаблону тела диалога.
  3. Выполнить наложение диалога jQuery UI на построенный шаблон.
  4. При корректном отображении диалога выполнить привязку ViewModel с указанием диалога как корневого элемента привязки.
Код, демонстрирующий это поведение, а также небольшой workaround:
function buildEditUserDlg(model, closeCallback){
return $("#editUserDlg").tmpl().dialog({
  
  title: model.FIO()+" ",
  /* Так как пустой объект пользователя содержит пустое ФИО,
     то чтобы заголовок отобразился корректно оставляем один пробел. */  

  width: 400,
  
  create: function(e) {
   var _self = this;
   ko.applyBindings(model, e.target);
   /* Выполнение привязки ViewModel на диалог */   

   model.FIO.subscribe(function(newValue) {
    /* Изменение заголовка диалога при изменении ФИО */
    $(_self).dialog("option", "title", newValue+" ");
   });
   model.isOperationComplete.subscribe(function(newValue){
    /* Так как ViewModel не должна знать, что используется на диалоге,
       то используется привязка закрытия диалога к флагу успешного
       окончания операции */
    if (newValue === true)
     $(_self).dialog("close");
   })
  },
  
  close: function(e) {
   /* Для упрощения открытия нескольких диалогов,
      всегда уничтожаем закрытые. */
   $(this).dialog("destroy").remove();
   closeCallback();
  },
  
  buttons: {
   "Сохранить" : function() {
    model.save();
   },
   "Отмена": function() {
    $(this).dialog("close");
   }
  }
});
}
Осталось описать код открытия диалога для редактирования (код открытия для создания является частным случаем). Для этого вернемся к коду маппинга пользователя:
this.edit = (function() {
var currentDialog = null;
return function() {
  if (currentDialog != null) {
   return;
  }
  var dialogModel = new userEditDialogMapping(ko.mapping.toJS(_self));
  currentDialog = buildEditUserDlg(dialogModel, function() {currentDialog = null});
};
})();
Отмечу, что наличие колбека закрытия диалога — вынужденная мера, избавиться от которой было бы неплохо, однако сделать это красиво мне не удалось.

HTML-код

<script type="text/html" id="editUserDlg">
 <div>
  <table>
   <tbody>
    <tr>
     <th><label for="surname">Фамилия:</label></th>
     <td><input maxlength="20" name="surname"
         data-bind="value: Surname" type="text" value=""></td>
   </tr>
   <tr>
    <th class="property"><label for="firstname">Имя:</label></th>
    <td><input maxlength="20"
          data-bind="value: FirstName" name="firstname" type="text"></td>
   </tr>
   <tr>
    <th class="property"><label for="patronymicname">Отчество:</label></th>
    <td><input maxlength="20" name="patronymicname"
          data-bind="value: PatronymicName" type="text" value=""></td>
   </tr>
   <tr>
    <th class="property"><label for="email">E-Mail:</label></th>
    <td><input maxlength="30" name="email"
          data-bind="value: EMail" type="text" value=""></td>
   </tr>
   <tr>
    <th class="property"><label for="login">Логин:</label></th>
    <td><input maxlength="20" name="login"
          data-bind="value: Login" type="text" value=""></td>
    </tr>
   </tbody>
  </table>
 </div>
</script>
На этом все. Клиентское приложение готово! P.S. Как лучше сделать подсветку HTML-шаблонов?
Tags:javascriptriaknockoutjsmvvm
Hubs: JavaScript
+32
16.1k 119
Comments 14