Pull to refresh
0
True Engineering
Лаборатория технологических инноваций

Мысли вслух о разработке javascript-приложений на примере небольшого Line Of Business фреймворка

Reading time 21 min
Views 19K
Привет, Хабр!

Не так давно мы сами себе поставили задачу — построить SPA-приложение. Сложное, большое, быстрое, с поддержкой множества устройств, без утечек памяти. К тому же, постоянно развивающееся, чтобы его было легко поддерживать. В общем, как мы и любим — всё и сразу.

Тема SPA-приложений и javascript-приложений в целом не нова, но нам не удалось найти даже на платных ресурсах основательных руководств по разработке приложений. Они являются скорее рассказом о той или иной MV*-библиотеке, чем примером для подражания. При этом не рассматриваются примеры разбиения по слоям, построения иерархий наследования и тонкостей наследования в javascript и т.д.

Мы попробуем зайти с другой стороны и описать, скорее, ход мыслей при разработке (с кодом и ссылками), чем какой-то конкретный инструмент. Начать мы вынуждены с уровня hello world, чтобы задать одну стартовую точку для читателей и писателя. Но уже со второго раздела повествование резко ускорится.

Мы считаем, что данная статья будет полезна:

  1. Front-end разработчикам, у которых уже есть небольшой опыт, но хочется вырасти.
  2. Back-end разработчикам, которым в какой-то момент пришлось начать заниматься js-разработкой и которые чувствуют некую неуверенность при работе с javascript.
  3. Верстальщикам, которые начали заниматься js-разработкой и хотели бы прокачать свои навыки.



Чтиво получилось весьма объемистым, но надеемся, что настолько же полезным.

Для хорошего понимания статьи необходимо:


Постановка задачи


Мы попытались подобрать задачу, которая будет достаточно сложной, но при этом будет реалистичной и понятной без долгого погружения в контекст. Мы построим фундамент для списков с сортировками, пейджингом, фильтрацией, отображением master-details, selection-ом строк и т.д. Также мы кратко затронем вопросы сохранения состояния приложения, взаимодействия с back end и т.п.

За основу нашего приложения мы возьмём starter kit и связку Durandal+knockout, потому что обе библиотеки просты как три копейки (обучающий tutorial knockout можно пройти буквально за час; на Durandal вы потратите столько же времени, да и его специфика нам почти не нужна, мы просто используем его как платформу для быстрого старта).

Искренне надеемся, что выбор технологий не сузит круг потенциальных читателей. Ведь, в конце концов, все MV*-фреймворки обладают схожим функционалом, либо же недостающие вещи добавляет ECMAScript более высокой версии, если вас не интересует поддержка браузеров вроде IE 8/9.

Hello world (разворачиваем starter kit)


Для начала нам нужно запустить приложение и добавить туда модель-заглушку, на которой мы и будем ставить эксперименты:
  1. Скачиваем HTML starter kit.
  2. Открываем его в любом удобном редакторе.
  3. Удаляем из папок app/viewModels и app/views все файлы, кроме shell.html и shell.js.
  4. Добавляем в папку app/viewModels файл TestList.js со следующим кодом:
    define([], function ()
    {
        'use strict';
        var testListDef = function ()
        {
        };
        return testListDef;
    });
    

  5. В папку app/views добавляем файл TestList.html и рисуем туда следующую разметку:
    <div>Hello</div>
    

  6. В файле shell.js меняем конфигурацию роутера с вот такой:
    { route: '', title:'Welcome', moduleId: 'viewmodels/welcome', nav: true },
    { route: 'flickr', moduleId: 'viewmodels/flickr', nav: true }
    
    На вот такую:
    { route: '', title: 'Test list', moduleId: 'viewmodels/TestList', nav: true }
    

  7. Также нам понадобятся библиотеки underscore и moment. Их можно установить любым удобным образом и прописать их в конфиг для requirejs в файле main.js в секции paths:
    'underscore': '../lib/underscore/underscore',
    'moment': '../lib/moment/moment'
    

  8. Запускаем и удостоверяемся, что мы видим страницу с надписью “Hello”.

Дальше можно переходить непосредственно к написанию нашего приложения.

Переходим к делу


Для начала давайте глянем на странички приложения, которое нам предстоит написать, и выделим общие моменты. Поскольку мы ведем наше повествование на основе реальных событий, картинок у нас в избытке. Заодно позволим себе немного похвастаться нашим дизайном.



Из общих моментов можно наблюдать наличие заголовка и часы. Также каждая страница идёт за данными на сервер, и есть смысл добавить признак для отображения состояния.

