AngularJS : $scope et data binding expliqués

Front-end 01/04/2026 21:00:00 Mezgani said
Angularjs Data-Binding Scope Javascript Frontend
AngularJS : $scope et data binding expliqués

Maîtrisez $scope, $rootScope et le two-way data binding AngularJS 1.x : ng-model, $watch, $apply et patterns avancés.

Introduction à $scope dans AngularJS

Dans AngularJS 1.x, $scope est l'objet fondamental qui fait le lien entre le contrôleur et la vue. C'est le modèle de données partagé qui permet à Angular de synchroniser automatiquement l'interface utilisateur avec la logique applicative.

Comprendre $scope est essentiel pour tout développeur AngularJS. C'est l'ancêtre direct des Signals Angular modernes et la base du data binding bidirectionnel qui a fait la réputation d'AngularJS.

A retenir : $scope est un objet JavaScript ordinaire enrichi par AngularJS. Il forme un arbre hiérarchique calqué sur la hiérarchie des contrôleurs dans le DOM.
// Déclaration d'un contrôleur AngularJS basique avec $scope
angular.module('monApp', [])
  .controller('MonController', function($scope) {
    // Propriété exposée à la vue via $scope
    $scope.message = 'Bonjour depuis AngularJS !';

    // Méthode accessible depuis le HTML
    $scope.saluer = function() {
      alert('Salut, ' + $scope.nom + ' !');
    };

    // Tableau de données lié à ng-repeat
    $scope.fruits = ['Pomme', 'Banane', 'Orange'];
  });

La vue HTML correspondante accède directement aux propriétés de $scope :

<!-- HTML lié au contrôleur MonController -->
<div ng-controller="MonController">
  <!-- Double accolades = interpolation de $scope.message -->
  <p>{{ message }}</p>

  <!-- ng-model crée un binding bidirectionnel avec $scope.nom -->
  <input ng-model="nom" placeholder="Votre nom">

  <!-- ng-click appelle la méthode $scope.saluer() -->
  <button ng-click="saluer()">Saluer</button>

  <!-- ng-repeat itère sur $scope.fruits -->
  <ul>
    <li ng-repeat="fruit in fruits">{{ fruit }}</li>
  </ul>
</div>
Note historique : AngularJS 1.x a atteint sa fin de vie officielle le 31 décembre 2021. Cependant, des milliers d'applications enterprise l'utilisent encore. Ce guide s'adresse aux développeurs qui maintiennent ou migrent ces applications.

$scope vs $rootScope

AngularJS crée un arbre de scopes. $rootScope est le scope racine, ancêtre de tous les autres. Chaque contrôleur obtient son propre $scope enfant, créé par prototypage JavaScript.

L'héritage prototypal des scopes

Un scope enfant hérite des propriétés de son scope parent via la chaîne prototypale JavaScript. C'est puissant mais source de bugs subtils, notamment avec les types primitifs.

// Exemple illustrant l'héritage prototypal des scopes
angular.module('appHierarchie', [])
  .controller('ParentCtrl', function($scope) {
    // Propriété primitive : source de problèmes d'héritage
    $scope.valeurPrimitive = 'parent';

    // Propriété objet : héritage correct et recommandé
    $scope.modele = {
      valeur: 'parent'
    };
  })
  .controller('EnfantCtrl', function($scope) {
    // ✅ Bonne pratique : modifier via l'objet intermédiaire
    $scope.modele.valeur = 'enfant modifié';

    // ❌ Mauvaise pratique : crée une nouvelle propriété locale
    // $scope.valeurPrimitive = 'enfant'; // ne modifie PAS le parent !
  });
<!-- Démonstration de la hiérarchie de scopes -->
<div ng-controller="ParentCtrl">
  <p>Parent : {{ modele.valeur }}</p>

  <div ng-controller="EnfantCtrl">
    <!-- Hérite et modifie le même objet parent -->
    <p>Enfant : {{ modele.valeur }}</p>
    <input ng-model="modele.valeur">
  </div>
</div>

Quand utiliser $rootScope

$rootScope est accessible partout dans l'application. Il est tentant de l'utiliser comme store global, mais cette pratique est déconseillée car elle crée des couplages forts. Préférez les services pour partager des données globales.

