29. Standalone Components
🧩 Standalone компоненты в Angular
Заголовок раздела «🧩 Standalone компоненты в Angular»Начиная с Angular 14, компоненты могут существовать без NgModule. Это упрощает архитектуру и делает код более явным.
🎯 Что такое Standalone
Заголовок раздела «🎯 Что такое Standalone»До 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 {}🚀 bootstrapApplication — запуск без AppModule
Заголовок раздела «🚀 bootstrapApplication — запуск без AppModule»// 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);📋 imports в @Component
Заголовок раздела «📋 imports в @Component»В 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 Pipe и Directive
Заголовок раздела «🔧 Standalone Pipe и Directive»Пайпы и директивы тоже могут быть standalone:
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 { /* ... */ }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 = ''; }}🗺️ Standalone маршруты
Заголовок раздела «🗺️ Standalone маршруты»// app.routes.ts — полностью standaloneimport { 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.tsexport 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) }];🔄 Миграция с NgModule на Standalone
Заголовок раздела «🔄 Миграция с NgModule на Standalone»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 — удалить!🏗️ Standalone vs NgModule — когда что?
Заголовок раздела «🏗️ Standalone vs NgModule — когда что?»| Критерий | Standalone | NgModule |
|---|---|---|
| Новые проекты (Angular 17+) | ✅ Рекомендуется | ⚠️ Устаревает |
| Существующие проекты | Постепенная миграция | ✅ Работает |
| Библиотеки компонентов | ✅ Проще публикация | Сложнее |
| Tree-shaking | ✅ Лучше | Хуже |
| Явность зависимостей | ✅ Все видно в компоненте | Размазано по модулям |
| Шаблонный код | ✅ Меньше | Больше |
🔑 provideX функции
Заголовок раздела «🔑 provideX функции»Standalone заменяет forRoot() паттерн на provide- функции:
// Было с NgModule@NgModule({ imports: [ RouterModule.forRoot(routes, { enableTracing: true }), HttpClientModule, StoreModule.forRoot(reducers), ]})export class AppModule {}
// Стало со StandalonebootstrapApplication(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> );}