Итак, добавляем в папку viewModels файл BaseViewModel.js вот с таким кодом:
define(['ko', 'underscore'], function (ko, _)
{
    'use strict';
    var baseViewModelDef = function (title)
    {
        this.title = ko.observable(title || null);
        this.state = ko.observable(ETR.ProgressState.Done);

        this.busy = ko.computed(function ()
        {
            return this.state() === ETR.ProgressState.Progress;
        }, this);

        this.ready = ko.computed(function ()
        {
            return this.state() !== ETR.ProgressState.Progress;
        }, this);

        this.disposed = false;
        this.inited = false;
    };
    _.extend(baseViewModelDef.prototype,
    {
        nowDate: ko.observable(new Date()).extend({ timer: 5000 }),
        baseInit: function ()
        {
            this.inited = true;
        },
        baseDispose: function ()
        {
            this.ready.dispose();
            this.busy.dispose();
            this.disposed = true;
        }
    });
    return baseViewModelDef;
});

Поясним некоторые структурные моменты, конвенции и т.д.:
  1. Весь код мы будем писать в strict-режиме.
  2. Все модули мы объявляем как именованные функции вместо анонимных. Такая практика сослужит вам большую службу, когда вы займетесь профилированием вашего кода на предмет утечек памяти, например, при помощи chrome developer tools, поскольку позволит вам легко определить, что за объект завис в памяти.
  3. Вместо того чтобы использовать широко распространенный подход захвата контекста при помощи замыканий, мы будет писать код с использованием this. Пожалуй, самая главная тому причина — дисциплина и четкость. Да, часто возникают ситуации, когда this не может быть передан правильно. Но в 99% случаев код можно и нужно написать таким образом, чтобы контекст this был верный. На оставшийся 1% случаев используем call, apply, underscore bind, но делаем это с понимаем, зачем и почему.
  4. Всё, что можно запихать в prototype, запихиваем в prototype. Причин тому множество. Начиная от более эффективного использования памяти и в некоторых случаях заметной разницы в скорости работы (начать исследования на эту тему можно с этой статьи) и заканчивая все той же дисциплиной (не будет возможности привязываться ко всяким замыканиям).
  5. Функцию underscore extend мы используем, чтобы все объявления лежали в одном объекте. Такой код читается проще, плюс во многих редакторах кода такой блок можно свернуть и скрыть, если он не нужен. В случае написания а-ля
    object.prototype.something = function
    так сделать не получится.
  6. Вместо использования magic strings и magic numbers мы объявляем некое подобие перечислений. В случае кода выше это перечисление ETR.ProgressState, которое выглядит следующим образом:
    ETR.ProgressState =
                    {
                        Done: 0,
                        Progress: 1,
                        Fail: 2
                    };
    

    Подобного рода объекты мы, не стесняясь, кладем в глобальный объект ETR (акроним по названию нашей компании), считая, что мы не нарушаем подход AMD, поскольку если некоторые статичные объекты нужны чуть ли не в каждом модуле, то их вполне можно вынести в глобальный контекст вместо того, чтобы передавать их как зависимости.
  7. В разметке нам понадобится определять, например, состояние view model, чтобы показывать/не показывать progress bar. Но писать в разметке выражения — это нехорошо и чревато. Поэтому мы весьма активно используем knockout computeds. Это позволяет нам написать биндинги вида if: busy вместо if: state===1.
  8. В случае сложных иерархий иногда необходимо некое подобие виртуальных методов с возможностью вызова базовых методов. Так, например, будет с нашими baseInit и baseDispose. В модулях-наследниках мы однозначно будем определять методы с аналогичным названием, но нам нельзя терять “базовый”. Для этого мы будем использовать underscore wrap. Что же касается префикса base в названиях, то мы так договорились называть методы, которые принадлежат “абстрактным” модулям, то есть которые предназначены только для наследования. Такое именование позволяет в конечных модулях назвать метод просто dispose, не оборачивая базовый метод при помощи wrap. То есть конечный код (в который чаще всего и приходится смотреть) получится немного чище.
  9. Каждая “страничка” нашего приложения будет иметь свой жизненный цикл, который, в целом, накладывается на жизненный цикл ViewModel-ов в Durandal. В случае с кодом выше, это методы baseInit и baseDispose. Чаще всего эти методы накладываются на методы activate и deactivate из Durandal, но иногда приходится привязывать их к методам attached/detached, иногда модели вообще не участвуют в жизненном цикле Durandal, а инициализировать и чистить их все равно нужно (например, вложенные view model). Поэтому методы мы назвали так, чтобы четко отделить мух от котлет.
  10. Флаги inited и disposed нужны, чтобы избежать повторной инициалиции/очистки, работы с уже уничтоженным объектом. Также они могут быть полезны при отладке и при профилировании. Лично нам использовать приходится только inited, и то изредка (но на всякий случай они у нас есть).

