Статьи

Богатые объектные модели и Angular.js: памятка

stringonfinger

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

В этой статье я расскажу о дорогостоящих вычислениях в богатых объектных моделях — будь то скрытые за методами получения или просто обычными методами — и о том, как мы можем использовать мемоизацию, чтобы уменьшить влияние этих вычислений на производительность. Мемоизация — это не то, что вы должны использовать постоянно, но для сценариев использования, включающих длинные цепочки вычислений, это может быть эффективным методом оптимизации, особенно в среде, такой как Angular.

Если вам не хочется читать, вы всегда можете перейти к той части моей презентации ng-conf, которая посвящена запоминанию , или перейти прямо к проекту Github . В любом случае, я предполагаю, что вы знакомы с основополагающими концепциями, которые я представил в первом посте этой серии.

Пример

Давайте продолжим с примера из моих предыдущих постов: подсчет стоимости, выручки и прибыли от предложения по отделке салона самолета:

angular.module('models').
  factory('Proposal', function(Base) {
    return Base.extend({
      get profit() {
        return this.revenue.minus(this.cost);
      },
      get revenue() {
        return this.price.
          convertTo(this.internalCurrency);
      },
      get cost() {
        this.recurringEngineering.cost.plus(
          this.nonRecurringEngineering.cost
        );
      },
      ...
    });
  });

Скажем, мы хотим отобразить эти значения в табличном формате, а также предоставить поле ввода, которое позволяет нам изменить количество самолетов, которые мы предлагаем оснастить. Наш шаблон может выглядеть примерно так:

...
<tr><td>Cost</td><td money="proposal.cost"></td></tr>
<tr><td>Revenue</td><td money="proposal.revenue"></td></tr>
<tr><td>Profit</td><td money="proposal.profit"></td></tr>
...
<tr>
  <td>Number of Aircraft</td>
  <td>
    <input type="number"
      min="1"
      ng-model="proposal.numberOfAircraft">
    </input>
  </td>
</tr>
...

Обратите внимание, что:

  • Мы используем пользовательскую директиву для отображения денежных значений. Это действительно просто для того, чтобы избежать дублирования кода — в каждом случае нам нужно отображать как валюту, так и сумму.
  • Хотя мы используем нотацию свойств для доступа к значениям, мы могли бы также связываться с вызовами методов. С помощью методов получения немного проще потерять отслеживание того, что на самом деле вычисляется:)

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

Чтобы гарантировать правильное обновление, каждый раз, когда Angular обнаруживает любое событие, будь то ввод пользователя, обратный вызов AJAX или даже таймер, он выполняет цикл дайджеста.

Каждый цикл включает переваривать Угловой неоднократно итерацию списка часов — который будет включать в себя proposal.cost, proposal.revenueи proposal.profitвыражении — и переоценка этих выражений , пока никаких изменений не обнаружены.

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

Вероятно, наиболее известное правило оптимизации производительности Angular заключается в том, что ваши выражения часов не должны занимать слишком много времени для оценки. Но в нашем случае, по мере того, как предложения становятся больше — больше деталей, больше статей затрат и т. Д. — соответственно увеличивается время расчета. Возможно, мы не сможем полностью исключить это время, но можем ли мы хотя бы сократить количество ненужных вычислений?

Представляем памятку

Мемоизация — это хорошо известная стратегия для ускорения дорогостоящих программ.

В контексте языка с функциональными корнями, такого как Javascript, запоминание — это когда вы оборачиваете функцию так, что, когда она вызывается в первый раз, результат вызова сохраняется. При последующих вызовах вместо повторного вызова исходной функции сохраненное значение возвращается немедленно. Такие библиотеки, как underscore.js и lodash, предоставляют инструменты для запоминания , или вы можете написать свои собственные.

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

Поэтому без лишних слов я представляю Base.memoizeметод — декларативное средство для указания свойств объекта, значения которого должны быть запомнены. Вот как это выглядит в действии:

angular.module('models').
  factory('Proposal', function(Base) {
    return Base.extend({
      memoize: ['revenue', 'cost', 'profit'],
 
      get profit() {
        return this.revenue.minus(this.cost);
      },
      get revenue() {
        return this.price.
          convertTo(this.internalCurrency);
      },
      get cost() {
        this.recurringEngineering.cost.plus(
          this.nonRecurringEngineering.cost
        );
      },
      ...
    });
  });

В этом случае, вызовы с profit, revenueи costбудет memoized методов геттерных. Реализация также обрабатывает вызовы обычных методов. Однако, если он используется в обычном методе, он не обращает никакого внимания на аргументы метода — тот же результат будет возвращен при последующих вызовах, даже если аргументы отличаются. Следовательно, во избежание путаницы желательно не запоминать методы, принимающие аргументы.

Unmemoization

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

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

Как мы это используем? Ну, самый простой способ — вызывать его всякий раз, когда пользователь запускает какое-то действие. Например, когда они устанавливают количество самолетов в предложении:

