Блог Михаила Крамера. PHP и JS

Подключение серверного рендеринга Angular Universal

Сейчас 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&amp;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 вы радостно сделаете сборку для сервера. Однако мы живём не в идеальном мире, и вот с чем пришлось после запуска столкнуться мне:

  1. Часть использованных библиотек не хотело собираться на бэке, в консоль летела куча мата, гугл ничего путного не выдавал. В общем, долго сидеть и разбираться с этим не хотелось, благо, при формировании вёрстки на сервере они все мне были нафиг не нужны, необходимость их вызова возникала только после определённых действий посетителя в браузере. Поэтому я убрал из кода все import и require для этих библиотек, и перенёс подключение уже собранных их версий в angular.json. Конечно, в результате мне пришлось везде, где я их использую, расставить declare const lib: any. Можно было поступить и чище, и расписать интерфейсы в заголовочных файлах Type Script, но главная цель была достигнута - все библиотеки были спрятаны от сборщика серверной части.

  2. А с этим я уже столкнулся при переводе на Angular Universal данного блога. Не все UI-библиотеки поддерживают серверный рендеринг. Например, PrimeNG, на которой сделан бложик, в коде компонентов использует много браузерно-зависимых инструкций, которые при рендеринге на сервере кидают кучу мата, к счастью не прерывая сам процесс, и HTML-ка доходит до клиента. Поскольку этот блог - не коммерческий проект, я счёл возможным просто заткнуть логи на сервере через настройки PM2, но на реальных проектах так конечно поступить не выйдет, так что надо заранее смотреть, чтобы выбранная UI-библиотека поддерживала Universal.

  3. Ну и на последок, у вас может быть куча браузеро-зависимого кода в ваших компонентах

Работа с браузеро-зависимым кодом

И так, что же делать, если вам прямо-таки необходимо использовать в своём проекте браузеро-зависимый код, который только мешается на сервере. Тут два подхода. Можно перекрыть на сервере глобальные объекты, такие как 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

Ваш комментарий
Комментарии