Привет, меня зовут Дмитрий Дружков, я тимлид фронтенд команды в Утконос Онлайн. В этой статье я расскажу, чем полезен Angular Universal в e-commerce проектах, как выбрать вид рендеринга, как выглядит первоначальная настройка технологии на примере нашего сайта и шаги по ускорению, а также раскрою плюсы и минусы Universal. Статья будет интересна тем, кто:

  • не знает SSR и делает сайт с нуля,

  • знаком с Angular и хочет научиться делать SSR,

  • хочет получить общее представление по настройке Angular Universal,

  • а также крупным проектам с большим количеством динамических страниц, которым важно SEO.

Про Angular Universal написано довольно много, но мало примеров настройки в больших e-commerce проектах, где есть свои нюансы. Часть решений была сделана нами, часть собрана по разным ресурсам, поэтому хочу поделиться комплексным взглядом на создание SSR.

Зачем нам понадобился Angular Universal 

Обычный Angular отдает браузеру клиента базовую index.html страницу, которая с точки зрения SEO пустая — там есть только тег app-root и сcылки на скрипты и стили. 

Интересно, что поведение поисковых роботов Яндекс и Google сильно отличается. Google умеет выполнять JavaScript код: он получает страницу,  выполняет всю клиентскую логику, делает api вызовы, рендерит страницу и индексирует её. Мы же во многом ориентируемся на Яндекс, который этого не делает. Он получает index.html и принимает его, как есть. Так что нам было важно использовать Universal.

Добавление Angular Universal

Добавить Angular Universal в проект довольно просто, процесс хорошо описан в официальной документации:

  • Выполните ng add @nguniversal/express-engine

Давайте посмотрим, что изменится в проекте после успешного выполнения команды. 

В проекте появятся новые файлы:

  • server.ts – логика express сервера

  • tsconfig.server.json – конфиг TS для SSR

  • src/main.server.ts 

  • src/app/app.server.module.ts

В angular.json будут добавлены:

  • В projects > utkonos > architect > build > options > outputPath путь будет изменён с "dist/utkonos" на «dist/utkonos/browser»

  • "server"   

  • "serve-ssr"

  • "prerender"

В package.json будут добавлены новые скрипты:

  • "dev:ssr": "ng run utkonos:serve-ssr" – dev сборка для разработки,

  • "serve:ssr": "node dist/utkonos/server/main.js" – запуск сервера SSR,

  • "build:ssr": "ng build && ng run utkonos:server" – сборка SSR,

  • "prerender": "ng run utkonos:prerender" – пререндер страниц

В main.ts поменяется логика загрузки AppModule.

В app-routing.module.ts добавляется правило initialNavigation: 'enabledBlocking' — обязательный для SSR параметр, без него при старте приложения не будут работать хуки жизненного цикла роутинга.

В app.modules.ts добавляется параметр к BrowserModule: withServerTransition({ appId: 'serverApp' }) для работы с TransferState’ом — о нём я расскажу ниже.

Теперь можно запустить dev-сборку командой npm run dev:ssr и, если ошибок нет, то по адресу http://localhost:4200 откроется ваш сайт. Чтобы проверить, что SSR работает выключите в браузере на странице JavaScript и перезагрузите страницу — так будет выглядеть сайт, сгенерированный node.js, каким его увидит поисковый робот — страница на чистом HTML и CSS без JavaScript.

Работа c DOM в Angular Universal

В отличии от браузера в среде node.js нет DOM, и потому любые манипуляции с ним в приложении вызовут ошибку. Решить это нам поможет Domino https://www.npmjs.com/package/domino, он эмулирует работу с DOM API. Добавим его в server.ts:

// SSR DOM
import { domino } from 'domino';
Object.assign(global, domino.impl);

import { fs } from 'fs';

// index from browser folder
const browserFolder = join(__dirname, '../browser');
const template = fs.readFileSync(join(browserFolder, 'index.html')).toString();
const window = domino.createWindow(template);

// mock
global['window'] = window;
global['document'] = window.document;

Также необходимо исключить прямые обращения ко всем браузерным api и реализовать их через InjectionToken. Замечу, что такой подход желателен и без использования SSR, т.к. за счёт абстракции даёт нам гибкость разработки, чистоту кода и тестируемость.

Посмотрим, как это сделать на примере window.

Создайте файл window.ts и добавьте в него определение токена WINDOW:

Window.ts

import { DOCUMENT } from '@angular/common';
import { inject, InjectionToken } from '@angular/core';
 
