Pull to refresh

Оптимизация knockoutjs при динамическом добавлении и удалении темплейтов

Reading time 5 min
Views 5.3K
Сразу предупреждаю, что статья рассчитана только для тех кто использует либо собирается использовать knockoutjs. Предполагается, что читатель уже знает что это такое и для чего он нужен.

В одном своем проекте я решил использовать knockout.js. Все было хорошо и даже замечательно, пока данных было мало и вызовы computed методов были не ресурсоемки. Но потом данных становилось все больше и появились вычисления, которые занимали заметное для глаза время. Пытаясь решить эту проблему, я разделил страницу на вкладки. Переходя на отдельную вкладку я менял темплейт и таким образом логично ожидал что уменьшится число вычислений computed значений при изменении какого-либо observable значения. Но не тут-то было. Оказалось, что особенность фреймворка такова, что перерасчет значений происходит даже тогда, когда темплейт полностью удаляется из дом модели.


Чтобы Вам стало понятней о чем идет речь, приведу простой пример:
Есть поле ввода. При изменении этого поля вычисляется, например, количество введенных символов, и выводится на экран.
<input data-bind="value:symbols" />
<div data-bind="text:symbolsLength"></div>

var vm={
    symbols:ko.observable('три'),
};
vm.symbolsLength=ko.computed(function(){
    return vm.symbols().length;
})
ko.applyBindings(vm);

Тут все просто и это работает: jsfiddle

Теперь немного изменим пример. Перенесем вычисляемое значение в темплейт и добавим еще один темплейт, в котором будет статический HTML. И сделаем кнопку, по нажатию на которую темплейты будут переключаться. Я ожидаю (точнее я хотел бы чтоб так было) что значение symbolsLength не будет вычисляться, когда это значение не выводится. Поэтому для проверки этого я поставлю алерт в функцию вычисления.
<script id="template1">
    <div data-bind="text:symbolsLength"></div>
</script>
<script id="template2">
    static html
</script>
<input data-bind="value:symbols, valueUpdate: 'afterkeydown'" />
<button data-bind="click:click">Change Template</button>
<div data-bind="template:templateName"></div>

var vm={
    symbols:ko.observable('три'),
    templateName:ko.observable('template1'),
    click:function(){
        vm.templateName(
            (vm.templateName()=='template1')?
            'template2':
            'template1'
        );
    }
};
vm.symbolsLength=ko.computed(function(){
    alert(1);
    return vm.symbols().length;
})
ko.applyBindings(vm);


Теперь посмотрим на пример и осознаем что случилось. Фреймворк произвел вычисление при инициализации свойства symbolsLength. Таким образом он узнал от каких объектов зависит это значение. В данном случае в функции вычисления symbolsLength вызывался объект symbols. Следующий раз при изменении symbols Нокаут будет заново вычислять symbolsLength. То есть когда мы меняем значение в поле ввода, вызывается функция вычисления symbolsLength, что подтверждается алертом.
Также, обратите внимание, что алерт всплывает, даже если мы кнопкой «Change Template» переключимся на другой темплейт.

Итак, возникает две проблемы:
1. Всегда вызывается вычисление computed значения при инициализации, даже если это значение нигде еще не используется
2. Даже после удалении темплейта, в котором использовалось computed значение, из DOM, все равно происходит вычисление при изменении зависимого значения.

Первая проблема решается очень просто. Для этого в функцию ko.computed нужно передать параметр deferEvaluation:true:
vm.symbolsLength=ko.computed({
    read:function(){
        alert(1);
        return vm.symbols().length;
    },
    deferEvaluation:true
})


Теперь вычисление symbolsLength будет происходить только после его первого использования, то есть после добавления темплейта «template1» в DOM. Но остается вторая проблема. Объект symbolsLength уже подписался на изменение объекта symbols и, даже если symbolsLength нигде не используется, все равно будет происходить вычисление.
Внутренний голос подсказывает, что нужно каким-то образом объекту symbolsLength отписаться от изменений объекта symbols.
Но как это сделать? И второй вопрос: в какой момент?
Я нашел свои ответы на эти вопросы и дальше расскажу о них. Возможно есть более простой вариант, о чем я и хочу узнать в комментариях.

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

Ответ на второй вопрос: сделать это можно во время очередного вычисления значения, когда больше нет подписчиков на результат вычисления. В данном случае подписчиком на вычисление symbolsLength является функция вывода этого значения в темплейте «template1».

Для определения количества подписчиков и количества зависимостей у объектов в Нокауте есть две функции:
getSubscriptionsCount()
getDependenciesCount()

В ходе наблюдения за возвращаемыми этими функциями значениями, была выявлена следующая закономерность:
1. При первом вычислении обе функции возвращают ноль. В этом случае мы ничего не делаем
2. При следующих вычислениях, эти две функции возвращают реальные значения. При этом количество зависимостей по определению будет больше нуля, так как symbolsLength зависит от symbols. Но нас интересует ситуация, когда количество подписчиков равно нулю.
Учитывая описанные два пункта, можно вычислить момент, когда нужно пересоздать объект.

Итак, теоретическая часть решена. Переходим к практической реализации.
Для этого создадим функцию, которая заменит ko.computed. Назовем ее ko.recompute:
	ko.recompute=function(callback){
		var rez=function(){
                     return rez.val();
		};
		rez.__ko_proto__=ko.observable;
		var o={
			deferEvaluation:true,
			read:function(){
				var s=rez.val.getSubscriptionsCount()==0;
				var d=rez.val.getDependenciesCount()==0;
				if(s!=d) {
					setTimeout(function(){
						rez.val.dispose();
						rez.val=ko.computed(o);
					},1);
					return null;
				}
				return callback();			
			}
		};
		rez.val=ko.computed(o);
		return rez;
	};


Теперь наш пример будет выглядеть так:
var vm={
    symbols:ko.observable('три'),
    templateName:ko.observable('template1'),
    click:function(){
        vm.templateName(
            (vm.templateName()=='template1')?
            'template2':
            'template1'
        );
    }
};
vm.symbolsLength=ko.recompute(function(){
    alert(1);
    return vm.symbols().length;
})
ko.applyBindings(vm);

Таким образом вычисление symbolsLength будет происходить в двух случаях:
1. Когда необходимо вывести на экран значение symbolsLength
2. Когда значение symbolsLength выведено на экран, но произошло изменение значения symbols
Ну и конечно же результат: jsfiddle

Но более правильный вариант функции ko.recompute предложил xdenser:
ko.recompute=function(callback){
       var c;
        function create(){
            c = ko.computed({
                read: callback,
                deferEvaluation:true,
                disposeWhen:function(){
                     return (c.getSubscriptionsCount()==0)&& setTimeout(create,0);
                }
            });
        }
        create();
        function read(){
            return c();
        }
        read.__ko_proto__=ko.observable;
        return read;
    };

Тут используется специальный параметр disposeWhen. Таким образом проверка будет происходить не в функции вычисления, а перед ней.
Tags:
Hubs:
+2
Comments 27
Comments Comments 27

Articles