Front-end angularforall.com

- Angular standalone components : migration complète

Angular Standalone Angular 17 Migration
Angular standalone components : migration complète

Migrez votre application Angular vers les standalone components : abandonnez les NgModules step by step et simplifiez votre architecture avec cette.

Pourquoi quitter les NgModules

Depuis Angular 2, les NgModules servent de conteneurs pour les composants, directives, pipes et services. Ce système a bien fonctionné pendant des années, mais il impose une friction importante pour les développeurs modernes:

Problème NgModule Impact concret Solution standalone
Double déclaration Composant déclaré dans le module ET utilisé dans les templates Imports directs dans le composant
Dépendances cachées Il faut ouvrir le module pour comprendre ce qu'utilise un composant Imports visibles dans @Component
Tree-shaking inefficace Un module entier est bundlé même si 1 composant est utilisé Seuls les composants réellement importés sont inclus
Compilation lente Les modules créent des frontières de compilation supplémentaires Compilation directe composant par composant
Tests complexes TestBed doit recréer le module + toutes ses dépendances TestBed.configureTestingModule avec imports directs

Standalone: architecture et imports

Un composant standalone déclare explicitement ses dépendances dans le tableau imports de son décorateur @Component. Ce tableau accepte d'autres composants standalone, des modules Angular, des pipes et directives, et des modules tiers.

// AVANT: composant avec NgModule
// user-card.component.ts
@Component({
  selector: 'app-user-card',
  templateUrl: './user-card.component.html'
  // AUCUNE info sur les dépendances ici
})
export class UserCardComponent {}

// user.module.ts (fichier séparé obligatoire)
@NgModule({
  declarations: [UserCardComponent], // déclaré ici
  imports: [CommonModule, RouterModule, DatePipe],
  exports: [UserCardComponent],
})
export class UserModule {}

// ----- APRÈS: composant standalone -----
// user-card.component.ts (plus besoin de user.module.ts)
@Component({
  selector: 'app-user-card',
  standalone: true, // Angular 17-19: explicite. Angular 20+: par défaut
  imports: [
    RouterLink,           // directive standalone d'Angular
    DatePipe,             // pipe standalone d'Angular
    UserAvatarComponent,  // autre composant standalone
    NgClass,              // directive standalone CommonModule
  ],
  template: `
    <div [ngClass]="{ 'active': user.isOnline }">
      <app-user-avatar [src]="user.avatar" />
      <a [routerLink]="['/users', user.id]">{{ user.name }}</a>
      <span>{{ user.createdAt | date:'mediumDate' }}</span>
    </div>
  `
})
export class UserCardComponent {
  @Input() user!: User;
}

Angular 20: standalone par défaut

Depuis Angular 19, standalone: true est le comportement par défaut pour les nouveaux projets. Angular 20 finalise cette transition: la propriété standalone dans le décorateur devient optionnelle (ignorée si absente).

// Angular 17-18: standalone opt-in (explicite requis)
@Component({ standalone: true, ... })
export class MyComponent {}

// Angular 19: standalone par défaut si schematics
// ng.json: "defaults": { "standalone": true }

// Angular 20+: standalone implicite pour tous les nouveaux composants
@Component({ selector: 'app-my', template: '...' })
export class MyComponent {}  // standalone sans le dire

// Générer des composants avec CLI Angular 20
ng generate component user-profile
# → génère sans standalone: true (implicite)

# Forcer le style avec module (rétrocompatibilité)
ng generate component legacy-comp --no-standalone

Règle d'import dans Angular 20

// Angular 20: les contrôles de flux intégrés (@if, @for, @switch) sont disponibles
// sans aucun import supplémentaire dans le template
@Component({
  template: `
    @if (user()) {
      <p>{{ user()!.name }}</p>
    }
    @for (item of items(); track item.id) {
      <li>{{ item.name }}</li>
    }
  `
  // Pas besoin d'importer NgIf ou NgFor — les control flows sont natifs
})
export class UserListComponent {
  user = signal<User | null>(null);
  items = signal<Item[]>([]);
}