export const WINDOW = new InjectionToken<Window>('An abstraction over global window object', {
  factory: () => {
    const { defaultView } = inject(DOCUMENT);
 
    if (!defaultView) {
      throw new Error('Window is not available');
    }
 
    return defaultView;
  },
});

И далее в компоненте добавьте зависимость:

 constructor(
    @Inject(WINDOW) private window: Window,
 ) {}

Работа с API бэкенда

Браузер может отправлять api запросы только на тот же домен, это обусловлено политикой безопасности. Маршрутизацию запросов к бэкенду можно настроить, например, через nginx. Если это не сделано, то все запросы от клиента принимает express сервер. Чтобы их получал api сервер, добавим мидлвару с прокси. Мы используем библиотеку express-http-proxy

import * as proxy from 'express-http-proxy';
 
// From environment variables
const apiUrl = ConfigHelper.getApiURLForServer();
 
// The Express app is exported so that it can be used by serverless Functions.
export function app(): express.Express {
  const server = express();
 
  // Proxy to back for client requests
  server.use('/api', proxy(apiUrl, { proxyReqPathResolver: (req: Request) => 
    '/api' + req.url
  }));

Статика

Для работы Angular Universal нужно подготовить статичные файлы. 

Сборка происходит по команде npm run build:ssr, она прописана в package.json. Для серверного и браузерного клиента выполняются отдельные сборки: ng build && ng run utkonos:server. 

Расположение папок со статикой указывается в angular.json: 

  • для браузера projects > utkonos > architect > build > options > OutputPath

  • для сервера projects > utkonos > architect > server > options > OutputPath

После сборки проекта мы получаем две папки browser и server. Структура базового проекта выглядит следующим образом:

Файл server/main.js используется node.js для рендеринга страниц. Файлы в папке browser используются клиентом.

Чтобы клиентский браузер смог получить статичные файлы из папки browser, нам нужно добавить мидлвар:

  // Serve static files from /browser
  server.get(
    '*.*',
    express.static(browserFolder, {
      maxAge: '1y',
    })
  );

Ускорение Angular Universal

Выбор варианта рендеринга

Давайте посмотрим возможные виды рендеринга:

  • SSR (server-side rendering) — при запросе страница рендерится на сервере каждый раз, выполняет api запросы и пользователь получает страницу с актуальной информацией.

  • SSG (static site generation) — страница рендерится заранее при сборке и отдаётся при обращении по роутингу.

  • CSR (client-side rendering) — рендеринг на стороне клиента, когда открывается сайт.

  • ISR (incremental static regeneration) — объединяет возможности SSR и SSG: при обращении пользователя страница рендерится на стороне сервера, кэшируется и отдаётся пользователю. При следующем запросе этой страницы она будет отдана из кэша.

Выбор вида рендеринга зависит от характера сайта.

Если у вас на сайте контент меняется часто, то подойдёт SSR. Но надо учитывать, что для этого требуются большие вычислительные ресурсы.

На сайте, где страницы меняются редко, и их количество заранее известно, можно выбрать SSG. Плюсом этого решения является то, что веб-сервер просто отдаёт статичные страницы, ресурсов для этого требуется немного. Минус — при изменении контента страниц придется снова запускать рендер.

E-commerce сайт содержит очень большое число страниц (у нас их несколько десятков тысяч), их число меняется, а контент постоянно обновляется. Поэтому мы выбрали подход ISR — это позволяет минимизировать серверную нагрузку и при этом поддерживать контент в актуальном состоянии. Про настройку кэширования будет дальше в статье.

Разделение логики клиент/сервер в TypeScript

В проекте иногда нужно разделить логику для SSR и для клиента. Например, когда для SEO не важна часть бизнес-логики, и мы можем сэкономить на её выполнении. 

Для определения среды выполнения можно использовать встроенные методы isPlatformBrowser и isPlatformServer.

Для удобства можно создать сервис и провайдить его при необходимости:

import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
 
@Injectable({ providedIn: 'root' })
export class BrowserApiService {
  private _isBrowser = isPlatformBrowser(this.platformId);
  private _isServer = isPlatformServer(this.platformId);
 
  constructor(
    @Inject(PLATFORM_ID) private platformId: object,
  ) {
  }
 
  isBrowser(): boolean {
    return this._isBrowser;
  }
 
  isServer(): boolean {
    return this._isServer;
  }
}

Разделение логики клиент/сервер в HTML

Бывает, что для SSR не нужно отображать какой-то HTML или компоненты, для этого удобно сделать структурную директиву:

import { Directive, TemplateRef, ViewContainerRef } from '@angular/core';
import { BrowserApiService } from '@shared/services/browser-api.service';
 
@Directive({
  selector: '[clientSideRendering]',
})
export class ClientSideRenderingDirective {
  constructor(private viewContainer: ViewContainerRef, private browserApi: BrowserApiSupportService, private tplRef: TemplateRef<any>) {
    this.viewContainer.clear();
    if (this.browserApi.isBrowser()) {
      this.viewContainer.createEmbeddedView(this.tplRef, {});
    }
  }
}

TransferState

Для рендера страницы на node.js выполняются api запросы, аналогичные тем, что выполняются в браузере у клиента. Чтобы сэкономить время и ресурсы, можно сохранить результаты api вызовов и использовать их в браузере. 

Это позволяет сделать TransferState: для его работы нужно два интерсептора — серверный и браузерный. На стороне сервера выполненные api запросы сохраняются, а на стороне клиента запрос сперва проверяется в кэше и возвращается как ответ, если он есть.

На стороне клиента закэшированные запросы приходят в теле intex.html в теге <script id="serverApp-state" type="application/json"> в виде объекта {ключ запроса: ответ запроса}   

Более подробно о настройке я расскажу как-нибудь отдельно.

Кэширование рендера

Для ускорения работы сервиса очень важно кэшировать результаты рендера и api вызовов. В этом плане есть интересное решение для ISR https://scully.io. Мы в Утконос используем Kubernetes для среды развёртывания. Одновременно поднимаются несколько подов node.js с экземплярами Angular Universal, поэтому индивидуальный кэш неэффективен. Для хранения кэша мы используем сервис Redis. 

Логика такая, что страница по GET запросу сперва проверяется в кэше, и только если её нет — рендерится, а затем сохраняется в кеш.  

Аналогично с api запросами. Дело в том, что все запросы на SSR выполняются из-под гостевой учётной записи, и ответы одинаковых запросов эквиваленты. Поэтому перед выполнением api запроса с node.js к бэкенду он сперва также проверяется в кеше.

При помощи Redis мы реализуем подход ISR для хранения отрендеренных страниц.

Настройку и работу с Redis я опишу в другой статье, это большая и интересная тема.

Кеширование статики в CDN

На сервер помимо GET запросов на открытие страниц приходит много запросов на статику: JS, CSS файлы, картинки и тд.

Чтобы снизить нагрузку на веб-сервер и увеличить скорость получения файлов клиентом, нужно все ресурсы кэшировать в CDN сервисе. Мы кэшируем там все статичные файлы. Настройка в CDN тоже заслуживает отдельного внимания. Кажется, у меня набирается ряд тем для будущих материалов) 

