NgModule a confondu presque tous ceux qui ont rencontré Angular pour la première fois. J'ai regardé des développeurs React senior passer deux jours à comprendre pourquoi un composant déclaré dans FeatureModule n'était pas disponible dans un autre FeatureModule. La réponse impliquait SharedModule, forFeature(), et un graphe de dépendances complètement invisible au niveau du composant. Les composants standalone corrigent cela en éliminant l'indirection.
Ce que standalone signifie vraiment
Un composant standalone déclare ses propres dépendances directement dans son tableau imports. Pas de wrapper NgModule. Pas de tableau de déclaration ailleurs. Si un composant a besoin de RouterLink, il importe RouterLink. S'il a besoin de CurrencyPipe, il importe CurrencyPipe. Le fichier du composant est auto-descriptif.
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { AccountCardComponent } from './account-card.component';
import { Account } from './account.model';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [
RouterLink,
AccountCardComponent
],
template: `
<h1>Dashboard</h1>
<a routerLink="/accounts">All accounts</a>
@for (account of accounts; track account.id) {
<app-account-card [account]="account" />
}
`
})
export class DashboardComponent {
accounts: Account[] = [];
}Remarquez que AccountCardComponent est importé directement. Il n'a pas besoin d'être dans un module partagé. L'import lui-même vous dit : ce composant utilise AccountCardComponent. Pas besoin de tracer les déclarations de modules. Notez que le tableau imports ne porte que ce que le template utilise vraiment. Tout le reste est du poids mort, et le diagnostic NG8113 d'Angular vous le signalera.
Bootstrap sans AppModule
Les nouveaux projets générés avec ng new ne créent plus d'AppModule. Ils utilisent bootstrapApplication avec un ApplicationConfig.
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch(console.error);
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor]))
]
};Lazy loading par composant
Avant les standalone, la plus petite unité chargeable en lazy était un module. Si vous vouliez lazy-loader un seul composant de page, il fallait d'abord l'envelopper dans un module. Maintenant vous utilisez loadComponent directement.
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'accounts',
loadChildren: () =>
import('./features/accounts/accounts.routes')
.then(m => m.ACCOUNTS_ROUTES)
}
];Pour les domaines fonctionnels avec plusieurs routes, je les regroupe toujours dans un fichier de routes et utilise loadChildren. Cela garde la frontière de chunk au niveau de la fonctionnalité. Pour les pages individuelles véritablement standalone, loadComponent est le bon choix.
Providers scopés sans modules
Les providers de NgModule servaient à limiter la portée des services à une fonctionnalité chargée en lazy. Sans NgModule, vous limitez les providers à une route via le tableau providers de la définition de route.
{
path: 'lending',
loadComponent: () =>
import('./features/lending/lending.component').then(m => m.LendingComponent),
providers: [
LendingService,
{ provide: LENDING_API_URL, useValue: '/api/v2/lending' }
]
}Dans cet exemple, le LendingService est limité à la route de la fonctionnalité de prêts. Il est créé la première fois que la route s'active, et rien en dehors de la fonctionnalité de prêts ne peut l'injecter. Un piège : par défaut, Angular ne détruit pas l'injecteur d'une route quand vous naviguez ailleurs, donc le service et son état survivent pendant toute la vie de l'application. Angular 21.1 a ajouté withExperimentalAutoCleanupInjectors, qui détruit un injecteur de route dès que cette route quitte l'arbre des routes actives, mais c'est expérimental et il faut l'activer soi-même. Tant que vous ne l'avez pas activé, réinitialisez explicitement l'état de la fonctionnalité à l'entrée de la route au lieu de supposer une instance neuve.
Migrer un projet existant
L'équipe Angular fournit des schematics qui automatisent la majeure partie de la migration. Exécutez-les en séquence.
- ng generate @angular/core:standalone --mode=convert-to-standalone : convertit les déclarations en standalone
- ng generate @angular/core:standalone --mode=prune-ng-modules : supprime les modules vides après conversion
- ng generate @angular/core:standalone --mode=standalone-bootstrap : met à jour main.ts pour utiliser bootstrapApplication
- Revoyez le diff attentivement pour la configuration des providers, surtout si vous utilisez des patterns forRoot()
- Mettez à jour TestBed : les composants standalone utilisent imports au lieu de déclarations dans configureTestingModule
Chez Wonderlabs, j'ai fait passer la plateforme de cartes de crédit d'Angular 13 à Angular 15 dans un monorepo. Les API standalone sont devenues stables en 15.0 et le schematic de migration standalone est arrivé en 15.2, donc c'est à partir de cette ligne de versions qu'une conversion comme celle-ci devient praticable. Le schematic gère l'essentiel du travail. Ce qui reste, c'est la configuration de providers dans AppModule qui nécessite une traduction manuelle vers les fonctions provideX(). Prévoyez une demi-journée par projet.
L'impact pratique
Après la migration, le changement le plus immédiat est la vitesse d'intégration des nouveaux arrivants. Un nouveau développeur peut comprendre de quoi dépend un composant en lisant le fichier du composant. Ils n'ont plus besoin de savoir dans quel module il vit, quel SharedModule exporte CommonModule, et pourquoi RouterModule doit être importé à trois endroits. Cette charge cognitive était réelle, et elle a disparu.
La taille du build s'améliore aussi dans les projets où les frontières de modules étaient grossières. Le tree-shaking est plus précis quand le graphe d'imports vit au niveau du composant. Dans un domaine fonctionnel qui s'appuie sur un SharedModule trop large, le bundle initial peut devenir nettement plus petit après la migration, juste en éliminant les imports que ce module tirait avec lui.
Les composants standalone n'ont pas ajouté de nouvelles capacités. Ils ont supprimé l'indirection. C'est un problème d'ingénierie plus difficile à résoudre, et ça compte davantage.
Références
- Angular standalone components. https://angular.dev/guide/components/importing
- bootstrapApplication API. https://angular.dev/api/platform-browser/bootstrapApplication
- Standalone migration schematic. https://angular.dev/reference/migrations/standalone
- Testing standalone components. https://angular.dev/guide/testing/components-basics