Статьи

Лучшие 5 ошибок разработчиков AngularJS: Часть 2. Злоупотребление $ watch

Это вторая часть в серии из пяти статей, в которой рассматриваются распространенные ошибки AngularJS. Напомним, что пять основных ошибок, которые я вижу, делают люди:

  1. Сильная зависимость от $ scope (без использования контроллера как)
  2. Злоупотребляя $ watch
  3. Чрезмерное использование трансляции $ и $ emit
  4. Взлом DOM
  5. Неспособность проверить

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

Злоупотребляя $ watch

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

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

<div ng-app="myApp">
    <div ng-controller="badCtrl">
        <select ng-options="g as g for g in genders"
                ng-model="selectedGender"></select>
        It's a {{genderText}}!
    </div>
</div>

Скрипт просто отслеживает два списка и следит за изменениями. Если пол меняется, свойство обновляется для отображения соответствующей метки.

(function (app) {
 
    var genders = ['Male', 'Female'],
        labels = ['boy', 'girl'];
 
    function BadController($scope) {
        $scope.genders = genders;
        $scope.selectedGender = genders[0];
        $scope.$watch('selectedGender', function () {
            $scope.genderText =
                $scope.selectedGender === genders[0]
                ? labels[0] : labels[1];
        });
    }

 
    app.controller('badCtrl', BadController);
 
})(angular.module('myApp', []));

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

watchtree1

Для производительности большую часть времени тратится на часы модели, а наименьшее количество времени на часы для свойства selectedGender мы добавили явно. Однако это время может возрасти и усложниться, как я скоро выясню. Можно ли даже «наблюдать» за изменениями, используя контроллер как ? Вот новый HTML:

<div ng-app="myApp">
    <div ng-controller="goodCtrl as ctrl">
        <select ng-options="g as g for g in ctrl.genders"
                ng-model="ctrl.selectedGender"></select>
        It's a {{ctrl.genderText}}!
    </div>
</div>

Вот дерево наблюдения при запуске нового примера:

watchtree2

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

В этом случае суммарные часы на плохом контроллере составляли в среднем около 5,264 мс служебных данных, в то время как объединенные часы на хорошем контроллере составляли в среднем около 4,312 мс служебных данных. Не похоже много? Второй подход в среднем улучшил 20% только для одного объекта. Рассмотрим контроллеры с несколькими часами, и в итоге вы увидите ощутимые различия во времени отклика вашего приложения. Это также тестирование в браузере; накладные расходы усиливаются, когда вы запускаете Angular на мобильном устройстве.

Говоря о мобильных устройствах: этот подход не только повышает производительность, но и снижает накладные расходы памяти, поскольку AngularJS не нужно отслеживать столько ссылок. Так как же я этого добился? Вот код для второго примера:

(function (app) {
 
    var genders = ['Male', 'Female'],

        labels = ['boy', 'girl']
 
    function GoodController() {
        this.genders = genders;
        this._selectedGender = genders[0];
        this.genderText = labels[0];
    }
 
    Object.defineProperty(GoodController.prototype,
        "selectedGender", {
        enumerable: true,
        configurable: false,
        get: function () {
            return this._selectedGender;
        },
        set: function (val) {
            if (val !== this._selectedGender) {
                this._selectedGender = val;
                this.genderText =
                    val === this.genders[0]
                    ? labels[0] : labels[1];
            }
        }
    });
 
    app.controller('goodCtrl', GoodController);
 
})(angular.module('myApp', []));

Instead of using a $watch I’m taking advantage of properties that were introduced in ECMAScript 5. The property manages the selection and when the selection changes, updates the corresponding label. The reason this works is because of the way Angular handles data-binding. Angular operates on a digest loop. When the model is mutated (i.e. by the user changing a selection), Angular automatically re-evaluates other properties to determine if the UI needs to be updated. Angular is already doing the work, and when you add a $watch you are simply plugging into the loop so you can react yourself.

The problem is that Angular must now hold a reference to your watch. If the model mutates, Angular will re-evaluate the expression in your watch and call your function if it changes. Of course, because your code might mutate the model further, Angular must then re-evaluate all expressions again to make sure there aren’t further dependent changes. This can exponentially add to the overhead of your application.

On the other hand, using properties makes it simple. When the user mutates the model by changing the gender, Angular will automatically re-evaluate properties like the gender text to see if it needs to update the UI. Because the gender selection updated the property, Angular will recognize the change and refresh the UI. In this approach, you allow Angular to do the work instead of having to plug into the digest loop yourself and add overhead to the entire process.

There are a few more lines of code with this approach, but even that can be simplified tremendously if you use a tool like TypeScript to create the property definitions. It also enables you to build pure JavaScript objects and even test for the updates without involving Angular. That keeps your tests simple and ensures they run quickly with little overhead (i.e. “Given controller when the selected gender changes to male then the gender text should be updated to boy.”)

There is one more advantage. This approach allows Angular to handle the watches, and Angular is great about managing those watches appropriately. If you add the $watch yourself, you are now responsible for de-registering the $watch when it is no longer needed. If you don’t, it will continue to add overhead.

The full source code is available in this jsFiddle of the two controllers running side-by-side.

Keep this technique in mind because I see another practice that adds overhead and doesn’t have to when it comes to communicating between controllers. Have you seen a model where a controller does a $watch and then uses $broadcast or $emit to notify other controllers something has changed? Once again, this approach forces you to depend on the $scope and adds another layer of complexity by relying on the messaging mechanism of $scope to communicate between controllers. In my next post, I’ll show you how to avoid this third common mistake.