Développement web et mobile

Migrer d'AngularJS vers Angular : stratégies, séquencement et risques

Avancé12 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

AngularJS est arrivé en fin de vie en 2021 : toute application qui l'utilise encore tourne sans correctifs sur un framework que plus personne ne maintient. Ce guide compare honnêtement les deux architectures, montre comment préparer une base de code legacy pour que n'importe quelle voie de migration coûte moins cher, met en balance le démarrage hybride, la livraison côte à côte et la réécriture, puis fait correspondre chaque concept AngularJS à son équivalent moderne.

Ce que vous allez apprendre

  • Justifier une migration par le risque, le coût et la maintenabilité plutôt que par la mode
  • Expliquer les différences d'architecture entre des scopes pilotés par une boucle de digest et des composants à base de signals
  • Préparer une base de code AngularJS pour que la migration à venir soit plus petite et plus sûre
  • Choisir entre démarrage hybride, livraison côte à côte et réécriture selon votre situation
  • Convertir contrôleurs, directives, filtres, services et routes vers leurs équivalents modernes
  • Garder l'application livrable et testée pendant toute une transition de plusieurs mois

Toute application AngularJS encore en production tourne sur un framework qui a cessé de recevoir des correctifs le dernier jour de 2021. Rien de spectaculaire ne s'est produit ce jour-là — le code a continué de fonctionner — mais le profil de risque a changé pour de bon. La prochaine vulnérabilité découverte dans le framework, ou dans la longue traîne de directives abandonnées dont dépendent la plupart de ces applications, ne sera jamais corrigée ; les navigateurs continuent d'évoluer en dessous, et le vivier de développeurs prêts à y travailler se réduit chaque année.

Voilà l'argument en faveur de la migration, et c'est un argument de risque, pas d'esthétique. La nuance compte, car les migrations sont validées par des personnes qui ont besoin d'une meilleure raison que « le nouveau est plus agréable ». La suite traite du concret : en quoi les deux architectures diffèrent réellement, quoi faire sur la base de code legacy avant de commencer, comment choisir une stratégie de livraison et comment continuer à livrer des fonctionnalités pendant toute la transition.

Deux frameworks au même nom, aux fondations différentes

Le choix du nom est malheureux : AngularJS et Angular partagent une marque et presque rien d'autre. Savoir où les modèles divergent vous indique quelles parties d'un portage sont mécaniques et lesquelles demandent de repenser la conception.

AspectAngularJSAngular
Unité d'interfaceContrôleur plus template, puis .component()Classe avec un décorateur, standalone par défaut
Conteneur d'état$scope, avec héritage prototypalChamps de classe, le plus souvent des signals
Mécanisme de mise à jourBoucle de digest qui réévalue chaque watcher jusqu'à stabilisationDétection de changements ciblée, pilotée par les inputs, les événements et les signals
Flux de donnéesBinding bidirectionnel presque partoutUnidirectionnel vers le bas, événements vers le haut
Injection de dépendancesPar nom sous forme de chaîne, avec annotation nécessaire pour la minificationPar type ou par token, résolue via un injecteur hiérarchique
LangageJavaScript ES5, puis ES6TypeScript, templates fortement typés
ModularitéRegistres angular.module et espace de noms globalModules ES et graphe d'imports du compilateur
Unité de compositionDirectives avec une fonction linkComposants et directives d'attribut
OutillageConcaténation, Grunt ou GulpCLI, compilation ahead-of-time, tree shaking

C'est la boucle de digest qui façonne le plus une base de code legacy. Comme AngularJS ne pouvait pas savoir ce qui avait changé, il réévaluait tous les watchers enregistrés, encore et encore, jusqu'à ce que les valeurs se stabilisent, avec un plafond de dix passes. D'où la chute de performances bien connue au-delà de quelques milliers de bindings, les erreurs $apply already in progress quand du code s'exécutait à l'insu du framework, et l'habitude d'ajouter des $scope.$watch pour recoller les morceaux. Angular moderne inverse la logique : il sait précisément quels bindings dépendent de quel état, si bien qu'une mise à jour ne touche que les composants concernés. Toute logique de votre application qui n'existe que pour compenser la boucle de digest peut tout simplement être supprimée — encore faut-il d'abord la reconnaître.

La seconde différence structurelle est l'héritage de scopes. Un scope enfant héritait de son parent : un template pouvait lire une valeur définie trois niveaux plus haut et — prototypes JavaScript obligent — écrire une valeur primitive sur l'enfant masquait silencieusement celle du parent au lieu de la mettre à jour. Cela a engendré toute une famille de bugs et une règle empirique (« toujours mettre un point dans votre ng-model »). Le nouveau modèle n'a aucun équivalent : les données arrivent par des inputs explicites, et rien n'est implicitement visible depuis un ancêtre.

