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

    Сразу предупреждаю, что статья рассчитана только для тех кто использует либо собирается использовать 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. Таким образом проверка будет происходить не в функции вычисления, а перед ней.
    Поделиться публикацией
    Похожие публикации
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 27
    • 0
      На самом деле можно сделать проще, но не бесплатно. Можно просто вместо ko.computed использовать функцию.
      vm.symbolsLength=function(){
              alert(1);
              return vm.symbols().length;
          }; 

      Конечно, тогда в биндингах надо будет добавлять скобочки. Но эффект будет тот же, как и от этой кучи кода. Почему тогда везде не использовать просто функцию вместо computed? А на нее нельзя подписаться. Ну так и на ko.recompute этот тоже нельзя подписаться.
      А впрочем вот так даже без скобочек должно работать:
      ko.recompute=function(callback){
         callback.__ko_proto__=ko.observable;
         return callback
      }
      
      • 0
        Спасибо за комментарий! Честно скажу для меня было неожиданностью, но этот вариант заработал. Для простого случая — это отличный вариант, но тогда теряется преимущество computed объекта — он вычисляется только один раз если исходные данные не изменились.
        Например
        vm.symbolsLength=ko.recompute(function(){
            alert(1);
            return vm.symbols().length;
        });
        vm.x1=ko.recompute(function(){
            return vm.symbolsLength()+'x1';
        });
        vm.x2=ko.recompute(function(){
            return vm.symbolsLength()+'x2';
        });
        

        jsfiddle
        Мой вариант вычисляет symbolsLength только одни раз, Ваш вариант будет вычислять его два раза
        • 0
          Да кеширование значения теряется, но в последнем примере смысла применять recompute для symbolsLength тоже нет. Поскольку последующие вызовы все равно создадут постоянные зависимости. Но вобщем согласен — мой вариант не совсем эквивалентен.
          • +1
            Постоянных зависимостей не будет. Допустим в темплейте мы вывели переменную x1. Затем мы поменяли темплейт. Произойдет следующее:
            При первом изменении symbols будет пересоздан x1, т.к. у него нет подписчиков (темплейт удален).
            При втором изменении symbols будет пересоздан symbolsLength, т.к. подписчик x1 был удален.
            Следующий раз при изменении symbols вычислений не будет, пока symbolsLength или x1 не появятся в DOM.
            • 0
              Да согласен. Вот еще вариант, чуть красивее:
              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;
                  };
              
              • 0
                Да, использование disposeWhen будет более правильнее.
                Спасибо. Дополню статью вашим вариантом.
      • 0
        Сначала был в восторге от Backbone. Потом от Knockout. Потом от Knockback. С развитием приложений и усложнением интерфейсов я начал сталкиваться с сюрпризами и ограничениями, которые приходилось решать набором всяческих самописных костылей. Иногда конечно оказывалось, что я протупил и проблема решалась просто заменой computed на fn(), но все равно в итоге почему-то получалась каша. Работающая, но каша.

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

        Если не знакомы, советую поиграться, как минимум, для осведомленности :)
        • +1
          С описанной в статье проблемой столкнулся уже на этапе оптимизации проекта и переписывать на другой фреймворк уже было поздно. Тем более хотелось все-таки разобраться в чем была причина проблемы. По правде говоря, решив ее, все стало работать довольно шустро и меня вполне устроило. Но если будет подобная задача, то для сравнения воспользуюсь AngularJS.
          • 0
            Просто Knockout не дает готового решения как структурировать свое приложение. Это оставлено на откуп разработчика.
            Кому то достаточно сделать простую модель, а кому то надо делать сложное приложение. И с Knockout можно избежать каши, просто рецепта нет. В этом есть как положительные так и отрицательные моменты. То же относится и к AngularJS — вам дают готовый рецепт структурирования и у этого подхода тоже есть плюсы и минусы.
            • 0
              > И с Knockout можно избежать каши, просто рецепта нет.
              Возможно, но есть пара нюансов.

              > То же относится и к AngularJS — вам дают готовый рецепт структурирования
              Это, пожалуй, самый главный плюс для меня.
              • 0
                Вы так ссылаетесь на эти нюансы, как будто там написана истина в последней инстанции. К сожалению не могу привести ответную ссылку. Могу сказать только что утверждения там спорные:

                1. Насчет POJO — это не совсем верно, поскольку в контроллер нам подсовывают клон объекта с предыдущей итерации. Или я не понимаю как это работает. Про клонирование, кстати, ни слова не сказано. Впрочем, да может быть POJO это лучше чем необходимость объявлять объекты особого типа, но опять же это не объект, который мы создаем сами. И кстати эти контроллеры в глобальном пространстве имен выглядят подозрительно. Надеюсь, их можно спрятать.
                2, Насчет того что множественное обновление массива вызывает обновление UI на каждой итерации. Так это легко обходится. И есть выбор — обновлять для каждого или для всех. Что в Angular на этот счет?

                В общем я думаю подход Angular vs Knockout это тема скорее для холивара. Потому что объективных преимуществ нет ни у того ни у другого. Я говорю именно о подходе. Angular несомненно более развитый фреймфорк в целом.
                • 0
                  1. Насчет POJO — это не совсем верно, поскольку в контроллер нам подсовывают клон объекта с предыдущей итерации.

                  Не клон, а наследник, имеющий родительский scope в качестве prototype.

                  Дело в том, что POJO — это то, что мы объявляем в этом scope (который подсунули). Scope — это, по сути, контейнер: jsfiddle.net/W3aMj/

                  $scope.model — это объект, который мы создали сами, с ним мы и работаем.

                  И кстати эти контроллеры в глобальном пространстве имен выглядят подозрительно. Надеюсь, их можно спрятать.

                  Да, меня это тоже сначала обескуражило, а потом оказалось, что можно по-нормальному. Все равно удивляюсь, почему в документации примеры с глобальными переменными. Наверное, чтоб продемонстрировать, как все просто :) jsfiddle.net/ayJqy/1/

                  Насчет того что множественное обновление массива вызывает обновление UI на каждой итерации… Что в Angular на этот счет?

                  Дык он на следующем тике все обновит, делая «dirty check». В этом же и фишка. В текущем тике можно менять хоть 1000 раз — ни чего не произойдет.
                  Как такое сделать в Knockout кстати?

                  В общем я думаю подход Angular vs Knockout это тема скорее для холивара. Потому что объективных преимуществ нет ни у того ни у другого.

                  Для меня объективное преимущество Angular — отсутствие недостатотков Knockout. Недостатков такого масштаба у Angular пока не вижу. Наверное, это единственная причина, по которой я продолжаю этот разговор :)

                  А вообще действительно, давайте закругляться. Если у вас есть вопросы касательно того, как устроен Angular, постараюсь на них ответить.
                  • 0
                    У меня есть вопрос.
                    Если вас не затруднит, хотелось бы увидеть и пощупать аналог описанной в статье ситуации (jsfiddle). Интересно посмотреть как переключать темплейты и как часто будут пересчитываться значения.
                    • 0
                      Конечно, jsfiddle.net/m3Z8r/1/

                      К моему удивлению, Angular ведет себя в данном случае довольно непредсказуемо:

                      — при переключении на template1 он вызывает функцию symbolsLength() дважды
                      — при переключении на template1 — единожды (а нужно, чтоб вообще не вызывал)

                      Но если установить изначальное значение templateName в 'template2' — тогда не вызовет symbolsLength() ни разу, что правильно.

                      Я сам знаком с Angular не больше недели, и мне интересно, почему так получается. Думаю, задам вопрос на stackoverflow. Видимо, на то есть свои причины, но xdenser все-таки прав касательно отсутствия объективных преимуществ обоих подходов.
                      • +1
                        Цикл $digest будет выполняться пока все watch-и не стабилизируются (новое значение равно пердыдущему для каждого watch-а). Когда устанавливается template1, он компилируется, создается новая область видимости. Предыдущее значение для watch-а «symbolsLength()» еще не заполнено. Поэтому на первом проходе оно заполняется, потом делается второй проход и т.к. ничего не изменилось, он является последним. Как то так.
                        • 0
                          А почему в данном случае symbolsLength() вызывается по два раза при каждом вводе символа?
                          • 0
                            Первый проход — длина изменилась, на втором — стабилизировалась.
                            А что Вы пытаетесь измерять? Просто измерение получается в том числе источником изменения.
                            Вот так обычно более «чисто» измеряют: jsfiddle.net/m3Z8r/4/
                            • 0
                              Осознал. Теперь понятно, почему геттеры настоятельно рекомендуется делать максимально быстрыми и свободными от побочных эффектов.
                              • 0
                                aav, этот вариант от octave больше соответствует описанной в статье ситуации, так как у вас не вычисляется значение symbolsLength. Но благодаря вашему комментарию я начинаю улавливать отличие между knockaut и angular.
                                Например knockaut кеширует геттеры по используемым данным. То есть если используемые данные не изменились, то возвращается последнее вычисленное значение.
                                Но я не до конца понял как кешируются данные в angular? Как вы сказали, значения пересчитываются каждую итерацию, пока меняется результат. Но как закешировать геттер в пределах одной итерации?
                                Например, в этом примере, я так понял, значение пересчитывется в переделах одной итерации столько раз, сколько используется геттер. И, если knockaut делает одно вычисление, то angular 3*2=6!
                                • 0
                                  Angular делает так, как Вы ему сказали делать.
                                  Если у Вас какие-то тяжелые вычисления, которые при этом не зависят от других данных Ваших моделей, которые в рамках итерации, вообще говоря, тоже могли измениться, то Вам же никто не запрещает их закешировать самому?
                                  А если в геттере какое-то элементарное действие, то чего его кешировать то?
                                  • 0
                                    В моем проекте как раз и были сложные вычисления в геттерах. Я многое могу сделать сам, в том числе и закешировать, но фреймверки как раз и нужны, чтобы меньше писать самому. И если в knockout уже есть кеширование, то это ему огромный плюс (правда пришлось допиливать — это конечно минус).
                                    • 0
                                      Ну тут, как я понимаю, самый главны вопрос — насколько часто эта необходимость возникает. У меня она возникает очень редко.
                                      Я не очень хорошо знаю knockout, но, насколько могу судить по примерам и статьям, в AngularJS как раз писать приходится меньше.
                      • 0
                        Дык он на следующем тике все обновит, делая «dirty check». В этом же и фишка. В текущем тике можно менять хоть 1000 раз — ни чего не произойдет.
                        Как такое сделать в Knockout кстати?

                        Вот так (используя «throttle» extender).

                        Либо можно написать свой укороченный extender, если нужен только read метод:
                        	ko.extenders.async = function(target, timeout) {
                        		target['throttleEvaluation'] = timeout;
                        		return target;
                        	};
                        
                        • 0
                          Еще тут есть пост об этом. Можно просто взять весь массив изменить, потом вызвать событие обновления.
                        • 0
                          Все равно я не понимаю как это работает с прототипами. Ну, допустим, если у меня простое свойство, тогда все понятно изменяя scope мы изменим только его свойство, а старое останется в прототипе. Но если у меня в scope массив и я сделаю в него push, я же изменю массив в прототипе, и вся магия развалится.
                          • 0
                            Вот здесь об этом автор говорит: www.youtube.com/watch?v=ZhfUv0spHCY#t=1871s

                            Но если у меня в scope массив и я сделаю в него push, я же изменю массив в прототипе, и вся магия развалится.

                            Не развалится, если эксплицитно объявить этот массив в текущем $scope, что и подразумевается.
                • 0
                  По-моему где-то я видел красивое решение этой проблемы, но вспомнить не могу где.

                  А можете вопрос продублировать на stackoverflow.com? Там есть несколько активных контрибутеров, может у них какие идеи есть?

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