25 January 2012

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

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

Сегодня я предлагаю разобраться как с помощью 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

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

Спасибо всем, кто дочитал до конца. Надеюсь код пригодится.
Tags:knockoutjsprotectedObservableisDirtybeginEditcommitrollback
Hubs: JavaScript
+31
7.4k 113
Comments 13