Сейчас SPA-приложения стали фактически стандартом. У большей части веб-приложений фронт написан либо на Vue, либо на React, либо на Angular. Но иногда возникают ситуации, когда нам нужно отреднерить наш HTML на сервере. В этом посте я опишу мой опыт с Angular Universal.
Прежде всего, зачем нужно делать серверный рендеринг? В моём случае, заказчику было нужно, чтоб при подставке URL страниц в мессенджеры и соц. сети, автоматически подтягивалась картинка и некое описание. Для этого, как известно, должны быть заполнены og-метатеги. Но SPA-приложение выдаёт в браузер стандартный и совершенно бесполезный для роботов соц. сетей и мессенджеров HTML. Например, у этого блога это будет выглядеть так при отключении серверного рендеринга:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Блог Михаила Крамера. PHP и JS</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="https://www.google.com/recaptcha/api.js?render=explicit&onload=initRecaptcha" async="" defer=""></script>
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-47046988-1"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
</script>
<link rel="stylesheet" href="styles.css"></head>
<body>
<app-root></app-root>
<script src="runtime.js" defer></script><script src="polyfills.js" defer></script><script src="styles.js" defer></script><script src="vendor.js" defer></script><script src="main.js" defer></script></body>
</html>
Т.е. единственное отличие от стандартного index.html Angular'а - подключение аналитики от гугла, что никак не поможет сделать какое-то превью. Плюс до сих пор не все поисковые системы хорошо индексируют динамически создаваемый контент. Насколько мне известно, сейчас JavaScript умеет исполнять только Google. И вот тут на помощь приходит серверный рендеринг, в нашем случае его реализация от разработчиков Angular под названием Angular Universal
Суть процесса можно описать достаточно просто: мы заставляем на сервере крутиться node.js скрипт, который будет вызывать все наши Angular-компоненты под капотом и генерировать нам красивую вёрстку, в которую мы с помощью нескольких сервисов можем подставить все метатеги, которые нам нужны. Итак, предположим (хотя с крупными проектами лучше так не делать), как в моём случае, вам надо добавить Angular Universal в существующий проект. В соответствии с официальным руководством от разработчиков всё выглядит очень просто. Вам нужно всего лишь дать одну команду в папке проекта:
ng add @nguniversal/express-engine
После этого некоторое время будет происходить "магия", по окончанию процесса вы даёте ещё одну команду:
npm run dev:ssr
Всё, в идеальном мире после этого у вас крутое SSR приложение, висит на http://localhost:4200/, и с помощью ещё одной простой команды build:ssr вы радостно сделаете сборку для сервера. Однако мы живём не в идеальном мире, и вот с чем пришлось после запуска столкнуться мне:
-
Часть использованных библиотек не хотело собираться на бэке, в консоль летела куча мата, гугл ничего путного не выдавал. В общем, долго сидеть и разбираться с этим не хотелось, благо, при формировании вёрстки на сервере они все мне были нафиг не нужны, необходимость их вызова возникала только после определённых действий посетителя в браузере. Поэтому я убрал из кода все import и require для этих библиотек, и перенёс подключение уже собранных их версий в angular.json. Конечно, в результате мне пришлось везде, где я их использую, расставить declare const lib: any. Можно было поступить и чище, и расписать интерфейсы в заголовочных файлах Type Script, но главная цель была достигнута - все библиотеки были спрятаны от сборщика серверной части.
-
А с этим я уже столкнулся при переводе на Angular Universal данного блога. Не все UI-библиотеки поддерживают серверный рендеринг. Например, PrimeNG, на которой сделан бложик, в коде компонентов использует много браузерно-зависимых инструкций, которые при рендеринге на сервере кидают кучу мата, к счастью не прерывая сам процесс, и HTML-ка доходит до клиента. Поскольку этот блог - не коммерческий проект, я счёл возможным просто заткнуть логи на сервере через настройки PM2, но на реальных проектах так конечно поступить не выйдет, так что надо заранее смотреть, чтобы выбранная UI-библиотека поддерживала Universal.
- Ну и на последок, у вас может быть куча браузеро-зависимого кода в ваших компонентах
Работа с браузеро-зависимым кодом
И так, что же делать, если вам прямо-таки необходимо использовать в своём проекте браузеро-зависимый код, который только мешается на сервере. Тут два подхода. Можно перекрыть на сервере глобальные объекты, такие как sessionStorage, с которым я поступаю следующим образом. В файле main.server.ts добавляю такой код:
global.sessionStorage = {
getItem(key: string): string | null {
return undefined;
},
key(index: number): string | null {
return undefined;
}, length: 0, removeItem(key: string): void {
}, setItem(key: string, value: string): void {
}, clear(): void {
}
}
Т.е. идея должна быть понятна - теперь на сервере есть такой-себе бесполезный sessionStorage, и выполнение серверной части проходит без ошибок по его поводу. Другой подход - использовать аналоги Angular для тех объектов, которыми мы обычно пользуемся из браузерного окружения. К примеру, не использовать window.location, а воспользоваться соответствующим классом Angular.
И наконец третье решение, которым разработчики фреймворка не рекомендуют пользоваться, - это проверять, где необходимо, мы на сервере или на клиенте. Для этого надо заинжектить в ваш компонент объект PLATFORM_ID и воспользоваться функцией isPlatformBrowser
import {Component, OnInit, Inject, PLATFORM_ID} from '@angular/core';
import {isPlatformBrowser} from "@angular/common";
@Component({
selector: 'app-some',
templateUrl: './some.component.html',
styleUrls: ['./some.component.scss']
})
export class StoryComponent implements OnInit {
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
}
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
alert("Уря!!! Мы в браузере");
}
}
}
Авторизированные запросы
Следующий пункт - авторизированные запросы. Как нам передать JWT-токен или куки на сервер? Особенно, если бэкенд написан не на JS, а, к примеру, на Java. К счастью, Angular, в отличие, к примеру, от Nuxt.js для Vue, позволяет нам этого просто не делать. Во-первых, у ангуляра нету требований, чтобы сервер и клиент генерили одинаковую вёрстку (оно есть у VueSSR, на котором основан Nuxt, и добавляет немало проблем, с которыми я столкнулся, когда этот блог работал на его основе). Во-вторых, а нафига нам вообще серверный рендеринг для частей приложения, которые доступны только после аутентификации? Поисковики и мессенджеры туда всё равно не пролезут, и можно радостно выдать браузеру стандартный HTML. Сделать это очень просто, слегка подправив стандартный node-код, который генерируется Angular в файле server.ts:
import 'zone.js/dist/zone-node';
import { ngExpressEngine } from '@nguniversal/express-engine';
import * as express from 'express';
import { join } from 'path';
import { AppServerModule } from './src/main.server';
import { APP_BASE_HREF } from '@angular/common';
import { existsSync } from 'fs';
import * as fs from "fs";
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
const server = express();
const distFolder = join(process.cwd(), 'dist/project-name/browser'); // Тут валяется браузерная сборка
const indexHtml = existsSync(join(distFolder, 'index.original.html')) ? 'index.original.html' : 'index';
// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
server.engine('html', ngExpressEngine({
bootstrap: AppServerModule,
}));
server.set('view engine', 'html');
server.set('views', distFolder);
// Example Express Rest API endpoints
// server.get('/api/**', (req, res) => { });
// Serve static files from /browser
server.get('*.*', express.static(distFolder, {
maxAge: '1y'
}));
// All regular routes use the Universal engine
server.get('*', (req, res) => {
const serverRender: boolean = req.path.indexOf("cabinet") == -1; // Урлы со словом cabinet не будут рендериться на сервере, а будет выдаваться стандартный HTML
if (serverRender) {
res.render(indexHtml, {req, providers: [{provide: APP_BASE_HREF, useValue: req.baseUrl}]});
} else {
let filePath = join(distFolder, indexHtml);
if (!filePath.endsWith(".html")) {
filePath += ".html";
}
res.type("html").send(fs.readFileSync(filePath));
}
});
return server;
}
function run(): void {
const port = process.env.PORT || 4000;
// Start up the Node server
const server = app();
server.listen(port, () => {
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = mainModule && mainModule.filename || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {
run();
}
export * from './src/main.server';
На последок, для запуска на сервере приложения с Angular Universal. Для этого оказался очень удобным пакетный менеджер PM2