Évaluer avant de planifier

Commencez par un inventaire, pas par une preuve de concept. Comptez les écrans et les définitions de routes. Listez chaque directive, module et plugin jQuery tiers, et signalez ceux qui sont abandonnés. Mesurez la part de logique qui vit dans les contrôleurs par rapport aux services : la logique déjà dans des services se porte en grande partie, celle des contrôleurs rarement. Repérez l'état partagé : authentification, utilisateur courant, permissions, panier, cache en mémoire. C'est l'état partagé qui détermine si l'ancienne et la nouvelle application peuvent tourner côte à côte ou doivent être imbriquées.

Soyez ensuite honnête sur la destination. Une migration est une rare occasion de corriger une architecture, et tout autant une rare occasion de faire dérailler un projet en voulant tout corriger d'un coup. Décidez explicitement du périmètre : le framework seul, le framework plus le design system, ou le framework plus un nouveau contrat backend. Chaque ajout multiplie le risque. La plupart des migrations réussies figent le design visuel et le contrat d'API, afin que toute différence de comportement soit sans ambiguïté un défaut.

Préparer la base de code legacy

Chaque heure passée ici réduit la conversion qui suit, et aucune n'est perdue, même si la migration est repoussée.

Passer des contrôleurs aux composants. AngularJS 1.5 a introduit .component(), volontairement proche du modèle moderne : un scope isolé, des bindings d'entrée unidirectionnels avec <, des callbacks de sortie avec &, et une classe de contrôleur utilisée via controllerAs au lieu de $scope. Donner cette forme à un écran est l'étape de préparation la plus rentable, car le composant obtenu se transpose presque ligne à ligne vers son remplaçant.

angular.module('orders').component('orderRow', {
  bindings: { order: '<', onRemove: '&' },     // one-way in, callback out
  controllerAs: 'vm',
  template: `
    <tr>
      <td>{{ vm.order.reference }}</td>
      <td><button ng-click="vm.onRemove({ id: vm.order.id })">Remove</button></td>
    </tr>`,
  controller: function () { /* no $scope */ },
});

Éliminer la dépendance à $scope. Remplacez $scope.$watch par des appels de méthode explicites, $scope.$emit et $scope.$broadcast par un service qui porte l'état, et toute dépendance à l'héritage de scopes par un binding explicite. Les bus d'événements qui traversent les scopes sont ce qu'il y a de plus difficile à porter, parce que les dépendances y sont invisibles.

Adopter les modules ES et un build. Remplacez les variables globales et l'ordre de concaténation par import/export, pour que le graphe de dépendances devienne explicite et analysable par l'outillage. C'est aussi à ce moment qu'il devient possible d'introduire TypeScript dans l'ancienne base de code, fichier par fichier, avec allowJs activé : les types écrits maintenant survivront à la migration.

Uniformiser la structure. Un composant, service ou filtre par fichier ; des dossiers par fonctionnalité plutôt que par type ; un nommage cohérent ; une annotation DI explicite partout, pour que rien ne dépende de noms de paramètres censés survivre à la minification.

Retirer jQuery et les plugins abandonnés. Deux systèmes qui se disputent le DOM d'une même page sont une source récurrente de difficultés pendant une migration, car un re-rendu du framework écrase silencieusement les modifications faites par l'autre bibliothèque. L'approche par étapes du guide jQuery s'applique directement.

Ajouter des tests de caractérisation. Impossible de vérifier un portage par rapport à une spécification que personne n'a écrite. Des tests end-to-end couvrant les parcours critiques, écrits sur l'application actuelle avant d'y toucher, sont ce qui transforme « ça a l'air pareil » en preuve. Exécutez-les à chaque commit dans le pipeline décrit dans intégration continue, et consultez stratégie d'automatisation des tests pour que cette suite reste abordable.

Choisir une stratégie de livraison

Démarrage hybride

Le package @angular/upgrade démarre les deux frameworks dans une même page. UpgradeModule lance AngularJS depuis une application Angular ; downgradeComponent enveloppe un composant moderne pour qu'un ancien template puisse l'utiliser ; downgradeInjectable et UpgradeModule.$injector.get permettent aux services de passer dans les deux sens. On migre d'abord les composants feuilles, puis leurs parents, puis le routage.

L'intérêt, c'est qu'un même écran peut contenir les deux : pas de frontière nette, pas de gestion de session dupliquée. Les coûts sont pourtant bien réels. Les deux frameworks se retrouvent dans le bundle, si bien que l'application grossit avant de maigrir. Deux systèmes de détection de changements interagissent, et leurs jointures — un ancien digest déclenché par un nouvel événement, ou l'inverse — produisent des bugs de timing difficiles à diagnostiquer. Le routage doit appartenir à un seul des deux côtés, ce qui implique généralement une phase intermédiaire bancale. Surtout, le package d'upgrade n'a reçu aucun investissement notable depuis des années et reste lié aux parties les plus anciennes du framework : vous devez vérifier qu'il fonctionne avec la version d'Angular visée avant de bâtir un plan dessus. Il convient le mieux à une grosse application très imbriquée, avec beaucoup d'état partagé, migrée par une équipe qui peut se permettre de vivre des mois en mode hybride.

Livraison côte à côte

Ici, les deux applications restent entièrement séparées, et un reverse proxy ou un routeur en périphérie décide laquelle sert chaque chemin : /orders vers la nouvelle application, tout le reste vers l'ancienne. Aucune interopérabilité entre frameworks, ce qui élimine toute la catégorie des bugs propres à l'hybride. Chaque application est buildée, testée et déployée indépendamment, et l'ancien bundle disparaît le jour où la dernière route bascule.

Les coûts se déplacent ailleurs. L'état de session et d'authentification doit être partagé, ce qui signifie en pratique un cookie ou un token compris par les deux applications et un flux de single sign-on qui survit au passage de l'une à l'autre. Naviguer entre les deux provoque un rechargement complet de page : l'habillage commun doit donc être dupliqué ou extrait, et la transition reste visible à moins que le design ne l'absorbe. Un peu de code dupliqué est inévitable pendant la période de chevauchement. Pour les applications dont les écrans sont réellement indépendants — la plupart des back-offices et des outils d'administration —, c'est généralement la voie la moins risquée, et c'est le même raisonnement qui conduit plus largement les équipes vers les architectures micro-frontend.

La réécriture

Parfois, la bonne réponse est de repartir de zéro : quand l'application est petite, quand les besoins ont tellement changé que porter le comportement actuel n'a plus de sens, ou quand la base de code n'a ni tests, ni structure, ni personne pour la comprendre. La réécriture est la seule stratégie sans coût d'interopérabilité, et la seule qui porte le risque classique de la branche longue qui n'aboutit jamais pendant que l'original continue d'évoluer. Si vous la choisissez, limitez-la dans le temps, gelez les fonctionnalités sur l'original et soyez prêt à abandonner la tentative plutôt que de la laisser courir indéfiniment.

Convertir les pièces

Une fois la stratégie choisie, l'essentiel du travail est mécanique. Cette correspondance couvre la majorité des cas que vous rencontrerez :

AngularJSAngular moderne
.controller avec $scopeClasse de composant avec champs et signals
.component avec bindingsComposant avec input() et output()
.directive avec une fonction linkComposant, ou directive d'attribut avec host bindings
.factory / .service / .providerClasse @Injectable, fournie en root ou au niveau d'une route
.filterPipe
$httpHttpClient
$qPromesses natives, ou observables RxJS
$timeout / $intervalsetTimeout / setInterval, sans wrapper de digest
$scope.$watchcomputed, effect, ou un setter d'input
ng-repeat@for avec une expression track obligatoire
ng-if / ng-show@if, ou un binding de classe ou de style
ng-model dans un formulaireFormulaires réactifs avec contrôles typés
$routeProvider ou états ui-routerTableau Routes avec loadComponent en lazy loading
Registre angular.moduleImports de modules ES plus le tableau imports du composant
$rootScope.$broadcastUn service portant un signal ou un subject

Deux conversions demandent du soin plutôt qu'une substitution mécanique. Les directives dont la fonction link manipule directement le DOM gagnent souvent à être réécrites en composants à templates déclaratifs plutôt que transcrites telles quelles. Et tout ce qui reposait sur un binding bidirectionnel à travers plusieurs niveaux doit être restructuré en flux unidirectionnel, ce qui revient généralement à remonter l'état dans un service. Tous les détails côté cible sont traités dans les bases d'Angular.

Tester et séquencer le travail

Traitez la migration comme une suite d'incréments livrables, jamais comme une branche. Convertissez un écran, faites-le passer en revue, mettez-le en production derrière un feature flag si nécessaire, et seulement ensuite attaquez le suivant. Une branche de migration qui vit trois mois accumule tant de conflits avec le travail courant sur les fonctionnalités que son merge devient un projet à part entière.

Pendant la transition, les tests opèrent à trois niveaux. Les tests end-to-end écrits avant le début des travaux s'exécutent sans modification contre les deux versions : ce sont eux, le contrat. Les tests unitaires, en général, ne se portent pas : ceux d'AngularJS dépendent d'angular.mock et de l'injecteur, prévoyez donc de les réécrire plutôt que de les traduire, et écrivez les nouveaux au fil de la conversion de chaque écran. En configuration hybride, ajoutez quelques tests d'intégration ciblant spécifiquement les jointures, car c'est là que vivent les bugs d'interaction entre frameworks.

Séquencez par risque et par dépendance. Commencez par un écran feuille réellement représentatif — pas le plus simple, qui ne vous apprendra rien sur le coût réel. Traitez tôt les services partagés, puisque tout en dépend, et l'authentification aussi, car c'est ce qu'il y a de plus difficile à faire tourner à deux endroits. Gardez les écrans les plus fréquentés et les plus critiques pour le métier jusqu'à ce que l'équipe ait plusieurs conversions à son actif. Tenez un burndown visible des écrans restants : une migration perd son financement quand ses progrès sont invisibles.

Les modes d'échec récurrents méritent d'être nommés. Refaire le design pendant le portage, si bien que chaque différence déclenche un débat sur le comportement attendu. Migrer sans tests et découvrir les régressions en production. Laisser l'état hybride devenir permanent, ce qui vous condamne à maintenir deux frameworks pour toujours. Sous-estimer les widgets tiers : un plugin de grille ou de planning sans équivalent moderne peut devenir le plus gros poste du plan. Et traiter la migration comme une tâche de fond sans responsable, c'est ainsi qu'un projet de deux trimestres devient un chantier de trois ans.

Points clés à retenir

  • AngularJS n'est plus supporté depuis fin 2021 ; ce qui justifie la migration, ce sont les risques non corrigés et une expertise qui disparaît, pas la mode des frameworks.
  • Les frameworks diffèrent dès les fondations — boucle de digest contre détection de changements ciblée, héritage de scopes contre inputs explicites, DI par chaînes contre tokens typés —, si bien qu'une partie du portage relève de la refonte plutôt que de la transcription.
  • Préparer le code legacy en convertissant les contrôleurs en composants à bindings unidirectionnels, en adoptant les modules ES et en retirant jQuery réduit chaque étape ultérieure et a de la valeur en soi.
  • Le démarrage hybride convient aux applications très imbriquées mais embarque deux frameworks et deux systèmes de détection de changements ; la livraison côte à côte convient aux frontières d'écran nettes et élimine les bugs d'interopérabilité, au prix d'une gestion de session partagée.
  • L'essentiel de la conversion tient dans une table de correspondance, mais les directives qui manipulent le DOM et le binding bidirectionnel sur plusieurs niveaux doivent être repensés en flux unidirectionnel.
  • Avancez par incréments livrables avec, comme contrat, des tests end-to-end écrits en amont ; traitez tôt les services partagés et l'authentification, et gardez les progrès visibles pour que le travail reste financé.

Sources et lectures complémentaires

Questions fréquentes

Est-il vraiment dangereux de continuer à faire tourner AngularJS ?
Le support a pris fin le 31 décembre 2021 : aucun correctif de sécurité n'a été publié depuis. Le risque n'est pas que le framework casse tout seul, mais qu'une vulnérabilité découverte dans le framework, dans une dépendance embarquée ou dans une directive tierce abandonnée ne soit jamais corrigée. Des mesures compensatoires comme une content security policy stricte et une validation rigoureuse côté serveur réduisent l'exposition, sans la supprimer.
Faut-il utiliser ngUpgrade ou faire tourner les deux applications côte à côte ?
ngUpgrade convient à une application très imbriquée, où un même écran mélange anciens et nouveaux composants et où ceux-ci partagent des services. La livraison côte à côte, qui aiguille des écrans entiers vers l'une ou l'autre application en périphérie, convient à une application aux frontières d'écran nettes et évite d'embarquer deux frameworks dans un même bundle. Vérifiez la compatibilité du package d'upgrade avec votre version cible d'Angular avant de vous engager.
Combien de temps dure une migration ?
La durée dépend du nombre d'écrans et de la quantité d'état partagé bien plus que du nombre de lignes de code. Une bonne estimation consiste à convertir de bout en bout deux écrans représentatifs, à mesurer honnêtement le temps écoulé revue et tests compris, puis à multiplier par le nombre d'écrans restants en ajoutant une marge conséquente pour les services partagés, l'authentification et les widgets tiers. Moins de quelques mois pour une grosse application relève généralement de l'optimisme.
Peut-on sauter la préparation et commencer la conversion tout de suite ?
C'est possible, et raisonnable sur une petite application. Sur une grosse, la préparation se rentabilise d'elle-même : convertir les contrôleurs en composants à bindings unidirectionnels, passer aux modules ES et ajouter des tests de caractérisation réduisent tous la taille de chaque conversion ultérieure, et restent utiles même si la migration est mise en pause. Démêler l'héritage de scopes pendant le portage coûte bien plus cher que de le faire avant.