В последнее время на Хабре все больше упоминаний о KnockoutJS, и я не останусь в стороне от этого тренда.
Сегодня я расскажу о том как сделать своими руками Ajax Grid View с фильтрацией и переходом по страницам написав, при этом, совсем немного кода.
Начиная писать эту статью я чувствовал себя несколько неловко, да и сейчас ощущение не ушло. Все дело в том, что сама библиотека простая, паттерн MVVM простой, и рассказывать я буду простые вещи. Я уверен, что в ближайшее время Knockout получит достаточно большое распространение. А неловко мне от того, что уже через год-дугой кто-то наткнувшись на эту статью будет обескуражен простотой изложенного материала. Примерно так, как любой из вас сейчас, открывший статью о jQuery от 2007 года.
Кто не испугался предполагаемого баяна, милости прошу под хабракат.
Как полагается, давайте поставим перед собой задачу, которую нам надо решить.
Представим себя front-end девелопером, которому надо сделать отображение списка людей (имя, пол, возраст) и позволить по этим параметрам искать. Список людей выводится постранично. А выглядит сие дело так:

Для нас создан уже весь backend и все интерфейсы уже известны. Так что я их просто приведу здесь.
На выход нам возвращается объект ListResult состоящий из массива «результат поиска» и данных для переключения страниц (номер текущей страницы и сколько всего страниц есть).
В коде это всё выглядит так:
Теперь мы можем сконцентрироваться на решении уже нашей задачи. Предлагаю начать наши изыскания с разметки. Ничего нового для уже знакомых с Knockout'ом здесь нет, для новичков всё тоже должно быть ясно.
Итак, у нас есть template для одной записи. В таблице у нас есть статический заголовок, а tbody заполняется из массива rows. Теперь нам надо описать view model, который сможет заполнить эту таблицу данными. В начале я приведу код, а потом объясню его.
ViewModel содержит только одно поле — rows. В начале оно пустое. Потом мы создаем dependentObservable, который будет выполнен при инциализации. Во время выполнения он сделает AJAX-запрос на сервер, а значения поля Data из ответа будет присвоено в поле rows. KO отследит изменение поля rows и заполнит таблицу пришедшими записями. Подробнее о работе dependentObservable можно прочитать в офциальной документации или в этом коментарии.
Следующим этапом добавим переключатель страниц. Начнём с viewModel
На этом примере очень хорошо видна суть ViewModel в паттерне MVVM. Модель состоит из двух свойств PageNumber и TotalPagesCount. А в представлении этой модели уже есть методы next() и back(). Если нам понадобятся свойства isFirstPage или isLastPage — они тоже будут объявлены во viewModel. Таким образом король (Model) окружён услужливой и изменяемой свитой (ViewModel).
Отображение переключателя страниц сделаем тривиальным.
Таким образом у нас будет просто отображение какая страница из скольки отображается и кнопки вперёд и назад. Осталось за малым, научить наш grid view обновлять данные при переключении страниц.
Для этого нам надо немного модифицировать наш dependentObservable:
Мы добавили значение поля PageNumber в AJAX-запрос. Теперь Knockout знает, что наш dependentObservable надо «пересчитать» при любом изменении свойства PageNumber(). Таким образом, когда пользователь нажимает кнопку дальше viewModel ловит это событие (data-bind=«click: next»), и просто увеличивает значение PageNumber на единицу. После этого KO видит, что произошло изменение PageNumber, значит надо перевыполнить dependentObservable. Тот, в свою очередь, отправляет AJAX-запрос, а пришедшие данные кладутся во viewModel.rows, что вызывает полную перерисовку содержимого таблицы.
Теперь настал черёд добавить фильтрацию. Будем использовать подход аналогичный переключению страниц. Все параметры поиска будут наблюдаемыми и их значения будут отправлятся при отправке запроса. Т.е. любое изменение условий фильтрации приведёт к отправке запроса на сервер.
И собственно представление панели фильтрации:
И немного надо подкоретировать наш dependentObservable:
Тут необходимо небольшое пояснение. Строка var data = ko.toJS(this.filterParams); получает JS-объект из поля filterParams, при этом получаются значения всех observables. Таким образом KO пересчитает наш dependentObservable при изменении любого условия фильтрования. Ещё я добавил второй dependentObservable, который будет сбрасывать номер текущей страницы в 1 при изменении условий фильтрации. Таким образом при изменении фильтров мы должны запрашивать первую страницу.
На самом деле в зачёркнутом абзаце рассказывалось о решении, которое приводило к двум запросам на сервер при изменении условий фильтрации. Первый был вызван самим изменением, второй — установкой pageNumber в единицу. Для исправления ситуации мы исправили строчку
на
В данном unwrapObservable даст такой же результат как и метод toJS за исключением того, что dependentObservable будет пересчитан при изменении filterParams.
Таким образом, инициирует запрос к серверу только изменение номера страницы, а оно может быть вызвано переключением страниц или изменением условий фильтрации.
Также при получении ответа от сервера мы обновляем значения в поле paging на случай, если изменилось количество страниц или номер текущей (к примеру запросили страницу 10тую, а их всего 5).
На самом деле поставленную перед собой задачу мы уже поностью решили. Однако я предлагаю немного абстрагироваться от неё и подумать. Мы решили вполне конкретную задачу. Но наша ViewModel почти не знает о самой задаче. Она знает только о URL, где брать данные и о параметрах фильтрации. Всё. А это значит, что наш код можно сделать пригодным к повторному использованию. Я превращу нашу viewModel в класс с аргументами url и filtrationParams:
Весь этот код занимает ровно 39 строк. Если вспомнить заголовок, нам осталась одна на иницилизацию:
Как видим, всю картину нам портит второй аргумент. Вместо того, что бы объект написать в одну строку подумаем о природе оного. На самом деле, это копипаст объекта FilterParams описаного на C#. Его поля используются только во View, а во ViewModel явно мы их явно не используем. Это даёт на основание выбрить этот класс из нашего ViewModel.
В этом примере я использовал ASP.NET MVC. И я решил эту задачу очень просто:
То есть я просто передаю экземпляр класса с дефолтными настройками фильтрации во View, а View сериализирует превращает его в JS-объект. Таким образом мы упростили себе задачу поддержки кода. Осталось только одно некомпилируемое место где используется этот объект — template FiltrationPanel.
Но это ещё не совсем всё. Изначально поле filtrationParams содержало в себе observable значения. А теперь мы ему скормили простой JS-объект. Все поля этого объекта нам надо обернуть в ko.observable(). Для этого есть плагин ko.mapping.
Используем этот плагин во второй строчке нашего класса AjaxGridViewModel:
На этом уже точно всё.
А теперь зачем это вообще надо было, когда есть jqGrid и другие. Суть в том, что это всё тяжеловесные контролы адаптированые под вывод таблиц. У них есть куча возможностей, но они достаточно узконаправленные. А мы создали reusable viewModel и абсолютно легковесное представление. Мы можем использовать таблицы, списки, да всё что угодно. При этом только серверный код и html знает о том, какие данные отображаются. И в этом гибкость. Мы получили удобное средство для отображения даных с фильтрацией и листалкой страниц. Кода мало и мы полностью пониамем, как он работает. Отлично, не правда-ли?
Спасибо всем, кто осилил статью. Надеюсь это было интересно и полезно. Кому интересно, могут скачать исходный код финальной версии примера на ASP.NET MVC 3 по этой ссылке.
Ещё раз спасибо за внимание. Буду рад вопросам и конструктивной критике.
UPDATE: исправлена проблема с двумя запросами к серверу при изменении условий фильтрации.
Сегодня я расскажу о том как сделать своими руками Ajax Grid View с фильтрацией и переходом по страницам написав, при этом, совсем немного кода.
Начиная писать эту статью я чувствовал себя несколько неловко, да и сейчас ощущение не ушло. Все дело в том, что сама библиотека простая, паттерн MVVM простой, и рассказывать я буду простые вещи. Я уверен, что в ближайшее время Knockout получит достаточно большое распространение. А неловко мне от того, что уже через год-дугой кто-то наткнувшись на эту статью будет обескуражен простотой изложенного материала. Примерно так, как любой из вас сейчас, открывший статью о jQuery от 2007 года.
Кто не испугался предполагаемого баяна, милости прошу под хабракат.
Как полагается, давайте поставим перед собой задачу, которую нам надо решить.
Представим себя front-end девелопером, которому надо сделать отображение списка людей (имя, пол, возраст) и позволить по этим параметрам искать. Список людей выводится постранично. А выглядит сие дело так:

