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

48. SSR и Angular Universal

Angular Universal — это технология SSR (Server-Side Rendering) для Angular. Вместо того чтобы отправлять браузеру пустой HTML и ждать загрузки JS, сервер возвращает готовый HTML. Это критично для SEO, производительности и First Contentful Paint 🚀


SPA (CSR)SSR
Первый контентПосле загрузки JS (~3-8с)Сразу (~0.3-1с)
SEOПроблемно (боты не ждут JS)Отлично
Social sharingПустые превьюПолные метатеги
Low-end devicesМедленноБыстро
Time to InteractiveПосле гидрацииПосле гидрации

Окно терминала
ng add @angular/ssr

Команда создаёт:

  • server.ts — Express-сервер
  • Обновляет angular.json с server target
  • Обновляет app.config.ts и добавляет app.config.server.ts

src/
├── app/
│ ├── app.config.ts ← Конфиг для браузера
│ └── app.config.server.ts ← Конфиг для сервера
├── main.ts ← Браузерный бутстрап
└── main.server.ts ← Серверный бутстрап
server.ts ← Express сервер

server.ts
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
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 решает это:

services/users.service.ts
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';
// Создаём ключи для TransferState
const 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);
})
);
}
}

Некоторый код нельзя выполнять на сервере (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();
}
}
}

Angular 17+ поддерживает полную гидрацию. Вместо того чтобы перерисовывать DOM после загрузки JS, Angular “подключается” к существующему HTML:

app.config.ts
import { provideClientHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(), // ← Включаем гидрацию
// Опционально: withEventReplay() для записи событий до гидрации
]
};

services/seo.service.ts
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',
});
}
}

Окно терминала
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"
}
}

// angular.json — добавляем prerender target
{
"prerender": {
"builder": "@angular-devkit/build-angular:prerender",
"options": {
"routes": [
"/",
"/about",
"/products",
"/blog"
]
}
}
}