Подписки RxJS

При работе с RxJS важно не забывать отписываться от источников событий. Если для браузерного клиента это не так критично, и тормозящую вкладку можно перезагрузить, то в среде node.js это сделать не так просто. 

Неудалённые подписки остаются в памяти, и сборщик мусора не может их удалить, поэтому критично важно следить за отписками. Делать это нужно в хуке жизненного цикла OnDestroy. 

Вариантов отписки много, каждая команда выбирает свой путь. 

Если у вас возникла утечка памяти, то в качестве временного решения можно перезагружать процесс node.js, если он достиг установленного лимита. К примеру, в Kubernetes пода пересоздаётся, если отслеживаемый процесс перестаёт отвечать. Поэтому можно сделать мониторинг за используемой памятью:

  private runMonitoring(): void {
    setInterval(() => this.checkNodeHealth(), this.nodeJSMonitoringInterval);
  }
  
  private checkNodeHealth(): void {
    const heapUsed = this.getHeapUsed();
    if (heapUsed > this.heapLimit) {
      process.exit();
    }
  }
  
  private getHeapUsed(): number {
    return process.memoryUsage().heapUsed / 1024 / 1024;
  }

Но помните, что это временное решение, и надо искать причину утечки памяти. В этом может помочь Chrome DevTools — есть хорошая статья Fix memory problems

Сжатие трафика

Необходимо оптимизировать передаваемый пользователю контент, подвергая его компрессии. Для этого мы используем compression, эта процедура хорошо описана в статье Minify and compress network payloads

import * as compression from 'compression';