// Utilisation raisonnée de $rootScope
angular.module('monApp')
  .controller('AppCtrl', function($rootScope, AuthService) {
    // Stocker l'état d'authentification global (acceptable)
    $rootScope.utilisateurConnecte = AuthService.getUser();

    // Écouter les changements de route pour le titre de page
    $rootScope.$on('$routeChangeSuccess', function(event, current) {
      // Met à jour le titre depuis les données de route
      $rootScope.titrePage = current.$$route.title || 'Mon App';
    });
  });
Règle d'or : Limitez l'usage de $rootScope aux données vraiment globales (utilisateur connecté, thème). Pour tout le reste, utilisez des services AngularJS.

Scope isolé dans les directives

Les directives peuvent créer un scope totalement isolé, sans héritage prototypal. C'est le comportement recommandé pour les composants réutilisables.

// Directive avec scope isolé
angular.module('monApp')
  .directive('monComposant', function() {
    return {
      restrict: 'E',
      // scope: true  → scope enfant avec héritage
      // scope: false → partage le scope parent
      scope: {
        // Binding par valeur (one-way, depuis l'attribut HTML)
        titre: '@',
        // Binding bidirectionnel (two-way)
        donnees: '=',
        // Binding de fonction (expression)
        onSave: '&'
      },
      template: '<div><h3>{{titre}}</h3></div>'
    };
  });

Le two-way data binding

Le data binding bidirectionnel (two-way binding) est la fonctionnalité phare d'AngularJS. Il synchronise automatiquement la vue et le modèle dans les deux sens : toute modification du modèle met à jour la vue, et toute interaction utilisateur met à jour le modèle.

Comment fonctionne le binding unidirectionnel

// Binding unidirectionnel : du modèle vers la vue uniquement
// Utilisation de {{ }} ou ng-bind
angular.module('monApp')
  .controller('BindingCtrl', function($scope) {
    // Propriété qui s'affiche dans la vue (lecture seule depuis la vue)
    $scope.compteur = 0;

    // Méthode pour incrémenter (modifie le modèle, la vue se met à jour)
    $scope.incrementer = function() {
      $scope.compteur++;
    };
  });
<!-- Binding unidirectionnel vers la vue -->
<div ng-controller="BindingCtrl">
  <!-- {{ compteur }} lit $scope.compteur mais ne le modifie pas -->
  <p>Compteur : {{ compteur }}</p>
  <button ng-click="incrementer()">+1</button>
</div>

Le two-way binding avec ng-model

// Démonstration complète du two-way binding
angular.module('monApp')
  .controller('TwoWayCtrl', function($scope) {
    // Modèle initialisé avec des valeurs par défaut
    $scope.profil = {
      nom: 'Alice',
      age: 30,
      newsletter: true
    };

    // Méthode affichant les données du modèle en temps réel
    $scope.afficherProfil = function() {
      // Ces valeurs reflètent exactement ce que l'utilisateur a saisi
      console.log('Nom :', $scope.profil.nom);
      console.log('Age :', $scope.profil.age);
      console.log('Newsletter :', $scope.profil.newsletter);
    };
  });
<!-- Formulaire avec two-way binding complet -->
<form ng-controller="TwoWayCtrl" ng-submit="afficherProfil()">
  <!-- ng-model lie l'input à $scope.profil.nom dans les deux sens -->
  <input type="text" ng-model="profil.nom" placeholder="Nom">

  <!-- Affichage en temps réel sans bouton Submit -->
  <p>Bonjour {{ profil.nom }} !</p>

  <input type="number" ng-model="profil.age">
  <input type="checkbox" ng-model="profil.newsletter"> Newsletter

  <button type="submit">Enregistrer</button>
</form>
Performance : Le two-way binding surveille tous les bindings à chaque cycle $digest. Sur des listes de plus de 2000 éléments, cela peut devenir lent. Utilisez bindOnce (::valeur) pour les données statiques.
<!-- Binding one-time avec :: (AngularJS 1.3+) -->
<!-- Les données sont liées une seule fois et plus surveillées -->
<p>{{ ::utilisateur.nom }}</p>

<!-- Très utile pour les listes longues de données statiques -->
<li ng-repeat="item in ::listeStatique">{{ ::item.titre }}</li>

ng-model en profondeur

ng-model est la directive centrale du data binding bidirectionnel. Elle ne fait pas que lier une valeur : elle gère la validation, les états CSS, les parsers/formatters et l'intégration avec ngForm.