Для нас создан уже весь backend и все интерфейсы уже известны. Так что я их просто приведу здесь.
ActionResult List(FilterParams filterParams, int pageNumber = 1);
На выход нам возвращается объект ListResult состоящий из массива «результат поиска» и данных для переключения страниц (номер текущей страницы и сколько всего страниц есть).
В коде это всё выглядит так:
public class FilterParams { public int? AgeFrom { get; set; } public int? AgeTo { get; set; } public bool ShowMale { get; set; } public bool ShowFemale { get; set; } } public enum Gender { Male, Female } public class PagingData { public int PageNumber { get; set; } public int TotalPagesCount { get; set; } } public class Person { public string FirstName { get; set; } public string LastName { get; set; } public int Age { get; set; } public Gender Gender { get; set; } } public class ListResult { IEnumerable<Person> Data { get; set; } PagingData Paging { get; set; } }
Теперь мы можем сконцентрироваться на решении уже нашей задачи. Предлагаю начать наши изыскания с разметки. Ничего нового для уже знакомых с Knockout'ом здесь нет, для новичков всё тоже должно быть ясно.
<script type="text/html" id="TableRow"> <tr> <td data-bind="text: FirstName"></td> <td data-bind="text: LastName"></td> <td data-bind="text: Gender"></td> <td data-bind="text: Age"></td> </tr> </script> <table> <thead> <tr> <th> First name</th> <th> Last name</th> <th> Gender</th> <th> Age</th> </tr> </thead> <tbody data-bind="template: { name: 'TableRow', foreach: rows }"> </tbody> </table>
Итак, у нас есть template для одной записи. В таблице у нас есть статический заголовок, а tbody заполняется из массива rows. Теперь нам надо описать view model, который сможет заполнить эту таблицу данными. В начале я приведу код, а потом объясню его.
var viewModel = { rows: ko.observableArray() }; ko.dependentObservable(function () { $.ajax({ url: '/AjaxGrid/List', type: 'POST', context: this, success: function (data) { this.rows(data.Data); } }); }, this); ko.applyBindings(viewModel);
ViewModel содержит только одно поле — rows. В начале оно пустое. Потом мы создаем dependentObservable, который будет выполнен при инциализации. Во время выполнения он сделает AJAX-запрос на сервер, а значения поля Data из ответа будет присвоено в поле rows. KO отследит изменение поля rows и заполнит таблицу пришедшими записями. Подробнее о работе dependentObservable можно прочитать в офциальной документации или в этом коментарии.
Следующим этапом добавим переключатель страниц. Начнём с viewModel
var viewModel = { rows: ko.observableArray(), paging: { PageNumber: ko.observable(1), TotalPagesCount: ko.observable(0), next: function () { var pn = this.PageNumber(); if (pn < this.TotalPagesCount()) { this.PageNumber(pn + 1); } }, back: function () { var pn = this.PageNumber(); if (pn > 1) { this.PageNumber(pn - 1); } } } };
На этом примере очень хорошо видна суть ViewModel в паттерне MVVM. Модель состоит из двух свойств PageNumber и TotalPagesCount. А в представлении этой модели уже есть методы next() и back(). Если нам понадобятся свойства isFirstPage или isLastPage — они тоже будут объявлены во viewModel. Таким образом король (Model) окружён услужливой и изменяемой свитой (ViewModel).
Отображение переключателя страниц сделаем тривиальным.
<script type="text/html" id="PagingPanel"> Page <span data-bind="text: PageNumber" /> of <span data-bind="text: TotalPagesCount" />. <br /> <a href="#next" data-bind="click: back"><</a> <a href="#next" data-bind="click: next">></a> </script> <div data-bind="template: { name: 'PagingPanel', data: paging }"></div>
Таким образом у нас будет просто отображение какая страница из скольки отображается и кнопки вперёд и назад. Осталось за малым, научить наш grid view обновлять данные при переключении страниц.
Для этого нам надо немного модифицировать наш dependentObservable:
ko.dependentObservable(function () { $.ajax({ url: '/AjaxGrid/List', type: 'POST', data: {pageNumber: this.paging.PageNumber()} context: this, success: function (data) { this.rows(data.Data); } }); }, this);
Мы добавили значение поля PageNumber в AJAX-запрос. Теперь Knockout знает, что наш dependentObservable надо «пересчитать» при любом изменении свойства PageNumber(). Таким образом, когда пользователь нажимает кнопку дальше viewModel ловит это событие (data-bind=«click: next»), и просто увеличивает значение PageNumber на единицу. После этого KO видит, что произошло изменение PageNumber, значит надо перевыполнить dependentObservable. Тот, в свою очередь, отправляет AJAX-запрос, а пришедшие данные кладутся во viewModel.rows, что вызывает полную перерисовку содержимого таблицы.
Теперь настал черёд добавить фильтрацию. Будем использовать подход аналогичный переключению страниц. Все параметры поиска будут наблюдаемыми и их значения будут отправлятся при отправке запроса. Т.е. любое изменение условий фильтрации приведёт к отправке запроса на сервер.
var viewModel = { filterParams: { ShowMale: ko.observable(true), ShowFemale: ko.observable(true), AgeFrom: ko.observable(), AgeTo: ko.observable() }, rows: ko.observableArray(), paging: { PageNumber: ko.observable(1), TotalPagesCount: ko.observable(0), next: function () { var pn = this.PageNumber(); if (pn < this.TotalPagesCount()) { this.PageNumber(pn + 1); } }, back: function () { var pn = this.PageNumber(); if (pn > 1) { this.PageNumber(pn - 1); } } } };
И собственно представление панели фильтрации:
<script type="text/html" id="FiltrationPanel"> Age from <input type="text" size="3" data-bind="value: AgeFrom" /> to <input type="text" size="3" data-bind="value: AgeTo" /> <br /> <label><input type="checkbox" data-bind="checked: ShowMale" />Show male</label> <br /> <label><input type="checkbox" data-bind="checked: ShowFemale" />Show female</label> </script> <div data-bind="template: { name: 'FiltrationPanel', data: filterParams }"></div>
И немного надо подкоретировать наш dependentObservable:
ko.dependentObservable(function () { var data = ko.utils.unwrapObservable(this.filterParams); // Dependent observable will react only on page number change. data.pageNumber = this.paging.PageNumber(); $.ajax({ url: url, type: 'POST', data: data, context: this, success: function (data) { this.rows(data.Data); this.paging.PageNumber(data.Paging.PageNumber); this.paging.TotalPagesCount(data.Paging.TotalPagesCount); } }); }, this); ko.dependentObservable(function () { var data = ko.toJS(this.filterParams); // Reset page number when any filtration parameters change this.paging.PageNumber(1); }, this);
На самом деле в зачёркнутом абзаце рассказывалось о решении, которое приводило к двум запросам на сервер при изменении условий фильтрации. Первый был вызван самим изменением, второй — установкой pageNumber в единицу. Для исправления ситуации мы исправили строчку
var data = ko.toJS(this.filterParams);
на
var data = ko.utils.unwrapObservable(this.filterParams);
В данном unwrapObservable даст такой же результат как и метод toJS за исключением того, что dependentObservable будет пересчитан при изменении filterParams.
Таким образом, инициирует запрос к серверу только изменение номера страницы, а оно может быть вызвано переключением страниц или изменением условий фильтрации.
Также при получении ответа от сервера мы обновляем значения в поле paging на случай, если изменилось количество страниц или номер текущей (к примеру запросили страницу 10тую, а их всего 5).
На самом деле поставленную перед собой задачу мы уже поностью решили. Однако я предлагаю немного абстрагироваться от неё и подумать. Мы решили вполне конкретную задачу. Но наша ViewModel почти не знает о самой задаче. Она знает только о URL, где брать данные и о параметрах фильтрации. Всё. А это значит, что наш код можно сделать пригодным к повторному использованию. Я превращу нашу viewModel в класс с аргументами url и filtrationParams:
var AjaxGridViewModel = function(url, filterParams) { this.rows= ko.observableArray(); this.filterParams = filterParams; this.paging = { PageNumber: ko.observable(1), TotalPagesCount: ko.observable(0), next: function () { var pn = this.PageNumber(); if (pn < this.TotalPagesCount()) this.PageNumber(pn + 1); }, back: function () { var pn = this.PageNumber(); if (pn > 1) this.PageNumber(pn - 1); } }; ko.dependentObservable(function () { var data = ko.utils.unwrapObservable(this.filterParams); // Dependent observable will react only on page number change. data.pageNumber = this.paging.PageNumber(); $.ajax({ url: url, type: 'POST', data: data, context: this, success: function (data) { this.rows(data.Data); this.paging.PageNumber(data.Paging.PageNumber); this.paging.TotalPagesCount(data.Paging.TotalPagesCount); } }); }, this); ko.dependentObservable(function () { var data = ko.toJS(this.filterParams); // Reset page number when any filtration parameters change this.paging.PageNumber(1); }, this); };
Весь этот код занимает ровно 39 строк. Если вспомнить заголовок, нам осталась одна на иницилизацию:
ko.applyBindings(new AjaxGridViewModel('/Ajax/List', { ShowMale: ko.observable(true), ShowFemale: ko.observable(true), AgeFrom: ko.observable(), AgeTo: ko.observable() });
Как видим, всю картину нам портит второй аргумент. Вместо того, что бы объект написать в одну строку подумаем о природе оного. На самом деле, это копипаст объекта FilterParams описаного на C#. Его поля используются только во View, а во ViewModel явно мы их явно не используем. Это даёт на основание выбрить этот класс из нашего ViewModel.
В этом примере я использовал ASP.NET MVC. И я решил эту задачу очень просто:
C#: public ActionResult Index() { return View(new FilterParams()); } CSHTML: ko.applyBindings(new AjaxGridViewModel('@Url.Action("List")', @Html.ToJSON(Model)))
То есть я просто передаю экземпляр класса с дефолтными настройками фильтрации во View, а View сериализирует превращает его в JS-объект. Таким образом мы упростили себе задачу поддержки кода. Осталось только одно некомпилируемое место где используется этот объект — template FiltrationPanel.
Но это ещё не совсем всё. Изначально поле filtrationParams содержало в себе observable значения. А теперь мы ему скормили простой JS-объект. Все поля этого объекта нам надо обернуть в ko.observable(). Для этого есть плагин ko.mapping.
Используем этот плагин во второй строчке нашего класса AjaxGridViewModel:
var AjaxGridViewModel = function(url, filterParams) { this.rows= ko.observableArray(); this.filterParams = ko.mapping.fromJS(filterParams); ...
На этом уже точно всё.
А теперь зачем это вообще надо было, когда есть jqGrid и другие. Суть в том, что это всё тяжеловесные контролы адаптированые под вывод таблиц. У них есть куча возможностей, но они достаточно узконаправленные. А мы создали reusable viewModel и абсолютно легковесное представление. Мы можем использовать таблицы, списки, да всё что угодно. При этом только серверный код и html знает о том, какие данные отображаются. И в этом гибкость. Мы получили удобное средство для отображения даных с фильтрацией и листалкой страниц. Кода мало и мы полностью пониамем, как он работает. Отлично, не правда-ли?
Спасибо всем, кто осилил статью. Надеюсь это было интересно и полезно. Кому интересно, могут скачать исходный код финальной версии примера на ASP.NET MVC 3 по этой ссылке.
Ещё раз спасибо за внимание. Буду рад вопросам и конструктивной критике.
UPDATE: исправлена проблема с двумя запросами к серверу при изменении условий фильтрации.
