Как мне очистить ViewModels от KnockoutJS?

У меня есть одностраничное приложение, в котором пользовательские страницы просматривают списки элементов. Каждый элемент, в свою очередь, имеет список элементов.

Наблюдаемый массив обновляется новыми элементами с сервера, полученного через запрос AJAX. Все это прекрасно работает.

К сожалению, после нескольких страниц количество выполняемых операций (и объем памяти, используемой в браузерах, таких как FireFox и IE8) продолжает расти. Я отследил его до того, что элементы в моем наблюдаемом массиве не очищаются должным образом и на самом деле остаются в памяти, хотя я заменил элементы в моем наблюдаемом массиве новыми данными.

Я создал небольшой пример который реплицирует проблему, которую я вижу:

HTML:

<p data-bind="text: timesComputed"></p>
<button data-bind="click: more">MORE</button>
<ul data-bind="template: { name: 'items-template', foreach: items }">
</ul>

<script id="items-template">
    <li>
        <p data-bind="text: text"></p>
        <ul data-bind="template: { name: 'subitems-template', foreach: subItems }"></ul>
    </li>
</script>

<script id="subitems-template">
    <li>
        <p data-bind="text: text"></p>
    </li>
</script>

JavaScript/KnockoutJS ViewModels:

var subItemIndex = 0;

$("#clear").on("click", function () {
  $("#log").empty();
});

function log(msg) {
  $("#log").text(function (_, current) {
    return current + "\n" + msg;
  });
}
function Item(num, root) {
  var idx = 0;

  this.text = ko.observable("Item " + num);
  this.subItems = ko.observableArray([]);
  this.addSubItem = function () {
    this.subItems.push(new SubItem(++subItemIndex, root));
  }.bind(this);

  this.addSubItem();
  this.addSubItem();
  this.addSubItem();
}

function SubItem(num, root) {
  this.text = ko.observable("SubItem " + num);
  this.computed = ko.computed(function () {
    log("computing for " + this.text());
    return root.text();
  }, this);

  this.computed.subscribe(function () {
    root.timesComputed(root.timesComputed() + 1);
  }, this);
}

function Root() {
  var i = 0;

  this.items = ko.observableArray([]);
  this.addItem = function () {
    this.items.push(new Item(++i, this));
  }.bind(this);

  this.text = ko.observable("More clicked: ");
  this.timesComputed = ko.observable(0);

  this.more = function () {
    this.items.removeAll();
    this.addItem();
    this.addItem();
    this.addItem();    
    this.timesComputed(0);
    this.text("More clicked " + i);
  }.bind(this);

  this.more();
}

var vm = new Root();

ko.applyBindings(vm);

Если вы посмотрите на скрипту, вы заметите, что "журнал" содержит запись для каждого созданного ViewModel. вычисленное свойство SubItem.computed выполняется даже после того, как я ожидал, что каждый из этих элементов будет удален. Это вызывает серьезное ухудшение производительности в моем приложении.

Итак, мои вопросы:

  • Что я здесь делаю неправильно? Я ожидаю, что KnockoutJS избавится от ViewModels, который мне действительно нужно утилизировать вручную?
  • Является ли использование ko.computed на SubItem моей проблемой?
  • Если KnockoutJS не собирается удалять эти режимы просмотра, как я должен сам избавляться от них?

Обновление: После некоторого дальнейшего копания, я уверен, что вычисленное свойство в SubItem является виновником. Тем не менее, я все еще не понимаю, почему это свойство все еще оценивается. Не следует ли разрушать SubItem при обновлении наблюдаемого массива?

Ответ 1

Сборщик мусора JavaScript может только выставлять вычисленное наблюдаемое после того, как все ссылки на него и его зависимости будут удалены. Это потому, что наблюдаемые сохраняют ссылку на любые вычислимые наблюдаемые, которые зависят от них (и наоборот).

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

function autoDisposeComputed(readFunc) {
    var computed = ko.computed({
        read: readFunc,
        deferEvaluation: true,
        disposeWhen: function() {
            return !computed.getSubscriptionsCount();
        }
    });
    return computed;
}