Angular Universal SSR implementacija

Kodėl apskritai kalbame apie SSR?

Prieš kelerius metus, kai dar dirbau su pirmuoju rimtesniu Angular projektu, klientas atėjo su problema – Google tiesiog nematė jų puikiai sukurtos aplikacijos. Viskas veikė naršyklėje, bet SEO specialistai plėšėsi plaukus. Tuomet pirmą kartą susidūriau su Angular Universal ir serverio pusės renderinimu.

Serverio pusės renderinimas (SSR) iš esmės reiškia, kad jūsų Angular aplikacija pirmiausia sugeneruoja HTML turinį serveryje, o ne kliento naršyklėje. Kai vartotojas ar paieškos roboto botas atidaro jūsų puslapį, jie iš karto gauna pilnai suformuotą HTML, o ne tuščią `index.html` su `` viduje.

Praktiškai tai sprendžia tris didžiausias Single Page Applications problemas: prastas SEO, lėtas pirminis puslapio įkėlimas ir problemas su social media preview. Kai Facebook ar Twitter bando parodyti jūsų puslapio nuotrauką, jie nepaleidžia JavaScript – tiesiog skaito HTML. Be SSR jie mato… nieko.

Kaip veikia Angular Universal po gaubtu

Angular Universal nėra kažkokia magija, nors kartais taip atrodo. Iš esmės tai Node.js serveris, kuris sugeba paleisti jūsų Angular aplikaciją serveryje ir sugeneruoti statinį HTML. Procesas atrodo maždaug taip:

  • Vartotojas užklausia puslapį
  • Node.js serveris gauna užklausą
  • Serveris paleidžia Angular aplikaciją atmintyje
  • Angular sugeneruoja HTML stringą
  • Serveris grąžina pilnai suformuotą HTML
  • Naršyklė gauna turinį ir jį atvaizduoja
  • Fone atsisiunčia JavaScript ir „hidratuoja” aplikaciją

Tas paskutinis žingsnis – hidratacija – yra kritinis. Kai JavaScript pakrovimas baigiasi, Angular „prigyja” prie jau esančio HTML ir aplikacija tampa interaktyvi. Nuo Angular 16 versijos hidratacija tapo daug efektyvesnė, nes framework’as nebeperkuria viso DOM medžio, o tiesiog prisijungia prie esamo.

Vienas dalykas, kuris man asmeniškai užtruko suprasti – SSR nepakeičia jūsų aplikacijos į statinį puslapį. Tai vis dar pilnavertė SPA, tik su greitesniu pirminiu įkėlimu ir geresniu SEO.

Praktinis setup žingsnis po žingsnio

Gerai, užteks teorijos. Paimkime realų projektą ir įdiekime SSR. Tarkime, turite esamą Angular aplikaciją (jei ne, `ng new my-app` padės).

Pirmiausia, Angular 17+ versijose procesas supaprastėjo iki vienos komandos:

„`bash
ng add @angular/ssr
„`

Ši komanda atlieka visą sunkų darbą: prideda reikalingus packages, sukuria `server.ts` failą, modifikuoja `angular.json` ir net sukuria `app.config.server.ts`. Senesnėse versijose reikėdavo rankiniu būdu konfigūruoti `@nguniversal/express-engine`, bet dabar viskas automatizuota.

Po šios komandos jūsų projekto struktūra pasikeis. Pamatysite naują `server.ts` failą šakniniame kataloge. Jame yra Express serverio konfigūracija:

„`typescript
export function app(): express.Express {
const server = express();
const distFolder = join(process.cwd(), ‘dist/my-app/browser’);
const indexHtml = join(distFolder, ‘index.html’);

const commonEngine = new CommonEngine();

server.set(‘view engine’, ‘html’);
server.set(‘views’, distFolder);

server.get(‘*.*’, express.static(distFolder, {
maxAge: ‘1y’
}));

server.get(‘*’, (req, res, next) => {
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: req.originalUrl,
publicPath: distFolder,
providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }],
})
.then((html) => res.send(html))
.catch((err) => next(err));
});

return server;
}
„`

Nebijokite šio kodo – dažniausiai jums nereikės jo liesti. Bet svarbu suprasti, kad čia vyksta routing’as ir renderinimas.

Build ir deployment niuansai

Dabar build’inimas turi du žingsnius. Vietoj paprastos `ng build`, dabar naudojate:

„`bash
npm run build:ssr
„`

Arba tiesiog `ng build`, nes SSR konfigūracija dabar yra default. Ši komanda sukuria du build’us:

  • Browser build – normalus client-side bundle dist/my-app/browser kataloge
  • Server build – Node.js optimizuotas bundle dist/my-app/server kataloge

