Перейти к содержимому

29. Standalone Components

Начиная с Angular 14, компоненты могут существовать без NgModule. Это упрощает архитектуру и делает код более явным.


До Angular 14 каждый компонент обязательно принадлежал модулю:

// ❌ Старый подход — нужен NgModule
@NgModule({
declarations: [ButtonComponent], // Компонент объявлен здесь
exports: [ButtonComponent]
})
export class ButtonModule {}
// Где-то в другом модуле
@NgModule({
imports: [ButtonModule] // Импортируем весь модуль ради одного компонента
})
export class FormModule {}

Standalone-компоненты объявляют зависимости сами:

// ✅ Новый подход — без NgModule
@Component({
standalone: true,
selector: 'app-button',
imports: [CommonModule], // Прямо здесь!
template: `<button [class]="variant">{{ label }}</button>`
})
export class ButtonComponent {
@Input() label = '';
@Input() variant: 'primary' | 'secondary' = 'primary';
}
// Использование
@Component({
standalone: true,
imports: [ButtonComponent], // Импортируем компонент, не модуль!
template: `<app-button label="Нажми" />`
})
export class FormComponent {}

// main.ts — без AppModule!
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideAnimations } from '@angular/platform-browser/animations';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes),
provideHttpClient(),
provideAnimations(),
// Любые другие глобальные провайдеры
]
}).catch(console.error);

В standalone компоненте imports — это всё, что используется в шаблоне:

import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { ReactiveFormsModule } from '@angular/forms';
import { FormsModule } from '@angular/forms';
// Другие standalone компоненты
import { ButtonComponent } from '../shared/button/button.component';
import { InputComponent } from '../shared/input/input.component';
import { SpinnerComponent } from '../shared/spinner/spinner.component';
@Component({
standalone: true,
selector: 'app-registration',
imports: [
CommonModule, // *ngIf, *ngFor, async pipe
ReactiveFormsModule, // formGroup, formControlName
RouterModule, // routerLink
ButtonComponent, // <app-button>
InputComponent, // <app-input>
SpinnerComponent, // <app-spinner>
],
template: `
<form [formGroup]="form">
<app-input formControlName="email" />
<app-button (click)="submit()">Войти</app-button>
</form>
<a routerLink="/login">Уже есть аккаунт?</a>
`
})
export class RegistrationComponent { /* ... */ }

Пайпы и директивы тоже могут быть standalone:

truncate.pipe.ts
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'truncate',
standalone: true, // ← standalone pipe
})
export class TruncatePipe implements PipeTransform {
transform(value: string, limit = 100): string {
if (value.length <= limit) return value;
return value.slice(0, limit) + '...';
}
}
// Использование в компоненте
@Component({
standalone: true,
imports: [TruncatePipe], // Импортируем пайп напрямую
template: `{{ longText | truncate:50 }}`
})
export class ArticleComponent { /* ... */ }
highlight.directive.ts
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
@Directive({
selector: '[appHighlight]',
standalone: true, // ← standalone directive
})
export class HighlightDirective {
@Input() appHighlight = '#dd0031';
constructor(private el: ElementRef) {}
@HostListener('mouseenter')
onMouseEnter() {
this.el.nativeElement.style.backgroundColor = this.appHighlight;
}
@HostListener('mouseleave')
onMouseLeave() {
this.el.nativeElement.style.backgroundColor = '';
}
}

// app.routes.ts — полностью standalone
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent)
},
{
path: 'users',
loadChildren: () => import('./users/users.routes').then(m => m.USERS_ROUTES)
},
];
// users/users.routes.ts
export const USERS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./users-list.component').then(m => m.UsersListComponent)
},
{
path: ':id',
loadComponent: () =>
import('./user-detail.component').then(m => m.UserDetailComponent)
}
];

Angular предоставляет автоматическую миграцию:

Окно терминала
ng generate @angular/core:standalone
# Или через ng update (Angular 16+)
ng update @angular/core --migrate-only --name standalone

Ручная миграция шаг за шагом:

// Было
@Component({
selector: 'app-badge',
template: `<span class="badge">{{ text }}</span>`
})
export class BadgeComponent {
@Input() text = '';
}
@NgModule({
declarations: [BadgeComponent],
exports: [BadgeComponent]
})
export class BadgeModule {}
// Стало
@Component({
standalone: true, // ← добавить
selector: 'app-badge',
template: `<span class="badge">{{ text }}</span>`
})
export class BadgeComponent {
@Input() text = '';
}
// BadgeModule — удалить!

КритерийStandaloneNgModule
Новые проекты (Angular 17+)✅ Рекомендуется⚠️ Устаревает
Существующие проектыПостепенная миграция✅ Работает
Библиотеки компонентов✅ Проще публикацияСложнее
Tree-shaking✅ ЛучшеХуже
Явность зависимостей✅ Все видно в компонентеРазмазано по модулям
Шаблонный код✅ МеньшеБольше

Standalone заменяет forRoot() паттерн на provide- функции:

// Было с NgModule
@NgModule({
imports: [
RouterModule.forRoot(routes, { enableTracing: true }),
HttpClientModule,
StoreModule.forRoot(reducers),
]
})
export class AppModule {}
// Стало со Standalone
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withDebugTracing()),
provideHttpClient(withInterceptors([authInterceptor])),
provideStore(reducers),
]
});

export default function StandaloneVsModulePlayground() {
const [view, setView] = React.useState('compare');
const [showImports, setShowImports] = React.useState({ button: false, form: false, page: false });
const toggle = (k) => setShowImports(p => ({ ...p, [k]: !p[k] }));
const components = [
{
id: 'button',
name: 'ButtonComponent',
color: '#dd0031',
standalone: {
code: `@Component({
standalone: true,
selector: 'app-button',
imports: [CommonModule],
template: '...'
})
export class ButtonComponent {}`,
imports: ['CommonModule'],
},
module: {
code: `@NgModule({
declarations: [ButtonComponent],
exports: [ButtonComponent],
imports: [CommonModule]
})
export class ButtonModule {}`,
note: 'Нужен отдельный файл модуля',
}
},
{
id: 'form',
name: 'RegistrationComponent',
color: '#7c3aed',
standalone: {
code: `@Component({
standalone: true,
imports: [
ReactiveFormsModule,
ButtonComponent, // ← прямо тут
InputComponent,
],
template: '...'
})
export class RegistrationComponent {}`,
imports: ['ReactiveFormsModule', 'ButtonComponent', 'InputComponent'],
},
module: {
code: `@NgModule({
declarations: [RegistrationComponent],
imports: [
ReactiveFormsModule,
ButtonModule, // ← нужен весь модуль
InputModule,
]
})
export class RegistrationModule {}`,
note: 'Импортируем целые модули',
}
},
{
id: 'page',
name: 'DashboardPage',
color: '#15803d',
standalone: {
code: `@Component({
standalone: true,
imports: [
RouterModule,
AsyncPipe,
ChartComponent,
TableComponent,
],
template: '...'
})
export class DashboardPage {}`,
imports: ['RouterModule', 'AsyncPipe', 'ChartComponent', 'TableComponent'],
},
module: {
code: `@NgModule({
declarations: [DashboardPage],
imports: [
RouterModule,
CommonModule,
ChartModule,
TableModule,
]
})
export class DashboardModule {}`,
note: 'Огромные модули с лишними компонентами',
}
}
];
return (
<div style={{ background: '#0f172a', minHeight: 480, padding: 24, borderRadius: 12, fontFamily: 'system-ui', color: '#e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<span style={{ color: '#dd0031', fontWeight: 700, fontSize: 16 }}>🧩 Standalone vs NgModule</span>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setView('compare')} style={{ background: view === 'compare' ? '#dd0031' : '#1e293b', color: view === 'compare' ? 'white' : '#94a3b8', border: 'none', padding: '5px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
Сравнение
</button>
<button onClick={() => setView('tree')} style={{ background: view === 'tree' ? '#dd0031' : '#1e293b', color: view === 'tree' ? 'white' : '#94a3b8', border: 'none', padding: '5px 12px', borderRadius: 6, cursor: 'pointer', fontSize: 12 }}>
Зависимости
</button>
</div>
</div>
{view === 'compare' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{components.map(c => (
<div key={c.id} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 14, border: `1px solid ${c.color}` }}>
<div style={{ color: c.color, fontSize: 12, fontWeight: 700, marginBottom: 8 }}>✅ Standalone: {c.name}</div>
<pre style={{ fontSize: 10, color: '#94a3b8', margin: 0, whiteSpace: 'pre-wrap' }}>{c.standalone.code}</pre>
</div>
<div style={{ background: '#1e293b', borderRadius: 8, padding: 14, border: '1px solid #334155' }}>
<div style={{ color: '#64748b', fontSize: 12, fontWeight: 700, marginBottom: 8 }}>📦 NgModule: {c.name} + {c.id.charAt(0).toUpperCase() + c.id.slice(1)}Module</div>
<pre style={{ fontSize: 10, color: '#64748b', margin: 0, whiteSpace: 'pre-wrap' }}>{c.module.code}</pre>
<div style={{ fontSize: 10, color: '#f59e0b', marginTop: 8 }}>⚠️ {c.module.note}</div>
</div>
</div>
))}
</div>
)}
{view === 'tree' && (
<div>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 16 }}>
Standalone: зависимости видны прямо в компоненте → лучше tree-shaking
</div>
<div style={{ display: 'flex', gap: 20 }}>
{components.map(c => (
<div key={c.id}>
<button
onClick={() => toggle(c.id)}
style={{ background: showImports[c.id] ? c.color : '#1e293b', color: showImports[c.id] ? 'white' : '#94a3b8', border: `1px solid ${c.color}`, padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 12, marginBottom: 8, display: 'block' }}
>
{c.name}
</button>
{showImports[c.id] && (
<div style={{ paddingLeft: 16, borderLeft: `2px solid ${c.color}` }}>
{c.standalone.imports.map(imp => (
<div key={imp} style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4, padding: '3px 8px', background: '#1e293b', borderRadius: 4 }}>
📦 {imp}
</div>
))}
</div>
)}
</div>
))}
</div>
<div style={{ marginTop: 20, padding: 12, background: '#15803d20', border: '1px solid #22c55e', borderRadius: 8, fontSize: 12, color: '#22c55e' }}>
✅ Каждый компонент знает ТОЛЬКО о своих зависимостях. Нет лишних импортов.
</div>
</div>
)}
</div>
);
}