5 August 2011

KnockoutJS: Ajax grid view с нуля в 40 строк

JavaScript
В последнее время на Хабре все больше упоминаний о KnockoutJS, и я не останусь в стороне от этого тренда.
Сегодня я расскажу о том как сделать своими руками 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);


Тут необходимо небольшое пояснение. Строка var data = ko.toJS(this.filterParams); получает JS-объект из поля filterParams, при этом получаются значения всех observables. Таким образом KO пересчитает наш dependentObservable при изменении любого условия фильтрования. Ещё я добавил второй dependentObservable, который будет сбрасывать номер текущей страницы в 1 при изменении условий фильтрации. Таким образом при изменении фильтров мы должны запрашивать первую страницу.

На самом деле в зачёркнутом абзаце рассказывалось о решении, которое приводило к двум запросам на сервер при изменении условий фильтрации. Первый был вызван самим изменением, второй — установкой 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: исправлена проблема с двумя запросами к серверу при изменении условий фильтрации.
Tags:knockoutjsmvvmgridviewasp.net mvcasp.net mvc 3
Hubs: JavaScript
+33
13.8k 127
Comments 22