Les états de validation ng-model

<!-- ng-model expose des états de validation dans le scope -->
<form name="monFormulaire">
  <input
    type="email"
    name="email"
    ng-model="utilisateur.email"
    required
    ng-minlength="5"
  >

  <!-- Accès aux états de validation via monFormulaire.email -->
  <p ng-show="monFormulaire.email.$invalid && monFormulaire.email.$touched">
    Email invalide
  </p>

  <!-- Classes CSS automatiques ajoutées par ng-model -->
  <!-- .ng-valid, .ng-invalid, .ng-pristine, .ng-dirty, .ng-touched -->
</form>

Parsers et formatters personnalisés

La directive ng-model expose un contrôleur ngModel qui permet d'ajouter des transformations via $parsers (vue → modèle) et $formatters (modèle → vue).

// Directive personnalisant ng-model avec parsers/formatters
angular.module('monApp')
  .directive('majuscule', function() {
    return {
      // Nécessite le contrôleur ngModel du champ hôte
      require: 'ngModel',
      link: function(scope, element, attrs, ngModel) {

        // Parser : transforme la valeur saisie avant de la stocker
        // Côté vue → modèle : convertit en MAJUSCULES dans le modèle
        ngModel.$parsers.push(function(valeurVue) {
          return valeurVue ? valeurVue.toUpperCase() : valeurVue;
        });

        // Formatter : transforme la valeur du modèle pour l'affichage
        // Côté modèle → vue : affiche en minuscules dans l'input
        ngModel.$formatters.push(function(valeurModele) {
          return valeurModele ? valeurModele.toLowerCase() : valeurModele;
        });
      }
    };
  });
<!-- Utilisation de la directive majuscule -->
<!-- L'utilisateur tape en minuscules, le modèle stocke en MAJUSCULES -->
<input type="text" ng-model="texte" majuscule>
<p>Modèle : {{ texte }}</p>
A retenir : Les $parsers et $formatters sont exécutés dans l'ordre d'ajout. Vous pouvez en chaîner plusieurs pour des transformations complexes (validation + formatage + masquage).

$watch, $apply et le cycle $digest

Le mécanisme central du data binding AngularJS est le cycle $digest. C'est lui qui détecte les changements et met à jour la vue. Comprendre ce cycle est indispensable pour éviter les bugs et les problèmes de performance.

Le cycle $digest expliqué

À chaque interaction (clic, saisie, requête HTTP), AngularJS lance un cycle $digest qui parcourt tous les watchers enregistrés et vérifie si leurs valeurs ont changé. Si oui, il met à jour la vue et relance un nouveau cycle jusqu'à stabilisation (max 10 cycles).

// $watch : surveille une expression et réagit aux changements
angular.module('monApp')
  .controller('WatchCtrl', function($scope) {
    $scope.prix = 100;
    $scope.quantite = 2;
    $scope.total = 0;

    // $watch simple : surveille une propriété primitive
    // Paramètres : expression, callback(nouvelleValeur, ancienneValeur)
    $scope.$watch('prix', function(nouveauPrix, ancienPrix) {
      console.log('Prix changé de', ancienPrix, 'à', nouveauPrix);
      $scope.total = $scope.prix * $scope.quantite;
    });

    // $watch sur une expression calculée
    $scope.$watch(
      function() {
        // Fonction d'expression : retourne la valeur à surveiller
        return $scope.prix * $scope.quantite;
      },
      function(nouveauTotal) {
        // Callback exécuté quand le produit prix*quantité change
        $scope.total = nouveauTotal;
      }
    );

    // $watchCollection : surveille les éléments d'un tableau/objet
    // Plus léger que $watch avec deep:true
    $scope.panier = [];
    $scope.$watchCollection('panier', function(nouveauPanier) {
      // Déclenché quand un élément est ajouté ou supprimé
      $scope.nbArticles = nouveauPanier.length;
    });
  });

$watch profond vs $watchCollection

// $watch avec deep watching (coûteux en performances)
$scope.$watch(
  'objetComplexe',
  function(nouvelObjet, ancienObjet) {
    // Déclenché si N'IMPORTE QUELLE propriété imbriquée change
    console.log('Objet modifié en profondeur');
  },
  true // true = deep watch (compare récursivement)
);

