День добрый, хабрасообщество!В статье будет рассмотрен процесс создания web-страницы редактирования списка пользователей. Готовый пример можно забрать тут. Статья является не обзорной, а приближенной к реальным боевым действиям, потому настоятельно рекомендую ознакомиться с простым примером.Я решил попробовать использовать вышесказанный фреймворк. Необходимость этого шага обусловлена тем, что раз за разом изобретался очередной велосипед для описания rich логики на стороне клиента. Это выглядело примерно так:
Такой подход может показаться неплохим вариантом для javascript-кода длиною в 100 строк. Однако любая его модификация может обойтись дорого при разрастании кода в 200 и более строк. Основными недостатками на мой взгляд являются: наличие рутинных действий (управление событиями, добавление к JSON-объектам методов), плохо формализуемое описание структуры pageController, сильная связность html и javascript. По сути, часто подобный код скатывается в мешанину вложенных обратных вызовов и неуловимой логики выбора уровня описания новой функции. Было решено использовать фреймворк для придания коду структуры. Выбор пал на MVVM фреймворк KnockoutJS и вот почему:(function($) { var initListeners = function() { /*Включение слушателей событий на странице, например, click, keypress, focus и т.д.*/ } var updateListeners = function() { /*Обновить слушатели на элементах, включить слушатели на вновь созданных элементах*/ } var createUser = function() { /*Вызов логики Ajax для создания объекта на сервере и добавлении его на страницу*/ } var updateUser = function() { /*Вызов логики Ajax для обновления объекта на сервере и добавлении его на страницу*/ } var parseResponse = function() { /*Преобразовать JSON-ответ сервера в объекты с методами*/ } initListeners(); })(jQuery)
- Декларативная привязка событий к элементам DOM формализована и работает волшебно (имхо, live от jQuery — операция более низкого уровня)
- Механизм синхронизации модели на клиенте JSON-данными использовать также просто как вилку
- Для построения элементов DOM используются шаблоны (их удобство — совсем не новость, однако их использование вместе с KnockoutJS выглядит также естественно как хлеб и масло)
- Возможность постепенного рефакторинга кода javascript на работу с Knockoutjs даже во время обеденного перерыва
Сформулируем задачу
Пусть необходимо написать часть приложения, ответственную за редактирование списка пользователей. Известно, что каждый пользователь представлен следующим набором полей:Также известно, что сервер может выполнять такие действия над пользователями, как:{ Id : "", Surname: "", FirstName: "", PatronymicName: "", Login: "", EMail: "" }
- Отправить текущий список пользователей клиенту
- Удалить пользователя по его ID
- Добавить/обновить запись пользователя
Определим требования к отображению страницы (я буду называть элементы страницы как их представляет пользователь, а не как они реализованы в html-коде):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();
- Список пользователей отображает актуальные данные с точки зрения сервера.
- Пользователь может быть выбран из списка.
- Выбранного пользователя можно удалить по кнопке над списком. Убрать пользователя из списка можно только после его успешного удаления с сервера.
- Можно создать пользователя по кнопке над списком. В списке отобразить пользователя только после его успешного создания на сервере.
- Любого пользователя списка можно редактировать. В списке обновить строку пользователя только после его успешного обновления на сервере.
- Первая колонка списка пользователей должна содержать ФИО, что удобнее отдельных колонок на имя, фамилию и отчество.
Реализация списка пользователей
JavaScript-код
Определим ViewModel для списка пользователей:Постойте, где же users?viewModel будет расширен утилитой, которая сформирует объекты-наблюдатели из JSON-строки или из POJO-объектов. Так организуется «приведение» простых объектов с сервера к объектам с методами и свойствами-наблюдателями, готовыми для использования совместно с KNockoutJS. Внимательно посмотрите на отрывок кода DataGate:var viewModel = { selectedUser : ko.observable(null), //Здесь будет ссылка на выбранного пользователя deleteSelectedUser : function() { /* Запрос сервера на удаление выбранного пользователя и последующее удаление его из массива users */ }, createUser : function () { /* Открыть диалог по созданию пользователя */ } }
Да, viewModel после выполнения процедуры маппинга будет иметь поле users, однако это будет не обычный массив, а массив-наблюдатель. Более того, маппинг выполняется глубоко, а это значит, что весь граф объектов с сервера будет приведен к аналогичному графу объектов-наблюдателей.Стоит отметить, что процессом такого маппинга можно гибко управлять:{ users: [...] }
- Определить функцию-конструктор для каждого объекта графа
- Если объект является массивом других объектов, то определить среди них ключевое поле
Как видно, в качестве ключа используется поле Id, а для построения объекта пользователя используется функция конструктор:var mapping = { users: { key: function(data) { return ko.utils.unwrapObservable(data.Id); }, create: function(options) { return new userMapping(options.data); } } }
Выполним привязку viewModel к странице: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() { /* отобразить диалог редактирования текущего пользователя */ })(); /* замыкание необходимо для того, чтобы не позволить открыть два диалога редактирования одного и того же пользователя одновременно */ }
$(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-код
На этом все. Клиентское приложение готово! P.S. Как лучше сделать подсветку 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>
