AngularJS e o $scope.$apply


Se você já tem uma certa experiência com AngularJS, com certeza tem a mesma opinião que eu com relação ao binding: é algo mágico. Mas e quando por algum motivo obscuro ele não funciona, o que fazemos? Talvez você pode ter passado pelo mesmo problema que eu: callbacks de alguma instrução do Javascript pura (fora de algum módulo do AngularJS) que manipulam o $scope.

Nessa hora você precisará entender algumas coisas como, por exemplo, o funcionamento de tudo por baixo dos panos. Vou tentar passar um breve overview disso e uma possível solução do problema do callback aqui neste post. Confira!

Cenário

Se o que expliquei no início ainda não soou familiar para você, então vamos ilustrar o seguinte cenário: temos uma aplicação que sua função é contar até 10. Qual a primeira coisa que vem na cabeça? Utilizar setInterval(), fácil né? Aí que mora o perigo… vamos lá!

O controller CountdownController, apresentado abaixo, irá inicializar o contador no $scope para exibí-lo na View:

var timerApp = angular.module('timerApp', []);

timerApp.controller('CountdownController', ['$scope',
  function($scope) {
    $scope.count = 0;
  }
]);

E a view irá apresentar o contador, enquanto ele for menor que 10:

<div class="count" ng-controller="CountdownController">
  <h1 ng-show="count < 10"></h1>
</div>

Por enquanto nada é atualizado, que tal testar se o binding funciona como o esperado? Um botão para incrementar o contador é adicionado.

<div class="count" ng-controller="CountdownController">
  <h1 ng-show="count < 10"></h1>
  <button ng-click="increment()">Incrementar</button>
</div>

O método para fazer o incremento também é adicionado dentro do CountdownController.

$scope.increment = function() { $scope.count++; };

Se conferir o resultado no browser tudo estará funcionando e quando o atributo count chegar a 10 o contador irá sumir. Já que o count será incrementado a cada segundo, e não ao clicar no botão, é hora de remover o botão do HTML e utilizar o setInterval() para fazer o incremento.

setInterval($scope.increment, 1000);

Voltando para o browser, nada acontece, mesmo que a página fique aberta 5 minutos. Será que o código está sendo chamado? Vamos ver via console.log.

$scope.increment = function() {
  console.log($scope.count);
  $scope.count++;
};

O código está sim sendo chamado, mas nada acontece.

Funcionamento do AngularJS e o $scope.$apply

Para resumir bem rapidamente como o AngularJS funciona: ele permite que qualquer valor seja alvo de binding e no término de cada Javascript que definimos em lugares “gerenciados” por ele, ocorrerá uma verificação se determinado valor mudou, caso sim aplica as mudanças na tela.

Providers, directives e services fornecem essa praticidade por baixo dos panos, quando definimos nossas próprias callbacks Javascript aí a história muda já que fica sob nossa responsabilidade aplicar as mudanças, e é aí que entra o $scope.$apply().

Na verdade, o $scope.$apply() é uma API de alto nível que chamará uma outra função que é a responsável por fazer toda a mágica do binding: $scope.$digest(). A documentação do AngularJS é bem clara quando diz: nunca chame direto o $scope.$digest(), então prefira sempre usar o $scope.$apply(), e utilize com moderação!

Então para corrigir o código anterior basta simplesmente definir o método increment() como:

$scope.increment = function() {
  $scope.$apply(function() {
    $scope.count++;
  });
};

Mas, para esse caso, prefira usar o $timeout ao invés de setInterval()! Na verdade, se realmente precisar usar algo do Javascript, prefira o setTimeout() :D

Confira agora a solução com o $timeout:

timerApp.controller('CountdownController', ['$scope', '$timeout',  
  function($scope, $timeout) {
    $scope.count = 0;

    $scope.increment = function() {
      $scope.count++;
      if ($scope.count < 10) {
        $timeout($scope.increment, 1000);
      }
    };
    $scope.increment();
  }
]);

Exemplo final: http://jsfiddle.net/v6xa54pk/

No meu cenário precisei do $scope.apply em uma API de websockets puramente Javascript, mais especificamente a Stomp over Websockets, caso você esbarre com algum problema de binding não deixe de consultar a documentação do AngularJS e procurar por alternativas escritas especificamente para Angular!

Referências

Até a próxima.