// $watchCollection (compromis performance/réactivité)
// Surveille les ajouts/suppressions mais pas les mutations internes
$scope.$watchCollection('listeObjets', function(nouvelleListe) {
  $scope.compter = nouvelleListe.length;
});

$apply : intégrer du code externe

Quand vous utilisez du code externe à AngularJS (setTimeout, jQuery, WebSocket…), les changements de $scope ne déclenchent pas automatiquement le cycle $digest. Vous devez appeler $apply manuellement.

// Problème : setTimeout est hors du contexte AngularJS
angular.module('monApp')
  .controller('ApplyCtrl', function($scope, $timeout) {
    $scope.message = 'En attente...';

    // ❌ MAUVAIS : Angular ne détecte pas ce changement
    setTimeout(function() {
      $scope.message = 'Terminé !'; // Vue non mise à jour
    }, 2000);

    // ✅ CORRECT option 1 : $apply force le cycle $digest
    setTimeout(function() {
      $scope.$apply(function() {
        $scope.message = 'Terminé !'; // Vue mise à jour
      });
    }, 2000);

    // ✅ CORRECT option 2 : $timeout (wrappeur AngularJS de setTimeout)
    // Gère automatiquement $apply, préférable dans tous les cas
    $timeout(function() {
      $scope.message = 'Terminé avec $timeout !';
    }, 2000);
  });

$evalAsync vs $apply

// $evalAsync : planifie l'exécution dans le prochain cycle $digest
// Évite les erreurs "$apply already in progress"
$scope.$evalAsync(function() {
  // Exécuté dans le prochain cycle $digest, pas immédiatement
  $scope.resultat = calculerQuelqueChose();
});

// Nettoyer un watcher quand il n'est plus nécessaire
// $watch retourne une fonction de désinscription
var stopWatch = $scope.$watch('donnees', function(val) {
  if (val === 'valeurCible') {
    console.log('Cible atteinte, arrêt de la surveillance');
    stopWatch(); // Appel de la fonction retournée pour stopper
  }
});
Performance : Chaque $watch est exécuté à chaque cycle $digest. Une application avec 2000+ watchers peut devenir lente. Désabonnez-vous toujours avec la fonction retournée par $watch dans le callback $destroy.

$broadcast, $emit et $on

AngularJS propose un système d'événements pour la communication entre scopes non directement liés. Ces méthodes permettent une communication découplée entre contrôleurs.

// Système d'événements inter-scopes AngularJS
angular.module('monApp')
  .controller('ParentCtrl', function($scope) {
    // $broadcast : diffuse l'événement VERS LES ENFANTS (descendant)
    $scope.notifierEnfants = function(message) {
      $scope.$broadcast('evenement:message', {
        texte: message,
        timestamp: new Date()
      });
    };

    // $on : écoute un événement sur ce scope
    $scope.$on('enfant:reponse', function(event, data) {
      // event.targetScope : scope qui a émis l'événement
      console.log('Réponse reçue :', data.texte);
    });
  })

  .controller('EnfantCtrl', function($scope) {
    // Écoute l'événement diffusé par le parent
    $scope.$on('evenement:message', function(event, data) {
      console.log('Message reçu :', data.texte);
      // event.stopPropagation() stoppe la propagation vers d'autres enfants

      // $emit : remonte l'événement VERS LES PARENTS (ascendant)
      $scope.$emit('enfant:reponse', { texte: 'Message bien reçu !' });
    });
  });

Pattern service pour éviter $broadcast/$emit excessif

// Meilleure alternative : service avec callbacks
angular.module('monApp')
  .service('EventBus', function() {
    var listeners = {};

    // Abonnement à un événement nommé
    this.on = function(event, callback) {
      if (!listeners[event]) listeners[event] = [];
      listeners[event].push(callback);
    };

    // Déclenchement de l'événement avec données
    this.emit = function(event, data) {
      if (listeners[event]) {
        listeners[event].forEach(function(cb) { cb(data); });
      }
    };
  })
  .controller('EmetteurCtrl', function($scope, EventBus) {
    $scope.envoyer = function() {
      // Émet via le service, sans traverser l'arbre de scopes
      EventBus.emit('donnees:mises-a-jour', { valeur: 42 });
    };
  })
  .controller('ReceveurCtrl', function($scope, EventBus) {
    EventBus.on('donnees:mises-a-jour', function(data) {
      // Reçoit les données sans couplage scope
      $scope.$apply(function() {
        $scope.valeur = data.valeur;
      });
    });
  });