export function app(): express.Express {
  const server = express();
  server.use(compression());

Фильтр урлов

Часть страниц не важна для SEO (например, корзина), поэтому их не нужно рендерить. Определите список таких адресов и отдавайте пользователям дефолтный index.html.

Ускорение Angular

Скорость работы страницы приложения отражается на скорости рендеринга в SSR. При начале рендеринга создаётся экземпляр приложения, выполняются нужные api запросы, рендерится HTML. После наступления стабильности приложения страница готова. На наступление стабильности влияют рендеринг вёрстки и асинхронные операции (setTimeout, api запросы), запущенные в зоне.  

Если время рендера неудовлетворительное, то надо заниматься оптимизацией работы приложения. В анализе проблем следует использовать средства Chrome DevTools. По этой теме есть много хороших статей: можно начать с официальной документации по анализу производительности Analyze runtime performance и WebVitals Web Vitals: готовимся к изменению алгоритма Google в июне 2021

Ещё следует провести анализ загрузки сторонних скриптов. К примеру, мы загружаем скрипты подрядчиков по чат-боту и рекламе, для аналитики Google Tag Manager. Если загружаемый скриптами контент не важен для SEO, можно отключить их при рендеринге на сервере. К примеру, мы не загружаем их в SSR.

Плюсы и минусы Angular Universal

Angular Universal даёт нам полноценный инструмент для работы с SEO, сайт становится заметен в интернете, а компания получает новых клиентов. Чтобы сайт хорошо ранжировался после настройки SSR, нужно использовать семантическую вёрстку, настраивать доступность, правильно расставлять теги, улучшать Web Vitals и много чего еще — это отдельная большая работа.

Ваш проект можно начать делать без Angular Universal и добавить его позже. Но в этом случае сделать это будет сложнее, т.к. разработка приложения только под браузер предполагает, что всегда есть window, localstorage и прочие api браузера. Разработка же с учётом SSR заставляет делать дополнительные проверки и абстракции от конкретных сущностей.

При правильном подходе в рендеринге можно значительно ускорить работу SSR. При ISR подходе, если страница пререндерена, и в кэше, то пользователь получает ее быстро, и в итоге получаем баланс в использовании ресурсов. 

Плюс у нас появляется возможность экономить ресурсы бэкенда тем, что кешируем api запросы, сделанные в SSR, при помощи TransferState. 

Есть и недостатки.

Например, когда клиент получает пререндеренную страницу, она представляет из себя статичный HTML файл, с которым пользователь не может взаимодействовать. Потом весь статичный контент внутри тега app-root удаляется, и происходит рендеринг привычного SPA. Этот процесс называется регидратация. Если содержимое нашей страницы зависит от получения внешних данных, и контент отличается от пререндера, то у пользователя может возникать эффект «моргания» страницы.

SSR очень чувствителен к утечкам памяти. Если в браузере пользователь может просто перезагрузить вкладку браузера и очистить память, то в среде node.js этого сделать сложнее. Поэтому важно следить за отписками в RxJS. 

Подход ISR не идеален: например, первый запрос пользователя также будет долгим. Зато он комбинирует возможности SSR и SSG, что является хорошим решением для большого сайта.

Еще отмечу, что Angular Universal является единственным решением для SSR и часто для настройки SSR в проекте нужно приложить немало усилий.

В качестве вывода

Angular Universal является рабочим решением для большого e-commerce сайта, он даёт SEO и некоторое ускорение для пользователей. Сам Universal требует улучшений в настройке, и я надеюсь, что команда Angular приложит все усилия, чтобы сделать его идеальным. 

В статье я затронул несколько интересных и обширных тем по работе с кэшем в Redis и настройке хранения статичных ресурсов в CDN — этому я посвящу свои будущие материалы. Спасибо, и до новых встреч!)

Комментарии (4)


  1. druzhkov_dv Автор
    28.04.2022 20:43
    +1

    Да, при сборке приложения для сервера получается 1 большой бандл. Хотя это не выглядит большой проблемой, тк он не передаётся куда то по сети в отличии от клиентского.


  1. bagger
    28.04.2022 23:03
    +1

    Спасибо, как раз находился в процессе принятия решения о том, стоит связываться с SSR для коммерческого сайта, вы меня убедили )


  1. alexbraun
    28.04.2022 23:18
    +1

    В этом году тоже использовал Angular Universal в своем проекте. Мне понравилось)


  1. viladimiru
    28.04.2022 23:30

    Я бы к минусам еще приписал отвратный tree shaking (точнее, его отсутствие) при подготовке SSR модулей. При использовании сторонних библиотек приходится серьёзно приглядываться к их легковесности, ибо после сборки модуля лишнее с библиотеки в принципе не отсекается. При этом с CSR модулях, судя по всему, таких проблем нет.