Наследуемся и рисуемся


Прежде всего, нам понадобится функция наследования. Мы решили сильно не мудрить и взяли ее отсюда. Для использования мы положили ее в класс Object и назвали inherit:
Object.inherit = function (subClass, superClass)
                {
                    var f = function () { };
                    f.prototype = superClass.prototype;
                    subClass.prototype = new f();

                    subClass.prototype.constructor = subClass;
                    subClass.superclass = superClass.prototype;

                    if (superClass.prototype.constructor === Object.prototype.constructor)
                    {
                        superClass.prototype.constructor = superClass;
                    }
                    return subClass;
                };

Примечание: вообще, для размещения подобного “настроечного” кода (расширение типов, объявление перечислений наподобие описанного раньше ETR.ProgressState и т.д.) рекомендуется завести отдельный модуль и загружать его непосредственно перед стартом приложения. В данном конкретном примере у нас его будет не очень много, поэтому можно просто положить подобные определения в файл main.js.
После наследования наш класс TestList будет выглядеть следующим образом:
define(['underscore', 'BaseViewModel'], function (_, baseViewModelDef)
{
    'use strict';
    var testListDef = function ()
    {
        testListDef.superclass.constructor.call(this, 'Тестовый список');
        this.activate = this.baseInit;
        this.deactivate = this.baseDispose;
    };
    Object.inherit(testListDef, baseViewModelDef);
    return testListDef;
});

Дело осталось за разметкой. Тут стоит осветить один момент: отображение часов. Поскольку в один html-элемент на нашем дизайне часы не вместишь, то при подходе “в лоб” в модели нам понадобятся, как минимум, два поля: для даты и времени. Также придется решать вопрос форматирования времени в строку, а делать это во view model — не самое лучшее решение. Поэтому мы напишем custom binding для knockout, который при помощи библиотеки moment решит этот вопрос:
ko.bindingHandlers.dateFormat = {
        after: ['text'],
        update: function (element, valueAccessor, allBindings)
        {
            if (allBindings.get('text'))
            {
                var format = ko.unwrap(valueAccessor()) || 'L';
                var value = ko.unwrap(allBindings.get('text'));
                if (value)
                {
                    var dateVal = moment(value);
                    var text = dateVal.isValid() ? dateVal.format(format) : value;
                } else
                {
                    text = '';
                }
                $(element).text(text);
            }
        }
    };

В общем, ничего сложного. Интересен в этом binding массив after, который сигнализирует knockout, что запускать процессинг этого binding нужно после того, как отработает binding text. Таким образом, мы гарантируем, что у нас в разметке уже будет текст, который нам и надо переформатировать как дату.

Во view у нас получается примерно следующее:
<div data-bind="dateFormat, text: nowDate"></div>
<div class="date" data-bind="dateFormat: 'DD MMM', text: nowDate"></div>
<div class="year" data-bind="dateFormat: 'YYYY', text: nowDate"></div>

Симпатично и компактно.

Создаем список


На этот раз мы не будем выкладывать весь код сразу, а последовательно добавим необходимый функционал, попутно озвучивая свои мысли. Рутинные и очевидные фрагменты кода мы оставим за скобками.

Давайте еще раз взглянем на картинку с приложением и попытаемся оценить, что же нам нужно от списков:


Дополнительно добавим еще две вещи:

  1. Списки могут быть просто списками с отображением количества записей, с постраничной разбивкой, буферной подгрузкой, infinite-подгрузкой по скроллу.
  2. Каждый список должен сохранять свое состояние (paging, сортировки, значения в фильтрах) в query string, чтобы пользователь мог переслать ссылку, а получатель открыл приложение в том же состоянии. Также, при уходе из окна, состояние списка должно запоминаться и восстанавливаться при возврате к окну.

Такие дела. Начнем с сортировок.
define(['jquery', 'underscore', 'ko', 'BaseViewModel'], function (jquery, _, ko, baseViewModelDef)
{
    'use strict';
    var listViewModelDef = function (title)
    {
        this.items = ko.observableArray([]).extend({ rateLimit: 0 });

        this.defaultSortings = [];
        if (this.sortings && ko.isObservable(this.sortings))
        {
            jquery.extend(true, this.defaultSortings, this.sortings());
            this.sortings.extend({ rateLimit: 0 });
        }
        else
        {
            this.sortings = ko.observableArray([]).extend({ rateLimit: 0 });
        }

        listViewModelDef.superclass.constructor.call(this, title);

        this.baseInit = _.wrap(this.baseInit, function (baseInit, params)
        {
            baseInit.call(this, params);
            if (params && params.sort && jquery.isArray(params.sort))
            {
                this.sortings(jquery.extend(true, [], params.sort));
            }
        });
        this.baseDispose = _.wrap(this.baseDispose, function (baseDispose)
        {
            this.defaultSortings = null;
            this.items.disposeAll();
            this.sortings.removeAll();
            baseDispose.call(this);
        });
    };

    _.extend(Object.inherit(listViewModelDef, baseViewModelDef).prototype,
    {
        setSort: function (fieldName, savePrevious){…},
        changeSort: function (){…},
        resetSettings: function (){…},
        reload: function (){…}
    });
    return listViewModelDef;
});

Также нам понадобится customBinding для рисования сортируемых столбцов на UI:
ko.bindingHandlers.sort = {
        init: function (element, valueAccessor, allBindingsAccessor, viewModel)
        {
            var $el = $(element);
            var fieldName = ko.unwrap(valueAccessor());

            var clickHandler = function (evt)
            {
                viewModel.setSort(fieldName, evt.ctrlKey);
                viewModel.changeSort();
            };
            $el.addClass('sortable').click(clickHandler);
            ko.computed(
            {
                read:{/*обновляем классы asc/desc у столбцов при изменении массива сортировок*/},
                disposeWhenNodeIsRemoved: element
            });

            ko.utils.domNodeDisposal.addDisposeCallback(element, function ()
            {
                $el.off('click', clickHandler);
                $el = null;
            });
        }
    };

Логика происходящего во view model следующая:
  1. Объявляем массив defaultSortings и копируем в него значения из массива sortings, если он был объявлен в дочернем классе. Это нужно для того, чтобы по нажатию кнопки сброса настроек (метод resetSettings) восстановить исходные сортировки. Обратите внимание, что копируем через jquery.extend с флагом deep, чтобы сделать полную копию и не получить потом проблем из-за изменения ссылочного объекта. Если же сортировок нет, сами объявляем массив.
  2. Оборачиваем методы baseInit и baseDispose. В первом мы пытаемся вытащить параметры сортировок из query string (передать нам параметры — это забота Durandal). В методе baseDispose просто наводим за собой порядок.
  3. Методы resetSettings и reload мы привяжем к кнопкам на UI. Также эти методы будут оборачиваться в наследующих модулях, составляя некий конвеер. Таким образом, весь функционал этих кнопок (который после реализации всего функционала списков станет весьма объемист) будет запрятан в базовых модулях.
  4. Назначение метода setSort, пожалуй, очевидно из названия, и код его тривиален. Отметим только флаг savePrevious. Этот параметр мы передаем из custom binding на тот случай, если при клике мышкой по заголовку был нажат Ctrl. Именно так и стоит разделить: логика работы с сортировками — в ListViewModel, а логика, при каких действиях пользователя сохранять сортировки, — в UI-части, то есть в custom binding.
  5. Метод changeSort существует отдельно и форсирует перезагрузку списка при смене сортировки. Он вынесен отдельно, чтобы абстрагироваться от UI, поскольку может быть ситуация, когда пользователь сначала выбирает несколько сортировок и только после этого нам надо загрузить данные. Также нам может понадобиться (и понадобится) встраиваться в метод в модулях-наследниках.
  6. В самом начале мы объявляем массив items, то есть наших записей. Нужен он для того, чтобы чистить из него записи при загрузке данных в списке по кнопке reload и при вызове dispose. Обратите внимание, что для очистки используется метод disposeAll, наше расширение к observableArray. Суть его в том, что мы вызываем removeAll, после чего перебираем все записи, и, при наличии в них метода dispose, вызываем его. Также мы это делаем через setTimeout 0, чтобы цикл работал пока идет загрузка данных с сервера, а не перед ней – немного улучшает usability, если в списке пара тысяч записей:
    ko.observableArray.fn.disposeAll = function (async)
                    {
                        async = async === undefined ? true : !!async;
                        var items = this.removeAll();
                            setTimeout(function ()
                            {
                                ko.utils.arrayForEach(items, function (item)
                                {
                                    if (item.dispose)
                                    {
                                        item.dispose();
                                    }
                                });
                                items = null;
                            }, 0);
                    };
    


Теперь, чтобы наш класс TestList получил функциональность сортировок, меняем в нем базовый класс с BaseViewModel на ListViewModel и в разметке рисуем нечто вроде вот этого:
<table class="table table-bordered table-striped table-hover">
            <thead>
                <tr>
                    <td><span data-bind="sort: 'Column1Name', localizedText: 'Column1Title'"></span></td>
                    <td><span data-bind="sort: 'Column2Name ', localizedText: 'Column2Title'"></span> </td>
                </tr>
            </thead>
            <tbody data-bind="foreach: items">
                <tr>
                    <td>
                    </td>
                </tr>
            </tbody>
            <tfoot>
            </tfoot>
</table>

Теперь нам стоит озадачиться тем, как отправлять эти сортировки на сервер при запросе. Поскольку писать код для этого в каждом конечном view model нам не хочется, мы добавим в наши списки еще один конвеерный метод — toRequest. Модули-наследники также будут его оборачивать, дополнительно укладывая информацию о номере и размере страницы, фильтрах и т.п. В итоге мы избавимся от необходимости в конечном модуле писать copy-paste код для сбора запроса на сервер:
this.toRequest = function ()
        {
            var result = {};
            result.sort = this.sortings();
            return result;
     };

Ещё этот метод пригодится для сохранения состояния модели в query string и в некий кэш (мы описывали это как требования выше), для того, чтобы при возврате на страницу это состояние восстановить. Для сохранения состояния можно создать отдельный класс stateManager, который будет сохранять состояния в кэше (например, в localStorage) и подменять url при помощи router. Также этот модуль встроится в router и при переходе по route будет искать в кэше состояние для объекта. Если оно находится, то необходимо дополнять им параметры, которые Durandal распознал из query string. Детальный код тут приводить не будем, поскольку все тривиально. Заметим только, что у Durandal достаточно слабая функция разбора query string. Например, она не умеет разбирать массивы, которые умеет сериализовывать jquery.param. Поэтому стандартную функцию в Durandal можно заменить на расширение jquery.deparam: функцию, обратную param.

Paging


Как мы уже говорили выше, paging-ов у нас будет три:

1. Простой (в общем-то, и не paging, а просто отображение числа записей).


2. Постраничный:


3. Буферная подгрузка:


Последний будет дополнительно с автоподгрузкой по scroll-у.

Прежде всего, нам понадобится информация о том, сколько записей загружено сейчас в список и сколько их всего. Добавляем два свойства в ListViewModel:
this.totalRecords = ko.observable(0).extend({ rateLimit: 0 });
this.loadedRecords = ko.observable(0).extend({ rateLimit: 0 });

И тут в голову приходит еще одна мысль…


Вычитывать эту информацию нам нужно с back end, иначе никак. Да, свойство loadedRecords тоже будет приходить с сервера, а не определяться как длина массива пришедших записей, поскольку список может быть сгруппирован. Плюс у нас уже есть (за счёт BaseViewModel) признак state, который мы до сих пор нигде не выставляем. И это только начало. Дальше — больше, а заниматься копипастом кода не хочется. Дополнительно для реализации pager-ов нам понадобится точно знать, как вызвать метод загрузки данных с сервера при смене страницы, то есть нужен некий контракт.

Тут мы решили сделать вещь немного странную, но вполне работоспособную. Мы наложим на классы-наследники ListViewModel одно ограничение: все они должны иметь метод loadData, который будет возвращать promise (строго говоря, возвращаться будет jquery deferred, но в данном контексте это не имеет большого значения). Также мы будем ожидать, что в callback promise-а будут приходить данные, из которых можно вытащить нужные нам totalRecords и loadedRecords. Далее данный метод мы будем оборачивать своим методом loadData и добавлять перехватчики для promise, которые сделают всю нужную работу. Получается некая замена абстрактного метода, которого в javascript нет.

В конечном счете, для ListViewModel это будет выглядеть примерно вот так:
var listViewModelDef = function (title)
    {
        if (!this.loadData)
        {
            throw 'In order to inherit "ListViewModel" type you must provide "loadData" method';
        }
…
this.loadData = _.wrap(this.loadData, function (q)
        {
            var opts = { total: 'totalCount', loaded: 'selectedCount' };
	    //Список-наследник может переопределить названия для возвращаемых полей
            if (this.namingOptions)
            {
                opts = _.extend(opts, namingOptions);
            }

            this.totalRecords(0);
            this.state(ETR.ProgressState.Progress);

            var promise = q.apply(this, Array.prototype.slice.call(arguments, 1));

            promise.done(function (data)
            {
                this.loadedRecords(data[opts.loaded]);
                this.totalRecords(data[opts.total] || 0);
                        this.state(ETR.ProgressState.Done);
    }).fail(function ()
    {
        this.state(ETR.ProgressState.Fail);
    });

    if (this.saveStateOnRequest === true)
            {
                this.saveRequestState();
            }

            return promise;
 });
…
    }

Код в нашем классе-наследнике теперь дополняется примерно вот таким образом:
define(['underscore', 'BaseViewModel', 'SomeViewModel'], function (_, baseViewModelDef, someViewModelDef)
{
    'use strict';
    var testListDef = function ()
    {
        testListDef.superclass.constructor.call(this, 'Тестовый список');
    };
    _.extend(Object.inherit(testListDef, baseViewModelDef).prototype, {
        loadData: function ()
        {
            return this.someService.getSomeData(this.toRequest())
                 .done(function (result)
                 {
                     this.someCustomPropertyToSet(result.someCustomPropertyToSet);
                     this.items.initWith(result.items, someViewModelDef);
                 });
        },
…
    });
    return testListDef;
});

В итоге получилось весьма неплохо. У нас нет нужды копипастить код выставления свойств родительских классов, но при этом — полная свобода в отправке запроса на back end и в получении от него ответа.