// Pour les pipes, directives et composants: toujours dans imports[]
@Component({
  imports: [DatePipe, RouterLink, AsyncPipe, UserAvatarComponent],
  template: `...`
})
export class ProfileComponent {}

Bootstrap et app.config.ts

Une application standalone se démarre avec bootstrapApplication() au lieu de platformBrowserDynamic().bootstrapModule(AppModule). La configuration globale est externalisée dans app.config.ts.

// main.ts: point d'entrée minimal
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';
import { appConfig } from './app/app.config';

bootstrapApplication(AppComponent, appConfig).catch(console.error);

// app.config.ts: toute la configuration globale en un lieu
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding, withViewTransitions } from '@angular/router';
import { provideHttpClient, withInterceptors, withFetch } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    // Routeur avec options tree-shakables
    provideRouter(
      routes,
      withComponentInputBinding(),  // inputs depuis l'URL automatiquement
      withViewTransitions(),         // View Transitions API
    ),

    // HTTP avec intercepteurs fonctionnels et Fetch API
    provideHttpClient(
      withFetch(),                   // utiliser Fetch au lieu de XHR
      withInterceptors([authInterceptor, loggingInterceptor])
    ),

    // Animations chargées en lazy (tree-shakable)
    provideAnimationsAsync(),

    // Change detection zone-based optimisé
    provideZoneChangeDetection({ eventCoalescing: true }),
  ],
};

Providers fonctionnels: provideX()

Le modèle standalone remplace les méthodes forRoot() et forChild() des modules par des fonctions provideX() tree-shakables. Voici les équivalences:

Ancienne API (NgModule) Nouvelle API (Standalone)
RouterModule.forRoot(routes) provideRouter(routes, ...withFeatures)
HttpClientModule provideHttpClient(withInterceptors([...]))
BrowserAnimationsModule provideAnimationsAsync()
StoreModule.forRoot(reducers) provideStore(reducers)
EffectsModule.forRoot([Effects]) provideEffects([Effects])
ServiceWorkerModule.register() provideServiceWorker('ngsw-worker.js')
// Providers de route avec scope limité (équivalent forChild)
// Un sous-groupe de routes peut avoir ses propres providers
export const adminRoutes: Routes = [
  {
    path: '',
    // Providers disponibles uniquement pour ces routes
    providers: [
      AdminService,
      { provide: HTTP_INTERCEPTORS, useClass: AdminAuthInterceptor, multi: true },
    ],
    children: [
      { path: 'users', loadComponent: () => import('./admin-users.component') },
      { path: 'settings', loadComponent: () => import('./admin-settings.component') },
    ],
  },
];

Lazy loading avec loadComponent et loadChildren

Avant les standalone components, le lazy loading nécessitait obligatoirement un NgModule par feature. Désormais, deux approches coexistent:

// app.routes.ts: routing complet avec lazy loading standalone

import { Routes } from '@angular/router';

export const routes: Routes = [
  // loadComponent: lazy-load d'un seul composant
  {
    path: 'profile',
    loadComponent: () =>
      import('./features/profile/profile.component')
        .then(m => m.ProfileComponent),
  },

  // loadComponent avec paramètre d'URL en input automatique (withComponentInputBinding)
  {
    path: 'users/:id',
    loadComponent: () =>
      import('./features/users/user-detail.component')
        .then(m => m.UserDetailComponent),
    // :id est automatiquement injecté comme @Input() id via withComponentInputBinding()
  },

  // loadChildren: lazy-load d'un groupe de routes (feature complète)
  {
    path: 'admin',
    canActivate: [authGuard],
    loadChildren: () =>
      import('./features/admin/admin.routes')
        .then(m => m.adminRoutes), // admin.routes.ts exporte Routes
  },

  // Route avec providers locaux (périmètre d'injection limité)
  {
    path: 'checkout',
    loadComponent: () => import('./features/checkout/checkout.component'),
    providers: [CheckoutService, OrderValidationService],
  },
];
// admin.routes.ts: sous-routes standalone
import { Routes } from '@angular/router';

