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

День добрый, хабрасообщество!В статье будет рассмотрен процесс создания 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-шаблонов?

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 14

    0
    Фиолетовый скроллбар в поле исходника на странице с примером (http://knockoutjs.com/examples/helloWorld.html) — что это?
      0
      Это впечатляет!
        0
        Хабрахабр уже давно научился нормально форматировать и подсвечивать код на джаваскрипте тегом source.

        Переделайте, а то код практически нечитаемый.
          0
          Сегодня вечером около 22:00 по мск поправлю. Пока на работе.
          +1
          Если не ошибаюсь, первая статья по KnockoutJS на хабре. Спасибо!
          Очень хорошо, что показали именно готовое решение, примеры на сайте раскрывают лишь отдельные аспекты, но не все вместе.

          В свое время очень порадовала демонстрация KnockoutJS с MIX'а, channel9.msdn.com/Events/MIX/MIX11/FRM08, с чего и началось знакомство с технологией.
            0
            Комментарий куда-то съелся. В общем, поздравляю с первой статьей на хабре по KnockoutJS. Отличная работа и удачно собранный пример.

            А кавычки в коде поправить действительно надо ;)
              0
              О сейчас как раз с ним ковыряюсь. Пытаюсь сделать динамическую подгрузку шаблонов и моделей.

              <div>
              	{{each(idx, menu) menus }}
              	 <button  data-bind="click: click">${caption}</button>
              	{{/each}}
              </div>
              <div id="sub-model-view" data-bind='template: {  name: currMenuTemplate } '>
              


              var model = {
              		menus:[
              		       MenuItem('Услуги/Операции','eq-operations'),
              		       MenuItem('Рабочие места','eq-workplaces')
              		      ],	
              		currMenuIdx: ko.observable(0),
              		currMenuTemplate: function(){
              			return model.menus[model.currMenuIdx()].template;	
              		}
              	};
              
              function MenuItem(caption, template){
              		var r = {
              			caption: caption,
              			template: template,
              			click:  function(){ ActivateMenu( r ); } 
              			};
              		return r;
              	};
              	
              	
              	function ActivateMenu(mi){
              		var i = model.menus.indexOf(mi);
              		model.currMenuIdx(i);
              		$.koApplication.loadModel('ui/js/eq/'+mi.template+'.js'); 
              	}
              
              


              Когда меняется currMenuIdx — меняется шаблон. Кстати, я пока не понял почему. Магия какая-то… Код подгрузки шаблона я не показал — там нет проблем. Так вот не могу понять как модель привязать к такому меняющемуся шаблону. Он обращается к основной модели и не находит там нужных свойств. Так пока писал у меня уже возникло несколько идей. Пойду проверять.
                0
                Разобрался. В данном случае надо так делать:

                <div id="sub-model-view" data-bind='template: {  name: currMenuTemplate, data: subModel } '>
                


                и в модели

                var model = {
                    ...
                		currMenuTemplate: function(){
                			return model.menus[model.currMenuIdx()].template;	
                		},
                                subModel: {...} 
                   ...
                	};
                


                и подгружать модель в subModel.
                +2
                Уже месяц с этой библиотекой. Пока доволен. Могу порекомендовать ссылочку на knockmeout.net — хорошие примеры, интересные разборы «полётов». На английском.
                  0
                  Сразу говорю чтобы не пинали, статья прежде всего о KnockoutJS, но хотел бы добавить информации :)
                  Раз уж используется jQuery UI, то можно было бы данное «реальное боевое условие» и без KnockoutJS организовать, в jQuery UI уже давно делают или правильнее сказать доделывают работу с моделями и store. И кстати говоря никогда не работал KnockoutJS, но исходники местами очень похожи по логике своей :)
                  Собственно вот страничка с примерами.
                    0
                    А как у этого фреймворка с утечками памяти? Планирую сделать на нем приложение, работающее целый день. Не будет ли проблем?
                      0
                      Исходя из документации, если не планируется удалять из DOM корневой элемент, на который прицеплен KNockoutJS, то значимых утечек не должно быть. Однако я не имею такого опыта, было бы очень интересно узнать Ваш.
                      +1
                      Наконец на хабре статья про knockout. Спасибо огромное порадовали, статья очень интересна прочел пару раз.
                      Зы: статья показалась написано как то «ломано».
                        0
                        Спасибо за отзыв! Если Вам не трудно, пожалуйста, отправьте в ЛС ломаные участки, я постараюсь с ними что-то сделать.

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое