KnockoutJS: Фильтрация списков на лету

    В минувшую субботу я имел честь читать доклад о MVVM и KnockoutJS на .NET Saturday в Днепорпетровске.
    Доклад был достаточно тепло встречен публикой и у многих появились интересные вопросы,
    которые не были раскрыты во время самого доклада.
    Собственно говоря, я решил написать публичные ответы на некоторые из них на Хабре.

    Сегодня я отвечу на вопрос о template-binding. «Как быть, если мне надо отобразить не все записи, а только подходящие определённым условиям».

    Ответ находится под хабракатом.


    Я не буду рассказывать, о том, что такое MVVM и KnockoutJS. На хабре можно прочитать эту статью, также будет видео с моего доклада.

    Итак, для начала давайте поставим перед собой задачу — в списке людей отобразить только мужчин.
    У нас уже есть код, который позволяет просто отобразить список людей и их пол (посмотреть работу в живую)

    ViewModel

    var Person = function(gender, name) {
        this.gender = ko.observable(gender);
        this.name = ko.observable(name);
    };
    
    var viewModel = {
        persons: ko.observableArray([
            new Person('M', 'John Smith'),
            new Person('M', 'Mr. Sanderson'),
            new Person('F', 'Mrs. Sanderson'),
            new Person('M', 'Agent Ralf'),
            new Person('F', 'Gangretta Peterson')
            ])
    };
    
    ko.applyBindings(viewModel);
    


    View

    <script type="text/html" id="PersonInfo">
        <li>
            <span data-bind="text: gender"></span>
            <span data-bind="text: name"></span>
        </li>
    </script>
    <div data-bind="
    template: {
    name: 'PersonInfo',
    foreach: persons}"></div>
    


    Самое простое решение задачи, это отфильтровать массив непосредственно в биндинге. Это делается модификацией параметра foreach:
    foreach: ko.utils.arrayFilter(
        persons(),
        function(p){ return p.gender() == 'M';}
    )
    


    Работать это будет и будет работать правильно. При изменении коллекции persons список будет поддерживаться в актуальном состоянии. При этом не будет производится полный ререндеринг шаблона. Новые элементы добавятся, а удалённые — исчезнут. Посмотреть в работе.

    Итак, код необходимый для решения нашей задачи ниже:

    ViewModel

    var Person = function(gender, name) {
        this.gender = ko.observable(gender);
        this.name = ko.observable(name);
    };
    
    var viewModel = {
        persons: ko.observableArray([
            new Person('M', 'John Smith'),
            new Person('M', 'Mr. Sanderson'),
            new Person('F', 'Mrs. Sanderson'),
            new Person('M', 'Agent Ralf'),
            new Person('F', 'Gangretta Peterson')
            ]),
        addMale: function() {
            this.persons.push(new Person('M', 'New male'));
        },
        addFemale: function() {
            this.persons.push(new Person('F', 'New female'));
        },
        removePerson: function(person) {
            this.persons.remove(person);
        }
    };
    
    ko.applyBindings(viewModel);
    


    и само представление:

    View

    <script type="text/html" id="PersonInfo">
        <li>
            <span data-bind="text: gender"></span>
            <span data-bind="text: name"></span>
            <small data-bind="text: new Date()"></small>
            <a href="#remove" data-bind="click: function() { viewModel.removePerson($data); }">x</a>
        </li>
    </script>
    <div data-bind="
    template: {
    name: 'PersonInfo',
    foreach: ko.utils.arrayFilter(
        persons(),
        function(p){ return p.gender() == 'M';}
    )}"></div>
        
        
    <a href="#add-male" data-bind="click: addMale">Add male</a>
    <a href="#add-male" data-bind="click: addFemale">Add female</a>
    


    Теперь, если включить думалку мы понимаем, что написали каку. Мы используем MVVM для разделения логики представления и бизнеслогики, и при этом во View пишем код для фильтрации. Это нонсенс — в MVVM за это отвечает ViewModel.

    Итак, правильное решение задачи — создать во ViewModel поле в котором будут только мужчины. Это поле должно поддерживаться в актуальном состоянии при изменении поля persons. Для этого в KnockoutJS служат Dependent Observables. Давайте взглянем на код, который добавляет поле males.
    viewModel.males = ko.dependentObservable(function() {
        return ko.utils.arrayFilter(this.persons(), function(p) {
            return p.gender() == 'M';
        });
    }, viewModel);
    


    Dependent observables имеют одну неочевидную особенность работы. Во время первого запуска Knockout «запоминает» к каким наблюдаемым объектам (observables) производился доступ и подписывается на их изменения. При возникновении изменений в любом из связанных observables — KO перевыполнит функцию описанную в dependentObservable.
    Также стоит обратить внимение на второй аттрибут, он указывает на то, что будет this во время выполнения функции для получения значения.

    Результат работы

    На самом деле я слукавил, когда говорил, что KO смотрит к каким observables мы доступались при первом запуске. На самом деле, он делает это каждый раз при вычислении значения dependent observable. Я приведу уже финальную версию нашего демо приложения, в которым мы можем выбирать кого отображать (males or females), а также менять пол человеку (о, простите меня грешного).

    ViewModel

    var Person = function(gender, name) {
        this.gender = ko.observable(gender);
        this.name = ko.observable(name);
        this.changeGender = function() {
            var g = this.gender() == 'F' ? 'M' : 'F';
            this.gender(g);
        }
    };
    
    var viewModel = {
        genderToFilter: ko.observable('M'),
        persons: ko.observableArray([
            new Person('M', 'John Smith'),
            new Person('M', 'Mr. Sanderson'),
            new Person('F', 'Mrs. Sanderson'),
            new Person('M', 'Agent Ralf'),
            new Person('F', 'Gangretta Peterson')
            ]),
        addMale: function() {
            this.persons.push(new Person('M', 'New male'));
        },
        addFemale: function() {
            this.persons.push(new Person('F', 'New female'));
        }
    };
    
    viewModel.males = ko.dependentObservable(function() {
        var g = this.genderToFilter();
        return ko.utils.arrayFilter(this.persons(), function(p) {
            return p.gender() == g;
        });
    }, viewModel);
    
    ko.applyBindings(viewModel);
    


    Из нового отметим:
    • В классе Person добавился метод для смены пола на противоположный;
    • Во viewModel добавилось наблюдаемое поле «кого отображать»;
    • В методе фильтрации мы сравниваем пол человека со значением поля «кого отображать».


    Изменения View тоже достаточно скромные:
    <script type="text/html" id="PersonInfo">
        <li>
            <a href="#change" data-bind="text: gender, click: changeGender"></a>
            <span data-bind="text: name"></span>
        </li>
    </script>
    <table width="100%">
        <tr valign="top">
            <td width="50%">
                <label>
                    <input type="radio" value="M" data-bind="checked: genderToFilter" />Males
                </label>
                <label>
                    <input type="radio" value="F" data-bind="checked: genderToFilter" />Femails
                </label>
                <div data-bind="
                template: {
                name: 'PersonInfo',
                foreach: males
                }"></div>
            </td>
            <td>
                <strong>All</strong>
                <div data-bind="
                template: {
                name: 'PersonInfo',
                foreach: persons
                }"></div>
            </td>
        </tr>
    </table>
    
    
    <a href="#add-male" data-bind="click: addMale">Add male</a>
    <a href="#add-male" data-bind="click: addFemale">Add female</a>
    


    Мы добавили radio-button с биндингом checked, который определяет какой из них будет выбран в соответствии со значением в поле genderToFilter. Это двунаправленный биндинг так что при изменении выбранного radio изменения будут приходить во viewModel. Мы обращались к полю genderToFilter во время фильтрации, значит фильтрация произойдёт снова.

    Аналогично произойдёт со сменой пола. Она учавствовала в методе фильтрации, значи список будет перефильтрован при изменении пола любого из людей.

    В свете этого, моё признание о лукавстве было своевременным. Если бы KO не пересканировал к каким observables мы обращались во время каждого перевычисления dependentObservable — изменения пола людям добавленным в runtime не приводили бы к перефильтрации.

    Последния абзацы были немного запутанными, но, надеюсь, всё таки понятными.

    Посмотреть финальную версию

    Заключение


    На самом деле вся эта статья была посвящена использованию dependentObservable в качестве значения для параметра foreach в template биндинге.
    Как видим, dependentObservable — очень мощная штука. Следит за всеми изменениями связанных объектов. В реальной работе вы не один раз столкнётесь с аналогичными задачами. По этому очень рекомендую для себя понять почему последний пример работает.

    Спасибо, что дочитали до конца. Буду рад вопросам и конструктивной критике.

    Похожие публикации

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 7

      0
      Спасибо весьма познавательно, точно уж пригодится в готовящимся проекте.
        +1
        Ай-ай-яй, Romanych, нехорошо обманывать! — подумал я, прочитав о том, что-де, Knockout рассчитывает зависимость для dependentObservable только в первый раз. Потом, смотрю, исправились.

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

        Например

        viewModel.location = ko.dependentObservable(function () {
        if (this.country() === "United States") {
        return this.city() + ', ' + this.state();
        } else {
        return this.city() + ', ' + this.country();
        }
        }, viewModel);


        Допустим, изначально клиент указал в качестве страны проживания Украину. Тогда location стал бы зависить только от полей city и country.

        Т.е. при изменении значения одного из этих observables вызывались бы все listeners, привязанные к свойству location.

        Затем пользователь сменил страну на США. Понятное дело, что location у него изменился, но вот обработчики событий, привязанных к location продолжили срабатывать только при изменении страны и города — изменения штата игнорировались полностью.

        Понятное дело, при собственно вычислении значения location() штат использовался — но все дело в событиях и обработчиках.

        Именно поэтому Knockout вынужден треккить зависимости при каждом вычислении — так список базовых свойств может динамически расширяться.
          +1
          Спасибо за ценное дополнение. Я старался донести важность того, что Нокаут запоминает, к чему обращались при чтении. Получилось немного невнятно из-за привязки к изначальной задаче. У вас более яркий пример, спасибо.
            0
            Не за что! Не расскажете, как доклад прошел и что затронули? И еще интересно, какие вопросы задавали.
              +1
              Доклад прошёл хорошо. Можно будет посмотреть видео. Обещали через неделю выложить, обязательно дам ссылку.

              Рассказал зачем вообще нужен MVVM на простейшем примере. Поставили задачу «Отобразить окружность заданого радиуса и цвета». Потом решение задачи без применения паттернов проектирования, потом немного усложнили задачу — добавилось пару мест для управления параметрами (цвет и радиус) и пару мест где они должны отображаться. Посмотрели на лапшевидный код.
              Далее попробовали сделать нечто MVVM поднобное, а потом уже та же задача на Knockout'е.

              Это всё длилось порядка 20-25 минут. А потом где-то 40-50 минут публичного программирования с описанием действий. Простенький редактор списка людей, ну и полезные хинты.

              На самом деле очень базовые вещи, но для введения вполне познаватально и полезно.
                0
                Здорово! Я для наших программистов тоже делал небольшой курс. Придумал задание, охватывающее спектр технологий и библиотек, и кучу ресурсов: ссылки, видео, книги — для изучения. Потом просто помогал с выполнением, если у кого трудности возникали. После такой тренировки ребята в проекте чувствуют себя вполне уверенно.
          0
          Сама идея использовать dependentObservable возникла в голове тут же, но то наверное уже опыт работы с библиотекой сказывается.

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

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое