26 July 2011

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

JavaScript
В минувшую субботу я имел честь читать доклад о 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 — очень мощная штука. Следит за всеми изменениями связанных объектов. В реальной работе вы не один раз столкнётесь с аналогичными задачами. По этому очень рекомендую для себя понять почему последний пример работает.

Спасибо, что дочитали до конца. Буду рад вопросам и конструктивной критике.
Tags:knockoutjstemplatedata-bindmvvmobservabledependentObservable
Hubs: JavaScript
+29
10.7k 68
Comments 7