Lokaliai testuoti galite su:

„`bash
npm run serve:ssr
„`

Tai paleidžia jūsų Express serverį su SSR. Atidarykite naršyklę ir pažiūrėkite į page source (Ctrl+U). Turėtumėte matyti pilną HTML turinį, ne tuščią shell’ą.

Deployment’as priklauso nuo jūsų infrastruktūros. Jei naudojate Vercel ar Netlify, jie turi built-in Angular SSR palaikymą. Tiesiog nurodyti build komandą ir output direktoriją. Jei deploy’inate į AWS ar Google Cloud, jums reikės Node.js aplinkos, kuri galės paleisti jūsų Express serverį.

Vienas dalykas, kurį pastebėjau production’e – serveris gali naudoti nemažai atminties, ypač jei turite daug concurrent užklausų. Rekomenduoju pradėti nuo bent 512MB RAM ir monitorinti. Mes vienoje aplikacijoje turėjome memory leak, kol supratome, kad subscription’ai serveryje nebuvo tinkamai unsubscribe’inami.

Browser-only API ir kaip su jais gyventi

Štai čia prasideda tikrasis smagumas. Jūsų kodas dabar veikia dviejose aplinkose: naršyklėje IR Node.js serveryje. Problema ta, kad serveris neturi `window`, `document`, `localStorage`, ar bet kokių kitų browser API.

Pirmą kartą paleisdamas SSR gavau krūvą klaidų: „window is not defined”, „localStorage is not defined” ir panašiai. Klasikinis pavyzdys:

„`typescript
// Blogai – crashins serveryje
ngOnInit() {
const token = localStorage.getItem(‘token’);
this.windowWidth = window.innerWidth;
}
„`

Sprendimas – visada tikrinti platformą:

„`typescript
import { isPlatformBrowser } from ‘@angular/common’;
import { PLATFORM_ID, inject } from ‘@angular/core’;

export class MyComponent {
private platformId = inject(PLATFORM_ID);

ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
const token = localStorage.getItem(‘token’);
this.windowWidth = window.innerWidth;
}
}
}
„`

Arba dar geriau – naudokite Angular dependency injection sistemą. Sukurkite service’ą, kuris abstrahuoja browser API:

„`typescript
@Injectable({ providedIn: ‘root’ })
export class StorageService {
private platformId = inject(PLATFORM_ID);

getItem(key: string): string | null {
if (isPlatformBrowser(this.platformId)) {
return localStorage.getItem(key);
}
return null;
}

setItem(key: string, value: string): void {
if (isPlatformBrowser(this.platformId)) {
localStorage.setItem(key, value);
}
}
}
„`

Dar viena problema – trečiųjų šalių bibliotekos. jQuery, chart bibliotekos, Google Maps – dauguma jų tikisi browser aplinkos. Sprendimas – dynamic import su platformos tikrinimu:

„`typescript
async ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
const module = await import(‘some-browser-only-library’);
this.library = module.default;
}
}
„`

HTTP užklausos ir duomenų perdavimas

SSR kontekste HTTP užklausos yra įdomios. Serveris padaro užklausą į API, gauna duomenis, sugeneruoja HTML. Paskui naršyklė pakrauna JavaScript ir… padaro tą pačią užklausą dar kartą? Ne, jei teisingai sukonfigūruosite.

Angular Universal turi TransferState mechanizmą, kuris leidžia perduoti duomenis iš serverio į naršyklę be papildomų užklausų:

„`typescript
import { TransferState, makeStateKey } from ‘@angular/platform-browser’;

const DATA_KEY = makeStateKey(‘myData’);

@Injectable({ providedIn: ‘root’ })
export class DataService {
private transferState = inject(TransferState);
private http = inject(HttpClient);
private platformId = inject(PLATFORM_ID);

getData(): Observable {
// Patikriname ar duomenys jau yra transfer state
const cachedData = this.transferState.get(DATA_KEY, null);

if (cachedData) {
// Naršyklėje naudojame cached duomenis
this.transferState.remove(DATA_KEY);
return of(cachedData);
}

// Darome užklausą
return this.http.get(‘/api/data’).pipe(
tap(data => {
// Serveryje išsaugome duomenis
if (!isPlatformBrowser(this.platformId)) {
this.transferState.set(DATA_KEY, data);
}
})
);
}
}
„`

Šis pattern’as užtikrina, kad duomenys bus užkrauti tik vieną kartą. Serveris juos įdeda į HTML kaip `