KnockoutJS: сказ о том, как легко принимать или отклонять изменения

    Довольно часто в пользовательском интерфейсе есть кнопки «Сохранить» и «Отмена». Особенно часто эти кнопки используются в формах. Несмотря на то, что в современном мире всё идёт к упрощению интерфейса, но на эти кнопки всё равно есть спрос.

    Сегодня я предлагаю разобраться как с помощью KnockoutJS принимать и откатывать изменения для индивидуальных observables так и целых view models.

    Знакомые с KnockoutJS сразу могут выдать две ссылки на лучший блог о сабже

    У этих методов есть как плюсы, так и вполне существенные недостатки, от которых нужно избавлятся. Недостатки с функциональной точки зрения
    • Dirty flag — не позволяет сохранять изменения, а только сбросить их в начальное состояние.
    • protectedObservable — никто не видит изменений observable до тех пор, пока не произойдёт commit. Это ограничение сильно удручает при использовании dependent observables, к примеру.

    Ну и к тому же, они нацелены на индивидуальные observable'ы, а хотелось бы работать с несколькими полями сразу.

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



    Первое, что мне приходит в голову, когда речь заходит о принятии или отмене изменений — это транзакции и их реализация в СУБД. Перед изменением надо сделать begin transaction, а потом commit или rollback. Всё просто и понятно.

    Ничто не мешает нам сделать аналог этих методов у observables. Можно поступить просто, и выдумать свой ko.transactionableObservable по аналогии с примерами из начала статьи.

    ko.transactionableObservable = function(initialValue) {
    var result = ko.observable(initialValue); 
    result.beginTransaction = function() { ... };
    result.commit = function() { ... };
    result.rollback = function() { ... };
    
    return result;
    }
    
    var name = ko.transactionableObservable('habrauser');
    


    Но мне этот подход решительно не нравится. Как видим, результатом будет обычный observable. А как нам быть, если мы хотим принимать/отменять изменения observableArray или writeable dependentObservable?

    На помощь нам приходят extenders, которые появились во второй версии Нокаута.

    Extender позволяет очень элегантным образом изменять или дополнять поведение любых видов observables. Наш код должен выглядеть так:

    var name = ko.observable('habrauser').extend({editable: true});
    


    Реализовывать extender'ы до безобразия просто:
    ko.extenders['myExtender'] = function(observavle, params){}

    Идея мне кажется стоящей, по этому сделаем простенькую реализацию нашего extender'а

    ko.extenders['editable'] = function (target) {
            var oldValue;
            var inTransaction = false;
    
            target.beginEdit = function () {
                var currentValue = target();
                if (currentValue instanceof Array) {
                    currentValue = currentValue.slice(); // make copy
                }
                oldValue = currentValue;
                inTransaction = true;
            };
    
            target.commit = function () {
                inTransaction = false;
            };
    
            target.rollback = function () {
                if (inTransaction) {
                    target(oldValue);
                    inTransaction = false;
                }
            };
    
            return target;
        };
    


    Я думаю, что переводить код на русский язык особого смысла нет. Единственный нюанс — это работа с массивами.
    Если внутри observable находится массив, мы не можем просто «запомнить» это значение. Так как это ссылочный тип данных, то нам надо запомнить копию массива, а не ссылку на него. И да, екстендить имеет смысл только observables содержащие не-ссылочные типы данных. Позже мы поборем эту проблему.

    Пример использования:
    var name = ko.observable().extend({editable: true});
    var nameLength = ko.dependentObservable(function() {
        return name() ? name().length : 0;
    });
    
    name('user');       // name set to 'user'
    name.beginEdit();   // begin transaciton
    name('me');         // name set to 'me', nameLength was recalculated
    nameLength();       // gives us 2
    name.commit();      // transaction commited; values are unchanged since last edit; we could start another one
    name.rollback();    // nothing happens since transation is commited
    name.beginEdit();   // begin another transaction
    name('someone');    // name set to 'someone', nameLength was recalculated
    name.rollback();    // rollback transaction; name set to initial value 'me', nameLength recalculated
    name();             // returns 'me'
    


    Ещё было бы отлично иметь флаг, наличия изменения. Принцип простой:
    target.hasChanges = ko.dependedObservable(function () {
                var hasChanges = inTransaction && oldValue != target();
                return hasChanges;
            });
    


    На выход должны получать true, только когда есть открытая транзакция и текущее знаечение отличается от начального на момент старта транзакции. Вспоминаем, что knockout пересчитывает значение, dependentObservable'а при изменении любого из observable'ов, которые в нём используются.

    В текущей реализации возвращатся всегда будет false. А причина этому очень проста: при первом выполнении (т.е. сразу после объявления) inTransaction == false, а значит вторая проверка оператора AND даже не выполнится. Сие значит, что dependendObservable не будет пересчитан никогда. Пофиксить это достаточно просто. Делаем переменную inTransaction «наблюдаемой».

    Таким образом значение будет пересчитано при входа/выходе из транзакции и при изменении оригинального observable'а. Это то, что надо!

    ko.extenders['editable'] = function (target) {
    	var oldValue;
    	var inTransaction = ko.observable(false);
    
    	target.beginEdit = function () {
    		var currentValue = target();
    		if (currentValue instanceof Array) {
    			currentValue = currentValue.slice(); // make copy
    		}
    		oldValue = currentValue;
    		inTransaction(true);
    	};
    
    	target.commit = function () {
    		inTransaction(false);
    	};
    
    	target.rollback = function () {
    		if (inTransaction()) {
    			target(oldValue);
    		}
    	};
    
    	target.hasChanges = deferredDependentObservable(function () {
    		var hasChanges = inTransaction() && oldValue != target();
    		return hasChanges;
    	});
    
    	return target;
    };
    


    Как по мне, мы получили вполне добротную реализацию для одного поля. А как же быть, когда полей у нас десяток. Перед редактированием каждому полю нужно сделать beginEdit(), а потом commit/rollback. Повеситься можно.

    Благодаря тому, что мы используем extender'ы в нашей реализации, мы можем расширять необходимые поля после их объявления и не переживать, что наши dependentObservables от этих полей поломаются. А это значит, что мы можем делать это вполне автоматически.
    Выражу свою мысль более приземлённо:

    var user = {
        firstName : ko.observable('habrauser'),
        lastName: ko.observable('sapiens')
    };
    user.fullName = ko.dependentObservable(function() {
        return user.firstName() + ' ' + user.lastName();
    });
    
    ko.editable(user);
    user.beginEdit();
    user.firstName('homo');
    user.fullName();       // 'homo sapiens'
    user.hasChanges(); // true
    user.commit();
    


    Если в начале статьи, мы делали конкретный observable транзакционным, то теперь мы делаем произвольный объект транзакционным. По большому счёту, это реализуется достаточно просто:
    ko.editable = function (viewModel, autoInit) {
           var editables = ko.observableArray();
           
         (function makeEditable(rootObject) {
                    for (var propertyName in rootObject) {
                        var property = rootObject[propertyName];
                        if (ko.isWriteableObservable(property)) {
                            var observable = property;
                            observable.extend({ editable: true });
                                editables.push(observable);
                        }
                        property = ko.utils.unwrapObservable(property);
                        if (typeof (property) == 'object') {
                            makeEditable(property);
                        }
                    }
                })(viewModel);
            
    
            viewModel.beginEdit = function () {
                ko.utils.arrayForEach(editables(), function (obj) {
                    obj.beginEdit();
                });
            };
    
            viewModel.commit = function () {
                ko.utils.arrayForEach(editables(), function (obj) {
                    obj.commit();
                });
            };
    
            viewModel.rollback = function () {
                ko.utils.arrayForEach(editables(), function (obj) {
                    obj.rollback();
                });
            };
    
            viewModel.addEditable = function (editable) {
                editables.push(editable.extend({ editable: true }));
            };
    
            viewModel.hasChanges = deferredDependentObservable(function () {
                var editableWithChanges = ko.utils.arrayFirst(editables(), function (editable) {
                    return editable.hasChanges();
                });
                return editableWithChanges != null;
            });
    
        };
    


    Внимания заслуживает только начало. Мы обходим все свойства объекта, если свойство является writeableObservable (а ведь только такие могут быть транзакционными, если призадуматься), то мы его расширяем с помощью нашего extender'а. Далее по коду, если в поле лежит объект (а массив это тоже объект), то мы проходимся по его полям тоже.

    И простенький пример использования:

    var user = {
        FirstName: ko.observable('Some'),
        LastName: ko.observable('Person'),
        Address: {
            Country: ko.observable('USA'),
            City: ko.observable('Washington')
        }
    };
    ko.editable(user);
    
    user.beginEdit();
    user.FirstName('MyName');
    user.hasChanges();          // returns `true`
    user.commit();
    user.hasChanges();          // returns `false`
    user.Address.Country('Ukraine');
    user.hasChanges();          // returns `true`
    user.rollback();
    user.Address.Country();     // returns 'USA'
    


    В результате наших изысканий мы получили вполне готовый для продакшн использования код, который я разместил на GitHub'е и назвал ko.editables: github.com/romanych/ko.editables

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

    Спасибо всем, кто дочитал до конца. Надеюсь код пригодится.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 13

      +1
      Chrome на mac. Кнопка сохранения не работает.
        0
        я про примеры говорил. В примерах у меня не работает кнопка сохранения.
          0
          Она должна быть disabled до тех пор, пока нет изменений. Рабоает не так?
            0
            И о каком примере идёт речь? В «продвинутом» кнопка всегда активна. Это вызвано багой, что observableArray всегда считается отредактированным. Я исправлю этот баг в течении дня
              +1
              Crome windows. Простой пример.
              Жму Edit. Редактирую поле. Жму Save. Жму Edit на этой же или другой строчке и изменения откатываются.
                0
                Это была ошибка юзабилити.
                Я специально сделал, что бы при переходе к другому объекту, изменения в тихую откатывались. Я добавил вопрос, что делать с изменениями: отменить или продолжить редактирование
            0
            Задам вопрос который многих волнует. Какие условия использования вашего кода?
              +1
              Те, кого это волнует, уже наверняка залезли и посмотрели. MIT там.
                0
                MIT license. Используйте в своих коммерческих и не коммерческих проектах. Изменяйте на своё усмутрение. Буду рад запостанным фич-реквестам, баг репортам и pull request'ам. Есть мысли по дальнейшим фичам, которые могут быть полезны ko.общественности
                +1
                О! Давненько не заходил к ним на сайт — наконец выкатили официально вторую версию.

                ps: Было бы не плохо скинуть авторам ссылку на код.
                Будет полезно если они ее разместят в разделе plugins (аналогично The mapping plugin).
                  0
                  За идею спасибо. Постараюсь реализовать.

                  ps: Да, вторая версия более чем достойная получилась
                    0
                    Я не нашел никаких упоминаний на сайте, что добавилось (изменилось) во второй версии.

                    Где можно посмотреть сводную информацию об этом? Хотя бы changelist какой-то…
                      0
                      Изменений не много, но они важные. Наиболее вкусные изменения:
                      • * Изменён порядок DOM. Теперь каждая нода может сказать — не надо биндить моих детей, я сама это сделаю
                      • * native templating engine (foreach, if, ifnot, with bindings)
                      • * добавили ko.dataFor(node) для получения view model для конкретной ноды (отлично работает с foreach binding'ом)
                      • * екстендеры
                      • * новые места расширения
                      • * главное — event binding передаёт в указанный коллбек первым аргументом binding object для кликнутого элемента. На сколько я понимаю, это было последней каплей, что бы переименовать 1.3 rc в 2.0


                      Можно почитать у Ryan'а: www.knockmeout.net/2011/12/knockout-20-is-out.html

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