Введение


Недавно у нас закончился крупный проект с довольно сложным продвинутым UI. Не вдаваясь в детали, скажем, что внутри браузера было реализовано что-то вроде рабочего стола (desktop) с окнами, перекрытиями и всем, чем полагается. Разумеется, проблемы с утечками памяти не обошли нас стороной. Признаемся честно, до поры до времени сосредоточились на получении бизнес-результата. Когда дошли руки до утечек памяти, то обнаружилось, что окна браузера занимают гигабайты оперативной памяти. Мы классифицировали ошибки и в общем виде выработали подход к их устранению. Этим подходом и хотим поделиться с вами.

По теме утечек памяти в клиентских приложениях написано уже немало. Изначально основную проблему представляли из себя браузеры IE8 и младших версий (смотрите, например:
http://habrahabr.ru/post/141451/
http://habrahabr.ru/post/146784/
https://learn.javascript.ru/memory-leaks).
Но и теперь, когда можно сказать, что IE8 в прошлом, проблемы остаются. Даже применение такого языка как TypeScript не гарантирует их отсутствия. А с учетом того что front-end в web-приложениях становится все сложнее, актуальность проблемы только возрастает.


Причины возникновения ошибок


Основными источниками утечек, которые мы для себя выделили были:
  • jQuery-виджеты
  • Custom Knockout-bindings
  • реализация архитектуры publish-subscribe
  • использование Promises
  • D3
  • Google Maps

Забегая вперед скажем, что последний пункт доставил наибольшее количество проблем. Часть из которых, к сожалению, вообще не могут быть решены. Тем не менее и здесь негативные последствия, могут быть сведены к минимуму.

Шаблон использования обработчиков очистки


Мы выделили операции по очистке в специальный модуль:
import ed = require(‘disposing');

Этот модуль содержит интерфейс ed.Disposables который по сути представляет из себя дерево обработчиков событий удаления, связанное с отображаемым представлением. Предполагается что эти обработчики будут зарегистрированы при создании объекта, т.е. в конструкторе класса.
См. пример:
class MapControl {
  constructor(
    …    
    private beDisposed	: ed.Disposables // передаем ссылку на объект механизма очистки памяти
    …
  ) {
 ...
	beDisposed(beDisposed => {
		… //код обработчика
} );
}


Когда часть представления становится не нужна, например, при закрытии модельного окна, или уходе со страницы, часть дерева нужно удаляем:
ulo.enableWindowUnloadTracking(window, function () {
ed.disposeAll(beDisposed, 'window unloading');
ey.nullify(window, 1, ec.alwaysTrue);
});

Давайте рассмотрим устранение ошибок с помощью данного подхода.

1. jQuery – виджеты


Хотя в современной жизни наблюдается определенная тенденция отказа от библиотеки jQuery, во многих случаях без нее не обойтись. Огромную роль играет миллионная армия виджетов, многие из которых реализуют весьма важные и полезные особенности.
Типичным приемом работы с виджетом является «заворачивание» его в объект-обертку, например:
export function toCheckbox(
  $element: JQuery,
  options: CheckboxOptions
) : JQuery {
return $element.jqxCheckBox(options);		
}

Проблема состоит в том, что после конструирования виджета, в объекте-обертке сохраняется ссылка на jQuery-селектор ($element). Такая ссылка может порождать циклическую зависимость между объектом и соответствующим DOM-элементом. Поэтому, такая ссылка должна быть «зачищена» при срабатывании механизма очистки памяти. В нашем случае для этого используется специальная функция nullify, которая вызывается для jQuery-селектора и «зануления» ссылки на jQuery-селектор:
 // реализация функции-конcтруктора jQuery-виджета
export function toCheckbox(
  beDisposed: ed.Disposables,
  $element: JQuery,
  options: CheckboxOptions
) : JQuery {
  ed.append(beDisposed, function disposeJqxCheckbox() {
      if ($element != null) {
          let instance = $element.jqxCheckBox('getInstance');

          // вызываем деструктор jQuery-виджета
          $element.jqxCheckBox('destroy');

          // 'зануляем' объект jQuery-виджета
          ed.nullify(instance, 1);

          // 'зануляем' ссылки
          $element = null;
          options = null;
          beDisposed = null;
      }
  });
  return $element.jqxCheckBox(options);
}

Таким образом, мы ликвидируем все возможности для виджета, и его DOM-представления остаться «не зачищенными».

2. Custom Knockout-bindings


Вообще, везде, где появляются DOM-элементы, необходимо внимательно следить за утечками. Поскольку основное назначение Konckout – связать между собой модель и представление, важно аккуратно обходится с DOM-элементами.

В следующем примере объект останется «висеть» в памяти, т.к. привязывает свои обработки событий к DOM-элементу:
init: function (element: HTMLElement, …) {  
     let beDisposed = xko.toBeDisposed(element);
      $(document).on('keypress', 'input,textarea,select', function (e) {
          $(element).on('keypress', 'input, textarea, select', function (e) {
          ...
          });
      });
}

Поэтому нужно их соответствующим образом отвязать:
init: function (element: HTMLElement, …) {
  
      let beDisposed = xko.toBeDisposed(element);

      $(document).on('keypress', 'input,textarea,select', function (e) {
          $(element).on('keypress', 'input, textarea, select', function (e) {
          ...
          });
      });
      ed.appendUntied(beDisposed, () => {
          $(element).off('keypress');
          $(document).off('keypress');
      });
  }


3. Реализация publish/subscribe архитектуры


Архитектура publish/subscribe является типичным приемом уменьшения связности. В нашем проекте такой прием получил реализацию в виде сигналов (Signals). Проблемы здесь может вызвать отсутствие отписки от действующих обработчиков сигнала после того, как объект уже был удален. Если в коде присутствует подписка на событие, то обязательно должна быть и отписка от него. Отсутствие отписки чревато большими утечками, особенно если callback-функция содержит ссылки на объекты с коротким сроком жизни. Проиллюстрируем это на примере использования сигнала с последующей отпиской для события click указанного html-элемента:
class Button implements ucb.Component {
  // объявляем сигнал, который потребитель может использовать для подписки на событие клика по элементу кнопки
  public justClicked = es.toActSignal();
  private noMoreOpt : () => void = null;

  constructor(
      // передаем ссылку на объект механизма очистки памяти
      private beDisposed: ed.Disposables,
      private stopPropogation: boolean
  ) {
      …
  }

  attach(element: HTMLButtonElement): void {
      // используем вспомогательную фугнкцию для регистрации очерендного обработчика события click html-элемента, который будет автоматически разрегистрироваться при удалении объекта
      this.noMoreOpt = ue.listenToUntil(
          this.beDisposed,
          element,
          'click',
          (event: MouseEvent) => {
              if (this.stopPropogation) {
                  ue.stopEventPropagation(event, 'Event blocked by a button component.');
              }
              // генерирует сигнал
              this.justClicked();
          }
      );
  }

  detach(): void {
      // все подписки будут разрегистрированы в момент удаления объекта
      if (this.noMoreOpt != null) {
          this.noMoreOpt();
          this.noMoreOpt = null;
      }
  }
}

Кроме того, обратите внимание, что при подписке на событие также необходимо передать ссылку на дерево обработчиков:
submit.justClicked.watchUntil(beDisposed, () => {
    // реализация обработчика
  });


4. Promise и race conditions (состояние гонки в клиентском коде)


Нередки случаи, когда в javascript-приложениях возникает необходимость применять асинхронные операции, например, обращения к серверу. Для решения таких задач, как правило, используют так называемые Promises. Это могут быть как jQuery-promises, так и promises из популярной библиотеки Q. Независимо от того, какая библиотека используется, способы работы с такими объектами схожи. Использование promises само по себе не вызывает затруднений, но, в зависимости от реализуемого сценария, могут проявится побочные эффекты, заметить которые можно только с помощью инструментов отладки и анализа.
Рассмотрим случай так называемых гонок или race conditions, на примере следующего кода:
// небезопасный вызов then
some.willGetLegendImage(this.beDisposed).then((image) => {
  model.setLegendImage(image);
});

Проблема здесь состоит в том, что вызывающий код проигнорировал возвращаемый then(…) объект-promise и, как следствие, не имеет представления о том, когда завершится выполнение переданной функции. Выполнение функции как бы выпадает из основного потока, а используемые объекты остаются заблокированными в памяти до момента обратного вызова. Кроме того, возможна ситуация, когда функция сработает после удаления исходного объекта, при этом мы получим исключение вида undefined.

Чтобы получить более безопасный код, нужно вернуть promise для последующего использования:
// сохраняем ссылку на объект promise, полученный как результат вызова then. Этот promise наследует основной promise, возвращаемый функцией willGetLegendImage.
 var promise = some.willGetLegendImage(this.beDisposed).then((image) => {

// ссылка на model из callback-функции блокирует объект в памяти 
  model.setLegendImage(image);

});

Таким образом, нужно всегда отслеживать состояние исходных promises и всегда возвращать результат вызова then(), наследуемые от исходных promises. Эти объекты также можно удалять используя механизм обработчиков событий удаления.

5. Библиотека D3


Для реализации требований в нашем проекте возникла необходимость отображать графики и схемы, которые трудно реализовать имея в распоряжении только html-элементы. Вдохновившись возможностями библиотеки D3, было решено использовать для работы её в купе с SVG-элементами. Несмотря на то, что API библиотеки относительно прост, сопровождается развитой документацией и существует масса работоспособных примеров, в контексте нашего приложения возник набор нюансов, о которых хотелось бы упомянуть.

5.1. D3 Updated Selection


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

Логично предположить, что при обновлении данных можно получить подмножество svg-элементов, которые не могут быть сопоставлены с обновленными данными, проще говоря они становятся ненужными. Часто, при построении представления, элементы из которых оно состоит, могут использоваться другими компонентами и такие элементы однозначно нужно как-то зачищать перед удалением. После связывания с данными мы получаем объект D3 UpdatedSelection. У такого объекта есть метод exit(), который позволяет получить доступ к подмножеству удаляемых элементов. Метод возвращает массив элементов, перебирая которые в цикле, можно выполнить код очистки. Если такой функционал отсутствует и где-то есть ссылки на ранее сгенерированный элемент, то в последствии можно обнаружить висящие в памяти DOM-элементы (так называемые detached DOM elements).
Например, имеем представление из маркеров – точек на карте, где каждая точка задана своими координатами:
// связываем маркеры с данными
var markers = this.layer.selectAll('svg.marker')
  .data(dataItems, d => d.itemId);

// для новых данных добавляем нужные элементы
markers.enter()
  .append('svg')
  .classed('marker', true)
  .each(function(data) { transform.call(this, data, paddingLeft, paddingTop); });
  .each(function(data: DataItem) {
      // рисуем маркер
      renderMarker.call(this, data, that.renderOptions);
  })

// все маркеры должны быть зарегистрированы в объекте SelectManager, чтобы он стал выбираемым с помощью мыши
markers.each(function(data) {
    var element = d3.select(this).node();
    var markerElement = $(element).find('circle, polygon').get(0);
    
    // добавляем ссылку на элемент маркера
    select.attach(markerElement, dragObjectFrom);
})

// ненужные маркеры перед их удалением из DOM-дерева должны быть дерегистрированы из объекта SelectManager
markers.exit()
  .each(function() {
      var element = d3.select(this).node();
      var markerElement = $(element).find('circle, polygon').get(0);
      
      // отсоединение элемента
      select.detach(markerElement);
  })
  .remove(); // и, наконец, удаляем элемент

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

5.2. D3 Events


D3 предоставляет собственную абстракцию для работы с событиями. Необходимость использовать события через D3 может возникнуть, например, при связывании данных, или при использовании d3.behaviors (drag, move, zoom и тому подобное).
Если предполагается, что представление должно обрабатывать события, должен присутствовать и код, выполняющий отписку существующих обработчиков от событий. Удаление обработчика делается просто: указываем строковую константу в качестве имени события и передаем вместо функции обработчика null:
// использует ссылку на объект d3 UpdatedSelection
selection.datum(null)
// удаляем все обработчики события click, dblclick и mousedown:
            .on('click', null)
            .on('dbclick', null)
            .on('mousedown', null)
// удаляем все обработчики, которые реализую d3 drag behavior:
            .on('drag', null)
            .on('dragstart', null)
            .on('dragend', null)
            .on('zoom', null)

Этот код, конечно же, можно и нужно поместить внутрь обработчика удаления объекта.

6. Google Map


6.1. Специфика жизненного цикла объекта Map


При всех плюсах данного компонента необходимо учитывать особенности жизненного цикла объекта карты: создав однажды экземпляр объекта Map его не получится удалить. Google Map API не предоставляет функцию деструктора данного объекта. Более подробно данный аспект обсуждается здесь: https://code.google.com/p/gmaps-api-issues/issues/detail?id=3803.
Необходимо с самого начала учесть этот нюанс в архитектуре приложения, чтобы в последствии избежать нежелательных утечек памяти.

Поэтому, чтобы минимизировать утечки памяти, разработчик просто обязан позаботиться об аккуратном использовании такого специфического объекта.

В зависимости от специфики приложения можно порекомендовать переиспользовать однажды созданный экземпляр карты, если она одна. Или иметь пул объектов карт, если необходимо одновременно работать с несколькими картами.

6.2. Работа с событиями карты и объектами карты


  • При использовании событий карты, по аналогии с DOM-событиями, поле подписки нужно сохранять во внутренней переменной ссылку на функцию кода очистки. Вызов такой функции обязательно нужно поместить в код очистки компонента.
  • При создании объектов карты (точки, линии, прямоугольники и пр.) необходимо очищать связанные данные, «занулять» ссылки на маркеры, отписываться от событий, и удалять данные слоя в коде очистки компонента:

import ed = require(‘disposing'); // механизм освобождения памяти реализован в модуле disposing, который подключается под именем ed
…

class MapControl {

  private dispatch: {  …  }; // диспетчер событий (d3)
  constructor(
    // передаем ссылку на объект механизма очистки памяти
    private beDisposed	: ed.Disposables,
    …
  ) {
    ...
  
    // регистрируем код очистки  
    ed.append(beDisposed, () => {
      
      // удаляем обработчики событий объекта карты
      this.mapEventsListeners.forEach(listener => google.maps.event.removeListener(listener));
      
      // зачистка событий объекта карты ()
      google.maps.event.clearInstanceListeners(this.map);
      this.map.unbindAll();

ea.use(this.markers, (marker: google.maps.Marker) => {
marker.setMap(null);
marker = null;
});
this.markers = null;

      // ручная зачистка DOM-элементов, порождаемых картой ()
      var element = this.map.getDiv();
      ea.use(
          ud.toNodeArray(element.getElementsByTagName('img')),
          (image: HTMLImageElement) => {
              image.src = '';
          }
      );
      ko.cleanNode(element, 'map-control');
      
      // ‘зануляем’ ссылку на объект карты
      this.map = null;    
    });  
  }
}

Как видно из приведенного примера мы сначала отписываемся от событий, а потом очищаем объекты карты.

Вместо заключения


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


P.S. Мы показали то, с чем столкнулись сами и как решали данные проблемы. Если у вас есть аналогичный опыт, будем рады о нем узнать, поскольку общий подход к проблеме, на наш взгляд, еще только вырабатывается.

Комментарии (2)


  1. greybax
    29.12.2015 00:30
    +9

    Одно не понятно — при чем тут TypeScript? Все описанное можно встретить в любом JS приложении


    1. some_x
      31.12.2015 11:59

      Я думаю при том что разработчики разработали методику борьбы с утечками которая оперирует понятиями отсутствующими в js, такими как интерфейс.