Дросселирование ввода на AngularJS с помощью debounce

Original author: Jeremy Likness
  • Translation
  • Tutorial
Существуют различные сценарии для использования дросселирования (throttling) ввода так, что пересчет значений фильтра будет происходить не каждый раз при изменении значения, а реже. Более подходящий термин — это «устранение дребезга» (debounce), так как в сущности вы ожидаете стабилизации значения на каком-либо постоянном уровне перед вызовом функции, чтобы не вызвать «дребезг» постоянных запросов к серверу. Канонический случай такого рода — это пользователь, вводящий текст в поле ввода для фильтрации списка элементов. Если логика вашего фильтра включает некоторый оверхед (например, фильтрация происходит через REST-ресурс, который выполняет запрос на базе данных бекенда), то вы точно не захотите все время перезапускать и перезагружать результаты запроса в то время, как пользователь пишет текст в поле. Более правильным будет вместо этого подождать, пока он закончит, и уже после этого выполнить запрос один раз.

Простое решение этой проблемы находится тут: jsfiddle.net/nZdgm

Представим, что у вас есть список ($scope.list), который вы публикуете как фильтрованный список ($scope.filteredList) на основе чего-либо содержащего текст из поля $scope.searchText. Ваша форма выглядела бы примерно следующим образом (не обращайте внимание на чекбокс throttle пока что):

<div data-ng-app='App'>
    <div data-ng-controller="MyCtrl">
        <form>
            <label for="searchText">Search Text:</label>
            <input data-ng-model="searchText" name="searchText" />            

            <input type="checkbox" data-ng-model="throttle"> Throttle            

            <label>You typed:</label> <span>{{searchText}}</span>
        </form>
        <ul><li data-ng-repeat="item in filteredList">{{item}}</li></ul>
    </div>
</div>


Типичный сценарий — наблюдать за полем поиска и реагировать мгновенно. Метод фильтрации:

var filterAction = function($scope) {
    if (_.isEmpty($scope.searchText)) {
        $scope.filteredList = $scope.list;
        return;
    }
    var searchText = $scope.searchText.toLowerCase();
    $scope.filteredList = _.filter($scope.list, function(item) {
        return item.indexOf(searchText) !== -1;
    });
};


Контроллер устанавливает $watch примерно следующим образом:

$scope.$watch('searchText', function(){filterAction($scope);});


Такой подход будет запускать фильтрацию каждый раз при вводе в поле. Чтобы устаканить ситуацию, используем встроенную в underscore.js функцию debounce. Функция довольна проста: передайте ей функцию для выполнения и время в миллисекундах. Это задержит реальный вызов функции до тех пор, пока с момента последней попытки ее вызова не пройдет указанное время. Другими словами, при задержке в 1 секунду (которую я использую в этом примере для утрирования эффекта) и непрерывном потоке вызовов функции во время быстрого ввода текста в поле, реальная функция не будет вызвана до тех пор, пока я не прекращу печатать и с этого момента не пройдет 1 секунда.

Может возникнуть искушение сделать простой debounce примерно таким образом:

var filterThrottled = _.debounce(filterAction, 1000);
$scope.$watch('searchText', function(){filterThrottled($scope);});


Однако, тут есть проблема. Такой подход использует таймер, который срабатывает вне цикла $digest, поэтому это в итоге никак не отразится на UI, ведь Angular не знает о случившихся изменениях. Вместо этого вы должны обернуть вызов в $apply:

var filterDelayed = function($scope) {
    $scope.$apply(function(){filterAction($scope);});
};


После этого вы можете установить $watch и отреагировать, как только ввод остановится:

var filterThrottled = _.debounce(filterDelayed, 1000);
$scope.$watch('searchText', function(){filterThrottled($scope);});