A retenir : $broadcast descend dans l'arbre de scopes (coûteux), $emit remonte vers la racine. Préférez les services ou les patterns d'observateurs pour les applications complexes.

Patterns avancés avec $scope

Au-delà des usages basiques, $scope supporte des patterns avancés pour gérer des applications complexes de façon maintenable.

Controller As : alternative à $scope explicite

Depuis AngularJS 1.2, la syntaxe Controller As permet d'utiliser this dans le contrôleur au lieu de $scope, rendant le code plus lisible et plus proche des classes ES6.

// Pattern "Controller As" : utilise this au lieu de $scope
angular.module('monApp')
  .controller('ProfilCtrl', function() {
    // 'this' fait référence à l'instance du contrôleur
    // Exposé dans la vue sous l'alias 'vm' (ViewModel)
    var vm = this;

    vm.titre = 'Mon Profil';
    vm.utilisateur = { nom: 'Bob', age: 25 };

    vm.sauvegarder = function() {
      console.log('Sauvegarde de', vm.utilisateur.nom);
    };
  });
<!-- Utilisation avec ng-controller et l'alias "as vm" -->
<div ng-controller="ProfilCtrl as vm">
  <h1>{{ vm.titre }}</h1>
  <input ng-model="vm.utilisateur.nom">
  <button ng-click="vm.sauvegarder()">Sauvegarder</button>
</div>

Nettoyage des ressources avec $destroy

// Nettoyage propre des ressources lors de la destruction du scope
angular.module('monApp')
  .controller('TimerCtrl', function($scope, $interval) {
    var intervalle;

    // Démarre un timer toutes les secondes
    $scope.demarrer = function() {
      intervalle = $interval(function() {
        $scope.compteur = ($scope.compteur || 0) + 1;
      }, 1000);
    };

    // Nettoyage OBLIGATOIRE : $destroy est déclenché quand
    // le contrôleur est détruit (navigation hors de la vue)
    $scope.$on('$destroy', function() {
      // Stoppe l'intervalle pour éviter les memory leaks
      if (intervalle) {
        $interval.cancel(intervalle);
      }
    });
  });

Comparaison avec Angular moderne

Angular 2+ a radicalement changé l'approche du data binding. Comprendre ces différences est essentiel pour envisager une migration.

Concept AngularJS 1.x Angular 17+
Data binding $scope + dirty checking Zone.js + Signals
Two-way binding ng-model [(ngModel)]
Détection changements $digest cycle (polling) Change Detection + Signals
Communication $broadcast / $emit @Input/@Output / EventEmitter
État global $rootScope / Services Services / NgRx / Signals
Watchers $watch / $watchCollection effect() avec Signals
// Équivalent Angular moderne du two-way binding AngularJS
// AngularJS : ng-model sur $scope
// Angular moderne : [(ngModel)] avec FormsModule ou signal()

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-profil',
  template: `
    <!-- Two-way binding avec signal via modèle de formulaire -->
    <input [(ngModel)]="nom">

    <!-- Affichage réactif : mis à jour automatiquement -->
    <p>Bonjour {{ nom() }} !</p>

    <!-- computed() = équivalent de $watch avec expression calculée -->
    <p>Longueur : {{ longueurNom() }}</p>
  `
})
export class ProfilComponent {
  // signal() remplace $scope.nom
  nom = signal('Alice');

  // computed() remplace $watch avec expression
  longueurNom = computed(() => this.nom().length);
}

Conclusion

$scope et le data binding bidirectionnel sont le coeur d'AngularJS 1.x. Le cycle $digest, les $watch, $apply, et le système d'événements $broadcast/$emit forment un écosystème cohérent qui a révolutionné le développement front-end à son époque.

Si vous maintenez une application AngularJS, maîtriser ces mécanismes vous permettra de corriger des bugs subtils et d'optimiser les performances. Si vous envisagez une migration vers Angular moderne, comprendre $scope vous aidera à saisir pourquoi les Signals et la détection de changements unidirectionnelle sont une amélioration majeure.

À retenir : Utilisez toujours des objets (pas des primitives) dans vos scopes pour éviter les bugs d'héritage prototypal. Nettoyez vos watchers dans $destroy pour éviter les fuites mémoire. Préférez la syntaxe Controller As à $scope explicite pour un code plus maintenable.

Partager