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

    В последнее время на Хабре все больше упоминаний о 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: исправлена проблема с двумя запросами к серверу при изменении условий фильтрации.
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 22

      +4
      Еще бы live demo посмотреть, не всегда есть желание собирать проект
        0
        Безусловно, однако я не уверен что у меня есть устойчивый к хабраеффекту хостинг
          0
          Хоть скриншот вставьте того что получилось
            +1
            Дык, чего там вставлять, вы грид никогда не видели? Столбцы, строки, циферки внизу для пейджинга. Как захочешь, так он и будет выглядеть, в статье описаны только внутренности.
              +1
              поверьте, лучше один раз увидеть чем десять раз услышать прочитать
                0
                Картинку добавил. Думаю над размещением где-то в паблике примера или портированием на jsfiddle.
          0
          Тут есть кое-что knockoutjs.com/examples/
          0
          Клева! Хорошая статья. Сам как раз сегодня написал похожий грид с использованием Knockoutjs. И в процессе написания возник вот какой вопрос. Можно ли в шаблоне как-то докопаться до модели представления, если она не является глобальным объектом?

          Например у нас есть такой шаблончик:

          <table class="ColumnTable">
              
              <tr data-bind='template: { name: "GridColumnTable", foreach: Columns }' ></tr>
          
          </table>
          
          <script type="text/html" id="GridColumnTable">       
              <td>
                  <span data-bind="text: HeaderName, css:{SortedColumn:Sort()!='none'}, click: function(event){ viewModels.TotalCallsGrid.ChangeSort(this);  } "></span>
                  <span data-bind="style :{ display: (Sort()!='up')?'none':'' } " class="ColumnSortArrow">↑ </span>
                  <span data-bind="style :{ display: (Sort()!='down')?'none':'' } " class="ColumnSortArrow">↓ </span>      
              </td>
          </script>
          
          


          Как видно при клике по столбцу, у меня вызывается обработчик function(event){ viewModels.TotalCallsGrid.ChangeSort(this);} и в нем обращение к модели идет через глобальный объект viewModel, который я ввел в качестве костыля. Как просто получить доступ к текущей модели?

          ЗЫ Кто-нибудь помогите разработчиком хабра написать нормальный редактор.
            +1
            Можно:

            data-bind='template: { name: «GridColumnTable», foreach: Columns, templateOptions: { ViewModelParent: $data } }'

            и

            click: function(event){ $item.ViewModelParent.TotalCallsGrid.ChangeSort(this); }

            примерно так.
            0
            Добавьте asp.net mvc в теги, пожалуйста.
              0
              А можно код на github/bitbucket/etc выложить?
                0
                Выложить-то можно, но в его нынешнем виде — это малополезно. Но я обещаю обдумать эту перспективу.
                +1
                Хостинг и правда упал, а пример с html js css можно было бы выложить сюда jsfiddle и вставить прямо в статью.
                Тоже недавно смотрел фреймворк, на мой взгляд там пока слишком сложно обрабатываются кастомные контролы, вроде слайдеров. А если использовать стандартные, да все хорошо выходит.
                  0
                  В своей предыдущей статье я довольно много залил на jsfiddle. Но я не нашёл как вставлять в пост с фиддла вставки.

                  Касательно сегодняшней статьи, я не смог симулировать реальный backend с помощью ихнего echo. Хотя можно по колдовать и получится, но тогда пример уже получится не чистым.

                  А на счёт не нативных контролов, то да, вы правы. Есть 2 пути решения этой проблемы:
                  • кастомный биндинг, который будет самостоятельно инициализировать кастомные контролы и управлять ими
                  • кастомный биндинг, который будет управлять уже проиницилизированными кастомными контролами.


                  Решение надо принимать на уровне проекта и потом следовать ему. В любом случае, вам надо будет один раз описать все кастомные биндинги (slider, datapicker, accordion, button) и потом просто их использовать.

                  Вот один из примеров: jsfiddle.net/rniemeyer/Rn9tg/
                  +1
                  knockoutjs для меня уже почти святая тема. Спасибо.
                    +1
                    А можно поподробнее, как избавиться от двойной перезагрузки данных при изменении условий фильтрации???
                      0
                      Как вы меня порадовали своим комментарием. Значит кто-то внимательно проработал статью. На самом деле я элементарно протупил, когда делал пример. Я уже в последний момент подумал, что надо сбрасывать номер страницы в единицу. И быстренько прикрутил. Потом уже, при написании статьи, когда описывал шаги, увидел что-то будет идти 2 запроса и даже не пытался решить эту проблему, мне показалось всё сложнее чем есть на самом деле.

                      По сути, наш главный dependentObservable должен следить только за изменением номера страницы. А номер страницы может меняться по двум причинам: пользователь перешёл на другую страницу или изменил условия фильтрации. Осталось только получить значение всех параметров при этом не получая их значения через функцию-getter. Для этого у нас есть ko.utils.unwrapObservable(). Он вернёт нам plain object и при это dependentObservable не будет изменяться при изменении параметров фильтрации. Иными словами надо заменить
                      var data = ko.toJS(this.filterParams);
                      

                      на
                      var data = ko.utils.unwrapObservable(this.filterParams);
                      


                      Код в статье поправил
                        +1
                        угу, ясно. только тоогда нужно учесть момент, что если мы были на первой странице и изменили параметры фильтрации, то наш основной dependentObservable по идее не будет вызван(номер страницы то не изменился), а значит перегрузку данных нужно и вспомогательном dependentObservable тоже делать. примерно так:

                        ko.dependentObservable(function () {
                        var data = ko.toJS(this.filterParams);
                        // Reset page number when any filtration parameters change
                        if (this.paging.PageNumber() == 1) {
                        // получаем данные через ajax
                        } else {
                        this.paging.PageNumber(1);
                        }
                        }, this);

                          0
                          Вы отчасти правы. Я имел те же самые сомнения. Но они разбились о суровую правду реальности. Как-ни странно, но мой пример работает без этой проверки. На самом деле я до конца не разобрался, почему он работает, ведь не должен :-) Как разберусь, обязательно напишу
                      0
                      На самом деле я тоже имел те же самые сомнения. Но они разбились о суровую правду жизни. Как-ни странно, но мой пример работает без этой проверки. На самом деле я до конца не разобрался, почему он работает, ведь не должен :-) Как разберусь, обязательно напишу
                        0
                        не помешали бы более подробные комментарии к коду, но все равно спасибо!
                          0
                          Предполагается, что читатель уже немного знаком с Knockout'ом. Если есть неясности — задавайте. Я рассказывал о Knockout'е на .NET Saturday в Днепропетровске. Можно посмотреть видео: vimeo.com/27047951. Где-то с 20той минуты идёт демонстрация создания простенького приложения. До этого просто введение в MVVM.

                          Если есть конкретные вопросы по коду, с удовольствием отвечу.

                        Only users with full accounts can post comments. Log in, please.