...
<tr><td>Cost</td><td money="proposal.cost"</td></tr>
<tr><td>Revenue</td><td money="proposal.revenue"></td></tr>
<tr><td>Profit</td><td money="proposal.profit"></td></tr>
...
<tr>
  <td>Number of Aircraft</td>
  <td>
    <input type="number"
      min="1"
      ng-model="proposal.numberOfAircraft"
      ng-change="proposal.unmemoize()">
    </input>
  </td>
</tr>
...

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

Вложенная незапятнанность

memoizeДекларация может быть применена к любому бизнес — объекту, а не только предложения. Например, мы могли бы избежать дорогостоящих вычислений на NonRecurringEngineeringобъекте, запоминая материальные и внутренние затраты:

angular.module('models').
  factory('NonRecurringEngineering', function(Base, Money) {
    ...
    return Base.extend({
      memoize: ['materialCost', 'internalCost'],
      ..
      materialCost: function() {
        return total(this.materialCostItems);
      },
      internalCost: function() {
        return total(this.internalCostItems);
      },
      ...
    });
  });

Эти вычисления также должны быть незапятнаны, но необходимость явно делать это для каждого запомненного объекта в дереве вычислений не будет практической. Вместо этого я сделал так, что, помимо указания имен методов в memoizeобъявлении, вы также можете указать имена дочерних объектов, которые должны быть немеизированы вместе с родителем. Например:

angular.module('models').
  factory('Proposal', function(Base) {
    return Base.extend({
      memoize: ['revenue', 'cost', 'profit',
        'recurringEngineering', 'nonRecurringEngineering'],
 
      get profit() {
        return this.revenue.minus(this.cost);
      },
      ...
    });
  });

Это означает , что при вызове unmemoizeпо предложению, то recurringEngineeringи nonRecurringEngineeringсвойство также будет unmemoized. Затем они, в свою очередь, очистят всех запомненных детей, которые у них есть.

Ожидается, что любой дочерний объект, указанный в, memoizeбудет Baseсмешан с ним и, следовательно, также будет иметь соответствующий unmemoizeметод. Исключение будет выдано во время смешивания, если это не так.

Автоматическое снятие памятки

Если в нашем шаблоне было несколько точек, где proposal.unmemoize()нужно было вызывать, код получился бы довольно повторяющимся. Еще хуже, если мы забудем очистить памятки в определенной точке взаимодействия, вероятно, мы получим неожиданные и неправильные результаты.

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

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

Я создал afterEveryDigestсервис, который позволяет контроллеру регистрировать функцию, которая будет выполняться после каждого цикла дайджеста. Вот пример этого в действии:

angular.module('controllers', ['services']).
  controller('ProposalCtrl',
    function($scope, $routeParams, ProposalSvc, afterEachDigest) {
      ProposalSvc.get($routeParams.proposalId).then(
        function(proposal) {
          $scope.proposal = proposal;
 
          afterEachDigest($scope, function() {
            $scope.proposal.unmemoize();
          });
        }
      );
    });

Теперь нам больше не нужно вызывать proposal.unmemoize()в каждом месте кода, где пользователь изменяет предложение.

Обратите внимание, что реализация этого сервиса в настоящее время должна использовать частную функцию Angular $$postDigest. Важно отметить, что $$postDigestвыполняется только после следующего цикла дайджеста, а не после каждого цикла. Чтобы заставить его запускаться после каждого цикла, я сначала попытался передать ему функцию, которая рекурсивно планировала себя, но $$postDigestпоместил эту функцию в очередь задач для текущего цикла дайджеста, что означает, что она немедленно выполнит функцию снова в цикле , входя в бесконечный цикл.

К счастью, я нашел фрагмент кода Карла Симона, который предлагает альтернативный подход в сочетании $$postDigestс $watch. Однако факт остается фактом $$postDigest— это все еще частная функция. В более долгосрочной перспективе я надеюсь на что-то вроде $postDigestWatchзвонка, который кратко упомянул Карл, который может превратиться в Angular 1.3 в его выступлении «Angular Performance» на ng-conf . Тем не менее, пока нет никаких признаков этого, поэтому все выглядит не очень хорошо.

Завершение

Вы найдете Base.memoize, Base.unmemoizeи afterEveryDigestкод в проекте Github . В коде вы можете заметить, что memoizeслужба, используемая внутри, имеет собственную реализацию запоминания, а не делегирование для подчеркивания или lodash. Это было связано с тем, что реализация подчеркивания не обеспечивала способа пометки метода, и я не хотел вводить lodash в качестве зависимости.

Мемоизация не должна использоваться без разбора, потому что чем больше вы делаете это, тем больше вы подвергаете себя потенциальным проблемам, если случайно запомните метод, который не идемпотентен и / или имеет побочные эффекты. Мемоизация также может усложнить отладку, поскольку вы вводите дополнительный уровень косвенности. Тем не менее, несколько удачно размещенных заметок могут значительно повысить производительность вашего кода. Просто помните, что, как всегда, лучше профилировать и оптимизировать конкретный сценарий проблемы, чем использовать подход дробовика.