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

54. Angular Elements

Привет! Яша здесь. Angular Elements — это способ «запаковать» Angular-компонент в стандартный Custom Element (Web Component). Это значит, что твой компонент можно использовать в React, Vue, или даже в обычном HTML без Angular! Разберём как это работает 🚀


Web Components — набор нативных браузерных стандартов:

  • Custom Elements — создание кастомных HTML-тегов (<my-button>)
  • Shadow DOM — инкапсуляция стилей и разметки
  • HTML Templates<template> и <slot>
<!-- После упаковки в Angular Elements: -->
<!-- Используется в любом HTML без Angular! -->
<script src="my-angular-element.js"></script>
<my-rating-widget value="4" max="5"></my-rating-widget>
<my-notification type="success" message="Saved!"></my-notification>

Окно терминала
npm install @angular/elements
// rating.component.ts — обычный Angular компонент
@Component({
selector: 'app-rating',
standalone: true,
template: `
<div style="display: flex; gap: 4px;">
@for (star of stars; track star) {
<span
[style.color]="star <= value ? '#fbbf24' : '#475569'"
style="font-size: 24px; cursor: pointer;"
(click)="onRate(star)">
</span>
}
</div>
`,
})
export class RatingComponent {
@Input() value = 0;
@Input() max = 5;
@Output() rated = new EventEmitter<number>();
get stars(): number[] {
return Array.from({ length: this.max }, (_, i) => i + 1);
}
onRate(star: number): void {
this.value = star;
this.rated.emit(star);
}
}
// app.module.ts — регистрация как Custom Element
import { NgModule, Injector } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { RatingComponent } from './rating/rating.component';
@NgModule({
imports: [BrowserModule, RatingComponent],
})
export class AppModule {
constructor(private injector: Injector) {}
ngDoBootstrap(): void {
// createCustomElement — превращает Angular компонент в Web Component
const RatingElement = createCustomElement(RatingComponent, {
injector: this.injector,
});
// Регистрируем в браузере под своим тегом
customElements.define('app-rating', RatingElement);
}
}

// main.ts — без NgModule
import { createApplication } from '@angular/platform-browser';
import { createCustomElement } from '@angular/elements';
import { RatingComponent } from './rating.component';
import { NotificationComponent } from './notification.component';
(async () => {
const app = await createApplication({
providers: [/* провайдеры */]
});
const Rating = createCustomElement(RatingComponent, { injector: app.injector });
const Notification = createCustomElement(NotificationComponent, { injector: app.injector });
customElements.define('app-rating', Rating);
customElements.define('app-notification', Notification);
})();

Angular автоматически маппит:

  • @Input() → атрибут/свойство Custom Element
  • @Output() → CustomEvent
<!-- В HTML атрибуты — всегда строки -->
<app-user-card user-id="42" show-avatar="true"></app-user-card>
<!-- В JavaScript можно передавать объекты через свойства -->
<script>
const card = document.querySelector('app-user-card');
card.user = { id: 42, name: 'Яша', email: '[email protected]' };
// Слушаем Angular @Output как DOM событие
card.addEventListener('userSelected', (event) => {
console.log('Выбран пользователь:', event.detail);
});
</script>
// Angular компонент — без изменений!
@Component({ selector: 'app-user-card', ... })
export class UserCardComponent {
@Input() userId!: number;
@Input() user!: User;
@Input() showAvatar = true;
@Output() userSelected = new EventEmitter<User>();
}

// Включение Shadow DOM для инкапсуляции стилей
@Component({
selector: 'app-isolated-widget',
encapsulation: ViewEncapsulation.ShadowDom, // ← Shadow DOM!
template: `
<div class="widget">
<h2>Изолированный виджет</h2>
<slot></slot> <!-- слот для контента от родителя -->
</div>
`,
styles: [`
/* Эти стили НЕ утекут за пределы Shadow DOM */
.widget { background: #1e293b; padding: 16px; border-radius: 8px; }
h2 { color: #dd0031; margin: 0 0 8px; }
`]
})
export class IsolatedWidgetComponent {}

// angular.json — конфигурация для сборки элемента
{
"projects": {
"elements": {
"architect": {
"build": {
"options": {
"outputPath": "dist/elements",
"main": "projects/elements/src/main.ts",
"polyfills": [],
"optimization": true
}
}
}
}
}
}
Окно терминала
ng build elements --configuration=production
# Объединить в один файл (устаревший способ)
cat dist/elements/runtime.js dist/elements/polyfills.js dist/elements/main.js > elements.js
# Современный способ — один бандл через esbuild
ng build --single-bundle true

react-app/src/AngularWidget.tsx
import { useEffect, useRef } from 'react';
// Декларация для TypeScript
declare global {
namespace JSX {
interface IntrinsicElements {
'app-rating': {
value?: number;
max?: number;
onRated?: (event: CustomEvent<number>) => void;
};
}
}
}
function AngularRating({ value, max, onRate }: Props) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const handler = (e: Event) => onRate?.((e as CustomEvent).detail);
el.addEventListener('rated', handler);
return () => el.removeEventListener('rated', handler);
}, [onRate]);
return <app-rating ref={ref} value={value} max={max} />;
}

import { NgElement, WithProperties } from '@angular/elements';
import { RatingComponent } from './rating.component';
// Типизированный элемент
type RatingElement = NgElement & WithProperties<RatingComponent>;
// Программное создание
const rating = document.createElement('app-rating') as RatingElement;
rating.value = 3; // TypeScript знает тип!
rating.max = 5;
document.body.appendChild(rating);
// Получение существующего элемента
const el = document.querySelector('app-rating') as RatingElement;
el.value = 5; // полная типизация

// shell/main.ts — загрузка Angular Elements динамически
async function loadMicroFrontend(url: string, elementName: string): Promise<void> {
// Проверяем, не загружен ли уже элемент
if (customElements.get(elementName)) {
return;
}
// Динамическая загрузка скрипта
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = () => resolve();
script.onerror = reject;
document.head.appendChild(script);
});
}
// Использование
await loadMicroFrontend('https://team-a.example.com/elements.js', 'team-a-widget');
// Теперь <team-a-widget> работает везде!

Симуляция Custom Element в не-Angular окружении: