В последнее время на Хабре все больше упоминаний о 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: исправлена проблема с двумя запросами к серверу при изменении условий фильтрации.