48. SSR и Angular Universal
🌐 Angular Universal — Server-Side Rendering
Заголовок раздела «🌐 Angular Universal — Server-Side Rendering»Angular Universal — это технология SSR (Server-Side Rendering) для Angular. Вместо того чтобы отправлять браузеру пустой HTML и ждать загрузки JS, сервер возвращает готовый HTML. Это критично для SEO, производительности и First Contentful Paint 🚀
Зачем нужен SSR?
Заголовок раздела «Зачем нужен SSR?»| SPA (CSR) | SSR | |
|---|---|---|
| Первый контент | После загрузки JS (~3-8с) | Сразу (~0.3-1с) |
| SEO | Проблемно (боты не ждут JS) | Отлично |
| Social sharing | Пустые превью | Полные метатеги |
| Low-end devices | Медленно | Быстро |
| Time to Interactive | После гидрации | После гидрации |
Установка Angular SSR
Заголовок раздела «Установка Angular SSR»ng add @angular/ssrКоманда создаёт:
server.ts— Express-сервер- Обновляет
angular.jsonс server target - Обновляет
app.config.tsи добавляетapp.config.server.ts
Структура проекта после ng add
Заголовок раздела «Структура проекта после ng add»src/├── app/│ ├── app.config.ts ← Конфиг для браузера│ └── app.config.server.ts ← Конфиг для сервера├── main.ts ← Браузерный бутстрап└── main.server.ts ← Серверный бутстрап
server.ts ← Express серверserver.ts — Express сервер
Заголовок раздела «server.ts — Express сервер»import { APP_BASE_HREF } from '@angular/common';import { CommonEngine } from '@angular/ssr';import express from 'express';import { fileURLToPath } from 'node:url';import { dirname, join, resolve } from 'node:path';import bootstrap from './src/main.server';
const serverDistFolder = dirname(fileURLToPath(import.meta.url));const browserDistFolder = resolve(serverDistFolder, '../browser');const indexHtml = join(serverDistFolder, 'index.server.html');
export function app(): express.Express { const server = express(); const commonEngine = new CommonEngine();
// Отдаём статику из dist/browser server.get('*.*', express.static(browserDistFolder, { maxAge: '1y' }));
// Все остальные запросы обрабатываем Angular SSR server.get('*', (req, res, next) => { const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine .render({ bootstrap, documentFilePath: indexHtml, url: \`\${protocol}://\${headers.host}\${originalUrl}\`, publicPath: browserDistFolder, providers: [ { provide: APP_BASE_HREF, useValue: baseUrl } ], }) .then(html => res.send(html)) .catch(err => next(err)); });
return server;}
// Запускfunction run(): void { const port = process.env['PORT'] || 4000; const server = app(); server.listen(port, () => { console.log(\`Node Express server listening on http://localhost:\${port}\`); });}
run();app.config.server.ts
Заголовок раздела «app.config.server.ts»import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';import { provideServerRendering } from '@angular/platform-server';import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = { providers: [ provideServerRendering() ]};
export const config = mergeApplicationConfig(appConfig, serverConfig);TransferState API — передача данных сервер → браузер
Заголовок раздела «TransferState API — передача данных сервер → браузер»Самая частая проблема SSR: данные загружаются на сервере, затем браузер делает те же запросы снова. TransferState решает это:
import { Injectable, inject } from '@angular/core';import { HttpClient } from '@angular/common/http';import { TransferState, makeStateKey } from '@angular/core';import { tap } from 'rxjs/operators';import { of } from 'rxjs';
// Создаём ключи для TransferStateconst USERS_KEY = makeStateKey<User[]>('users');const USER_KEY = (id: string) => makeStateKey<User>(\`user-\${id}\`);
@Injectable({ providedIn: 'root' })export class UsersService { private http = inject(HttpClient); private transferState = inject(TransferState);
getUsers(): Observable<User[]> { // Браузер: проверяем, есть ли данные от SSR if (this.transferState.hasKey(USERS_KEY)) { const users = this.transferState.get(USERS_KEY, []); this.transferState.remove(USERS_KEY); // Удаляем после использования return of(users); }
// Сервер (или браузер без кеша): делаем запрос return this.http.get<User[]>('/api/users').pipe( tap(users => { // На сервере сохраняем в TransferState this.transferState.set(USERS_KEY, users); }) ); }}isPlatformBrowser / isPlatformServer
Заголовок раздела «isPlatformBrowser / isPlatformServer»Некоторый код нельзя выполнять на сервере (localStorage, window, document):
import { isPlatformBrowser, isPlatformServer } from '@angular/common';import { PLATFORM_ID, inject } from '@angular/core';
@Injectable({ providedIn: 'root' })export class StorageService { private platformId = inject(PLATFORM_ID);
get(key: string): string | null { if (isPlatformBrowser(this.platformId)) { return localStorage.getItem(key); } return null; // На сервере localStorage нет }
set(key: string, value: string): void { if (isPlatformBrowser(this.platformId)) { localStorage.setItem(key, value); } }}
// В компоненте@Component({...})export class SomeComponent implements OnInit { private platformId = inject(PLATFORM_ID);
ngOnInit() { if (isPlatformBrowser(this.platformId)) { // Только в браузере this.initIntersectionObserver(); this.startAnimation(); } }}Hydration — умное восстановление DOM
Заголовок раздела «Hydration — умное восстановление DOM»Angular 17+ поддерживает полную гидрацию. Вместо того чтобы перерисовывать DOM после загрузки JS, Angular “подключается” к существующему HTML:
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = { providers: [ provideRouter(routes), provideClientHydration(), // ← Включаем гидрацию // Опционально: withEventReplay() для записи событий до гидрации ]};SEO — мета-теги для SSR
Заголовок раздела «SEO — мета-теги для SSR»import { Injectable, inject } from '@angular/core';import { Meta, Title } from '@angular/platform-browser';
@Injectable({ providedIn: 'root' })export class SeoService { private title = inject(Title); private meta = inject(Meta);
setPage(config: { title: string; description: string; image?: string; url?: string; }) { this.title.setTitle(\`\${config.title} | МойСайт\`);
this.meta.updateTag({ name: 'description', content: config.description });
// Open Graph для соцсетей this.meta.updateTag({ property: 'og:title', content: config.title }); this.meta.updateTag({ property: 'og:description', content: config.description }); if (config.image) { this.meta.updateTag({ property: 'og:image', content: config.image }); } if (config.url) { this.meta.updateTag({ property: 'og:url', content: config.url }); }
// Twitter Cards this.meta.updateTag({ name: 'twitter:card', content: 'summary_large_image' }); this.meta.updateTag({ name: 'twitter:title', content: config.title }); }}
// Использование в компоненте@Component({...})export class ProductComponent implements OnInit { private seo = inject(SeoService);
ngOnInit() { this.seo.setPage({ title: 'iPhone 15 Pro', description: 'Купить iPhone 15 Pro по лучшей цене', image: 'https://mysite.com/iphone15.jpg', }); }}Сборка и запуск SSR
Заголовок раздела «Сборка и запуск SSR»npm run build:ssr# илиng build && ng run app:server
# Запуск SSR сервераnode dist/server/server.mjs
# В package.json{ "scripts": { "build:ssr": "ng build && ng run app:server", "serve:ssr": "node dist/myapp/server/server.mjs", "dev:ssr": "ng run app:serve-ssr" }}Prerender — статическая генерация
Заголовок раздела «Prerender — статическая генерация»// angular.json — добавляем prerender target{ "prerender": { "builder": "@angular-devkit/build-angular:prerender", "options": { "routes": [ "/", "/about", "/products", "/blog" ] } }}