export const adminRoutes: Routes = [
  { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
  {
    path: 'dashboard',
    loadComponent: () => import('./admin-dashboard.component'),
  },
  {
    path: 'users',
    loadComponent: () => import('./admin-users.component'),
  },
];
// Pas de NgModule nécessaire!

Migration automatique depuis NgModules

Angular fournit un schematic de migration automatique. En 3 commandes, il convertit ~95% du code existant:

# Pré-requis: Angular CLI v16+ et un projet Angular
ng version

# Étape 1: Convertir les composants, directives et pipes en standalone
ng generate @angular/core:standalone
# → Choisir: "Convert all components, directives and pipes to standalone"
# Résultat: standalone: true ajouté, imports[] remplis automatiquement

# Étape 2: Nettoyer les NgModules devenus inutiles
ng generate @angular/core:standalone
# → Choisir: "Remove unnecessary NgModule classes"
# Résultat: modules sans providers supprimés, SharedModule, CommonModule, etc.

# Étape 3: Migrer le bootstrap
ng generate @angular/core:standalone
# → Choisir: "Bootstrap the application using standalone APIs"
# Résultat: main.ts → bootstrapApplication(), AppModule → app.config.ts

Cas nécessitant une intervention manuelle

  • Modules avec forRoot(): remplacer par provideX() dans app.config.ts.
  • Modules avec forChild(): utiliser les providers de route à la place.
  • APP_INITIALIZER: migrer vers la syntaxe fonctionnelle { provide: APP_INITIALIZER, useFactory: ... }.
  • SharedModule avec re-exports: le schematic le divise souvent incorrectement — vérifier les imports manquants.
  • Bibliothèques tierces encore avec NgModule: continuer à les importer via imports[] du composant ou de app.config.ts.
Rétrocompatibilité : Les NgModules continuent de fonctionner dans Angular 20. Les composants basés sur modules et les composants standalone coexistent sans problème dans la même application. La migration est incrémentale.

Tester les composants standalone

Les tests de composants standalone sont plus simples car TestBed n'a pas besoin de reconstruire un module entier — il suffit d'importer uniquement ce dont le composant a besoin.

// user-card.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { UserCardComponent } from './user-card.component';
import { RouterTestingModule } from '@angular/router/testing';
import { DatePipe } from '@angular/common';

describe('UserCardComponent', () => {
  let component: UserCardComponent;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      // Standalone: importer directement le composant
      imports: [
        UserCardComponent,           // le composant à tester
        RouterTestingModule,         // pour RouterLink
        // Pas besoin de créer un module séparé!
      ],
    }).compileComponents();

    const fixture = TestBed.createComponent(UserCardComponent);
    component = fixture.componentInstance;
  });

  it('affiche le nom de l\'utilisateur', () => {
    const fixture = TestBed.createComponent(UserCardComponent);
    fixture.componentInstance.user = {
      id: 1, name: 'Said Mezgani', avatar: '/img/avatar.webp', isOnline: true
    };
    fixture.detectChanges();

    const nameEl = fixture.debugElement.query(By.css('.user-name'));
    expect(nameEl.nativeElement.textContent).toContain('Said Mezgani');
  });

  it('affiche l\'indicateur "en ligne" quand isOnline est true', () => {
    const fixture = TestBed.createComponent(UserCardComponent);
    fixture.componentInstance.user = { ..., isOnline: true };
    fixture.detectChanges();

    const badge = fixture.debugElement.query(By.css('.online-badge'));
    expect(badge).toBeTruthy();
  });
});

// Test avec services mockés: standalone simplifie l'injection
describe('UserListComponent', () => {
  it('affiche les utilisateurs chargés depuis l\'API', async () => {
    const mockUsers = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
    const mockUserService = { getUsers: jasmine.createSpy().and.returnValue(of(mockUsers)) };

    await TestBed.configureTestingModule({
      imports: [UserListComponent],
      providers: [
        { provide: UserService, useValue: mockUserService }
      ],
    }).compileComponents();

    // ...
  });
});

Partager