Конечно, полноценный пример тут должен включать в себя и throttling, так чтобы можно было увидеть разницу между «мгновенной» фильтрацией и отложенной. Фидл на случай можно посмотреть здесь: jsfiddle.net/nZdgm

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 16

    +1
    От переводчика: если вам понравилась статья, то лучшая благодарность — зеленая стрелочка в профиле. Спасибо :)

    От читателя: клянчить карму некрасиво. Пожалуйста.
      +1
      Ребята из прекрасной организации Rx, сделали расширение под Angular. Теперь возможности Rx(Reactive programming) есть и в Angular, при том, что мне очень понравилось, что можно использовать ng-click, а в коде обрабатывать и пропускать через фильтры, мапы, редьюсы.
      Вот пример throttle github.com/Reactive-Extensions/rx.angular.js/blob/master/examples/%24toObservable/app.js
        0
        Строго говоря, Rx — не организация, а «reactive extensions». А вот организация, которая стоит за реализацией «реактивного» подохода на JavaScript — Microsoft. Кроме того, этот подход уже доступен во множестве других библиотек из js (jquery, angular, node.js и т.д. — github.com/Reactive-Extensions).

        Плюс известная Netflix развивает RxJava, а на гитхабе RxJS можно найти порты для ruby, c#, php, python и т.д.
          0
          Я не стал дальше расписывать что это и как) Думаю для этого лучше сделать отдельную статью =) Мэтью радует новыми фичами.
        +2
        По оптимизации я бы добавил вот ещё что: если приложение начинает разрастаться (сотни вотчеров), лучше автокомплит выделить в отдельный компонент (директиву) со своим скоупом и использовать $scope.$digest() (который обновит только скоуп компонента) вместо $scope.$apply(), который обновляет состояние всех скоупов, начиная с корня ($rootScope).
          0
          ИМХО, самый простой вариант написать свой «debounce» только с использование $timeout и не будет нужды вызывать $apply
            0
            $timeout это обычная обёртка над setTimeout (+Promise), коллбэк которого всё-равно вызывает $apply
              0
              Да и по этому вызывать $apply руками нужды не будет. Я именно об этом и говорил.
            +1
            Когда делал поле для поиска, написал директиву, откладывающую срабатывание по вводу

            app.directive('changeTimeout', function() {
                    return {
                        require: 'ngModel',
                        link: function(scope, elem, attr, ctrl) {
                            if (!attr.ngChange) {
                                throw new TypeError('ng-change directive not present');
                            }
            
                            angular.forEach(ctrl.$viewChangeListeners, function(listener, index) {
                                ctrl.$viewChangeListeners[index] = _.debounce(function() {
                                    scope.$apply(attr.ngChange);
                                }, attr.changeTimeout || 0)
                            });
                        }
                    }
                });
            
              0
              что делает свойство $viewChangeListeners?
                0
                Туда записываются колбэки из ng-change
                  0
                  только из ng-change или для других ивентов тоже?
                    0
                    Всё что вызывается при изменении видимых данных. ng-keyup и т. п. тоже относится
              0
              x
                0
                В своем проекте написали такой декоратор:

                angular.module('ngDebounce', []).config ['$provide', ($provide) ->
                    $provide.decorator '$timeout', ['$delegate', '$q', ($delegate, $q) ->
                        $delegate.debounce = (func, wait, immediate) ->
                            timeout = undefined
                            deferred = $q.defer()
                            (->
                                context = this
                                args = arguments
                                later = ->
                                    timeout = null
                                    unless immediate
                                        deferred.resolve func.apply(context, args)
                                        deferred = $q.defer()
                
                                callNow = immediate and not timeout
                                $delegate.cancel timeout  if timeout
                                timeout = $delegate(later, wait)
                                if callNow
                                    deferred.resolve func.apply(context, args)
                                    deferred = $q.defer()
                                deferred.promise)
                        return $delegate
                    ]
                ]
                
                  0
                  Возможно, кому-то будет интересно почитать про debounce на pure-JS, plutov.by/post/fn_delay

                  Only users with full accounts can post comments. Log in, please.