День добрый, хабрасообщество!В статье будет рассмотрен процесс создания 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: ""
}
Также известно, что сервер может выполнять такие действия над пользователями, как:- Отправить текущий список пользователей клиенту
- Удалить пользователя по его ID
- Добавить/обновить запись пользователя
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-коде):- Список пользователей отображает актуальные данные с точки зрения сервера.
- Пользователь может быть выбран из списка.
- Выбранного пользователя можно удалить по кнопке над списком. Убрать пользователя из списка можно только после его успешного удаления с сервера.
- Можно создать пользователя по кнопке над списком. В списке отобразить пользователя только после его успешного создания на сервере.
- Любого пользователя списка можно редактировать. В списке обновить строку пользователя только после его успешного обновления на сервере.
- Первая колонка списка пользователей должна содержать ФИО, что удобнее отдельных колонок на имя, фамилию и отчество.
Реализация списка пользователей
JavaScript-код
Определим ViewModel для списка пользователей:var viewModel = {
selectedUser : ko.observable(null), //Здесь будет ссылка на выбранного пользователя
deleteSelectedUser : function() { /* Запрос сервера на удаление выбранного пользователя и последующее удаление его из массива users */ },
createUser : function () { /* Открыть диалог по созданию пользователя */ }
}
Постойте, где же users?viewModel будет расширен утилитой, которая сформирует объекты-наблюдатели из JSON-строки или из POJO-объектов. Так организуется «приведение» простых объектов с сервера к объектам с методами и свойствами-наблюдателями, готовыми для использования совместно с KNockoutJS. Внимательно посмотрите на отрывок кода DataGate:{ users: [...] }
Да, viewModel после выполнения процедуры маппинга будет иметь поле users, однако это будет не обычный массив, а массив-наблюдатель. Более того, маппинг выполняется глубоко, а это значит, что весь граф объектов с сервера будет приведен к аналогичному графу объектов-наблюдателей.Стоит отметить, что процессом такого маппинга можно гибко управлять:- Определить функцию-конструктор для каждого объекта графа
- Если объект является массивом других объектов, то определить среди них ключевое поле
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 элементов.
Реализация диалога создания/редактирования пользователя
JavaScript-код
Как говорилось выше, для адекватного поведения интерфейса необходимо реализовать отдельную ViewModel диалога (желательно универсальную как для создания, так и для редактирования). Однако сам механизм отображения диалога редактирования можно представить такой последовательностью действий:- Получить POJO объект пользователя из выбранного (эта операция также может быть выполнена при помощи маппинга) или создать пустой POJO-объект пользователя (если требуется не отредактировать, а создать нового)
- Построить HTML-код по шаблону тела диалога.
- Выполнить наложение диалога jQuery UI на построенный шаблон.
- При корректном отображении диалога выполнить привязку ViewModel с указанием диалога как корневого элемента привязки.
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-шаблонов?