Далее нам нужно определить два модуля-наследника — PagedListViewModel и BufferedListViewModel. Показывать целиком и объяснять код мы не будем, в силу их тривиальности, просто приведем общую структуру. Примечательным моментом является использование writable computeds, о существовании которых пока еще знают не все пользователи этой библиотеки. Их использование позволяет нам проверять, что пользователь вводит в поле rowCount строго цифры, и вводиоме значение не превышает заданный лимит количества записей на один запрос, а также общее количество записей в списке.
define(['ko', 'underscore', 'ListViewModel'], function (ko, _, listViewModelDef)
{
    'use strict';
    var bufferedListViewModelDef = function (title)
    {
        this.defaultRowCount = 20;
        this.minRowCount = 0;
        this.maxRowCount = 200;
        this.rowCountInternal = ko.observable(this.defaultRowCount);
        this.skip = ko.observable(0).extend({ rateLimit: 0 });

        this.rowCount = ko.computed({
            read: this.rowCountInternal,
            write: function (value) {…},
            owner: this
        });

bufferedListViewModelDef.superclass.constructor.call(this, title);        
this.loadData = _.wrap(this.loadData, function (q) {…};
this.baseInit = _.wrap(this.baseInit, function (baseInit, params) {…};
        this.resetData = _.wrap(this.resetData, function (baseResetData) {…};
        this.toRequest = _.wrap(this.toRequest, function (baseToRequest) {…};
        this.changeSort = _.wrap(this.changeSort, function (baseChangeSort) {…};
        this.baseDispose = _.wrap(this.baseDispose, function (baseDispose) {…};
    };
    Object.inherit(bufferedListViewModelDef, listViewModelDef);
    return bufferedListViewModelDef;
});

Теперь, всего лишь сменив базовый класс для TestList, мы почти получаем функциональность paging-а. Чтобы получить ее целиком, нам понадобится ещё несколько биндингов. View с ними будет выглядеть примерно так:
<tfoot>
                <tr data-bind="progress">
                    <td colspan="6" class="progress-row"></td>
                </tr>
                <tr data-bind="nodata">
                    <td colspan="6" data-bind="localizedText: 'CommonUserMessageNoData'"></td>
                </tr>
                <tr class="pagination-row">
                    <td data-bind="pager">
                    </td>
                </tr>
</tfoot>

Тут также обойдемся без очевидного кода и кратко поясним на словах:
  • Биндинги progress и nodata опираются на свойства view model-а state и selectedCount, — чтобы скрыть или показать элемент, к которому они применены.
  • Биндинг infinite проверяет, что ему подсунули bufferedListViewModel, и навешивает обработчик на событие scroll объекта window, в котором догружает данные в список.
  • Биндинг pager делегирует вызов другим биндингам в зависимости от того, какую view model ему передали:
    ko.bindingHandlers.pager = {
            init: function (element, valueAccessor, allBindingsAccessor, viewModel)
            {
                if (viewModel instanceof bufferedListViewModelDef)
                {
                    ko.bindingHandlers.bufferedListPager.init(element, valueAccessor, allBindingsAccessor, viewModel);
                }
                else if (viewModel instanceof pagedListViewModelDef)
                {
                    ko.bindingHandlers.pagedListPager.init(element, valueAccessor, allBindingsAccessor, viewModel);
                }
                else if (viewModel instanceof listViewModelDef)
                {
                    ko.bindingHandlers.simpleListPager.init(element, valueAccessor, allBindingsAccessor, viewModel);
                }
            }
        };
    


Примечательным моментом являются тот факт, что корректная работа instanceof возможна благодаря функции, которую мы использовали для наследования (она использует создание промежуточного proxy-объекта, что позволяет не затирать prototype при “многоэтажном” наследовании).

Интересно и то, что наши биндинги bufferedListPager, pagedListPager, simpleListPager генерируют разметку, и это не очень красиво. Более того, весь блок tbody является копипастным от списка к списку, что тоже не так уж здорово. Для тех, кто использует Durandal или просто knockout, можем посоветовать решения в виде Durandal widgets и knockout components. В нашем случае это не так, потому как первый способ не работал на нашей структуре, widget не работает как виртуальный binding, а при отсутствии одного корневого элемента он оборачивает его div-ом, что делает невалидной разметку таблицы. Второй же способ мы просто не успели попробовать, поскольку он появился после того, как был написан основной code base.

Оглядываемся


Прежде чем двигаться дальше, давайте окинем широким взглядом все, что мы сделали, и поймем: надо ли нам так писать?

Итак, чтобы получить список с сортировками, пейджером, автоподгрузкой, автоматическим сбором параметров для запроса на back-end, сохранением состояния в query string и локальном кэше и сбросом этого состояния, нам нужно написать вот такой view model:
define(['underscore', 'BufferedListViewModel'], function (_, bufferedListViewModelDef)
{
    'use strict';
    var testListDef = function ()
    {
        testListDef.superclass.constructor.call(this, 'Тестовый список');
    };
    _.extend(Object.inherit(testListDef, bufferedListViewModelDef).prototype, {
        loadData: function ()
        {
            return this.someService.getSomeData(this.toRequest())
                 .done(function (result){…});
        },

        init: function ()
        {
            this.baseInit();
        },
        dispose: function ()
        {
    this.someService.dispose();
            this.baseDispose();
        }
    });
    return testListDef;
});

И вот такой View:
        <table>
            <thead>
                <tr>
                    <td><span data-bind="sort: 'SomeProperty'>Some Property</span></td>
    <td><span data-bind="sort: 'OtherProperty'>Other Property</span></td>
                </tr>
            </thead>
            <tbody data-bind="foreach: items ">
                <tr>
                    <td data-bind="text: someProperty"></td>
    <td data-bind="text: otherProperty "></td>

                </tr>
            </tbody>
            <tfoot>
                <tr data-bind="progress">
                    <td colspan="2" class="progress-row"></td>
                </tr>
                <tr data-bind="nodata">
                    <td colspan="2" data-bind="localizedText: 'CommonUserMessageNoData'"></td>
                </tr>
                <tr class="pagination-row">
                    <td data-bind="pager">
                    </td>
                </tr>
            </tfoot>
        </table>

Согласитесь, весьма компактно, учитывая, какую функциональность мы получаем. А ведь мы только начали, и ещё многое можно выдумать…

Фильтрация


Чтобы наш TestList стал совсем как настоящий, нам осталось добавить возможность передачи параметров для фильтрации на сервер. Для этого у нас уже сделан конвеер toRequest. Мы можем в конечной модели его оборачивать, и добавлять нужные параметры в объект. Заодно мы автоматически получим сохранение в query string и в кэш состояний.
Но тогда нам придется в каждом методе init самостоятельно вытаскивать их из query string (точнее, из объекта params, который представляет собой query string+состояние из кэша). А еще нам придется самим дописывать метод resetSettings, чтобы по нажатию кнопки сброса очистить значения. Несложно себе представить ситуацию, когда параметр в toRequest добавили, а его разбор в init или сброс в resetSettings — забыли. Через какое время мы это заметим?
Очевидно, что тут есть более интересное решение.

Например, можно неким декларативным способом обозначить, какие поля наших моделей представляют собой фильтры, а всё остальное вынести в базовые классы. В этом нам поможет очередная приятная штука из арсенала knockout — extenders (вы могли заметить, что мы их уже использовали в статье). Например, очень интересен стандартный extender rateLimit.
Итак, мы напишем extender, который позволит регистрировать некий observable как фильтр. Примерно вот так:
this.filterProperty = ko.observable(null).extend({filter: { fieldName: 'nameForRequest', owner: this }});

Также, на всякие особые случаи, нам понадобятся вспомогательные настройки. Например:
  • значение по умолчанию (на случаи когда первое значение в observable — это не оно);
  • признак, что не надо разбирать это поле из query string автоматом (актуально для сложных объектов со своим конструктором);
  • метод форматирования данных для передачи на сервер.

Эти настройки мы будем дописывать в сам же observable. Итого, у нас получается примерно вот так:
ko.extenders.filter = function (target, options)
                {
                    options.owner.filter = options.owner.filter || {};
                    options.owner.filter[options.fieldName] = target;
                    target.defaultValue = options.defaultValue === undefined ? target() : options.defaultValue;
                    target.ignoreOnAutoMap = (!!options.ignoreOnAutoMap) || false;
                    target.formatter = options.formatter || undefined;
                    target.emptyIsNull = options.emptyIsNull || false;
                    return target;
                };

В итоге, в объекте view model-а у нас будет объект filter, в котором лежит все, что нам надо, чтобы собрать, разобрать и ещё чего-нибудь.

Код для сбора/разбора параметров мы, опять же, приводить не будем. Сделаем только оговорку на некий “convention”, который мы у себя применили.

Для объектов, которые не так просто превратить в нечто сериализуемое, необходимо указать способ форматирования. При этом, если тип reusable, то писать это в extender для каждого параметра — это copy paste. Поэтому мы договорились, что в каждом значении фильтра мы проверяем наличие метода toRequest, и, если такой метод есть, берем как значение результат его вызова. Например, у нас back end, среди прочего, это wcf-сервиса от Sharepoint 2010, которые требуют особый формат даты ”/Date(1234567890000)/”. Эту проблему решили в две строчки кода, не считая скобок:
Date.prototype.toRequest = function ()
            {
                return '\/Date\(' + this.getTime() + ')\/';
            };

Еще один вопрос, который стоит рассмотреть: куда положить функциональность по манипулированию параметрами фильтра? Наши кандидаты — это три модуля *listViewModel плюс модуль BaseViewModel. Класть её в списки — слишком узко, подобная штука может понадобиться не только в списках. Класть в BaseViewModel — слишком круто, далеко не всем она понадобится.

Неплохим решением в таких случаях являются mixin-ы. В случае с нашими фильтрами, мы добавили mixin к классу ListViewModel вот такой строчкой:
modelWithFilterExtender.apply.call(this);

Контракт самого extender выглядит примерно вот так:
define(['ko', 'underscore'], function (ko, _)
{
    'use strict';
    var modelWithFilterExtenderDef = {
        apply: function ()
        {
            this.filter = this.filter || {};
            this.initFilterParams = function (baseInit, params){…};
            this.apllyFilterParams = function (data) {…};
            this.resetFilterToDefault = function (){…};
            this.clearFilterParams = function (){…};
        }
    };

    return modelWithFilterExtenderDef;
});

Вообще, практика очень хорошая, рекомендуем.
Поделимся небольшой success story.

С помощью mixin-ов в данном приложении мы сделали функциональность для selection строк (детально про него, к сожалению, рассказать не успеем, слишком уж большой получается наша статья). Суть сводится к тому, что строки в таблице можно выбирать мышкой, стрелками, клавишей Tab с использованием Ctrl или Shift для множественного выбора. Для реализации мы написали selectionExtender (для списка) и selectedExtender (для строчек). На основе написанной функциональности реализовали функционал “свернуть все/развернуть все”, “выбрать все/очистить выбор”, отображение контекстного меню в зависимости от выбранной строки/строк, отображения master/details. И вообще, для чего только еще не применили.

Какова же была наша радость, что авторы кода сделали это в виде mixin-а, когда понадобилось реализовать группированные списки. Все, что понадобилось сделать, — написать во view model группы записей вместо одной строчки кода:
selectedExtender.apply.call(this, this.key);

две:
selectedExtender.apply.call(this, this.key);	     
selectionExtender.apply.call(this, this.items);

И вся функциональность в рекурсивной манере появилась и у групповых списков.

И на этой позитивной ноте мы откланяемся. Спасибо всем, кто дочитал до конца. До скорых встреч!
Tags:
Hubs:
+5
Comments 8
Comments Comments 8

Articles

Information

Website
www.trueengineering.ru
Registered
Founded
Employees
101–200 employees
Location
Россия