Сайт визитка на Angular. Настройка SSR.

Aleksandr Serenko
F.A.F.N.U.R
Published in
9 min readMar 27, 2022

В данной статье поговорим о настройке Server Side Rendering (SSR), настройке prerender’а и небольшой оптимизации отдачи страниц в Angular.

SSR — представляет собой инструмент создания статичных страниц на сервере. Это позволяет отдавать поисковой системе или пользователю не пустую страницу с импортом javascript’ом, а готовую отрисованную страницу.

В Angular SSR реализуется с помощью пакета Universal. Установка пакета очень проста:

nx add @nguniversal/express-engine

Команда установит зависимости и добавит необходимые конфигурационные файлы.

Немного поправим структуру.

Создадим отдельный браузерный модуль AppBrowserModule:

Модуль будет подключать в себе AppModule, а также специфичные только для браузера модули.

AppServerModule останется без изменений:

Так как теперь есть два стартовых скрипта: main.ts и main.server.ts, то переименуем main.ts в main.browser.ts для однозначного трактования.

Main.browser.ts остается без изменений, кроме одного добавленного import’а — hammerjs:

Сам сервер, который рендерит статику располагается в apps/store/server.ts:

Из-за того, что приложение использует локализацию, то в путях необходимо указать локаль — ru-RU:

const distFolder = join(process.cwd(), 'dist/store/browser/ru-RU');

Так как в приложении есть метод оформления заказа, он также реализован с помощью данного сервера.

// API for send order
server.post('/api/order', (req, res) => {
// TODO: Add send order to mail
const id = Math.random().toString(36).slice(-6).toUpperCase();
return res.status(201).json({ id });
});

В данном случае, запросы на /api/order будут возвращать рандомную строку длинной в 6 символов, который будет являться “номером” заказа.

Также для корректной работы preprender’а была изменена логика отрисовки страниц.

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

server.get('*', (req, res) => {
const filePath = join(distFolder, req.path, 'index.html');

// For prerender, use exists file
if (existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.render(indexHtml, {
req,
providers: [
{
provide: APP_BASE_HREF,
useValue: req.baseUrl,
},
{
provide: REQUEST,
useValue: req,
},
{
provide: RESPONSE,
useValue: res,
},
],
});
}
});

Для того чтобы понять есть страница или нет, просто проверяется путь:

const filePath = join(distFolder, req.path, 'index.html');

Другими словами пререндер создаст на каждый путь файл index.html.

Для следующих путей:

/
/cart
/not-found
/product/classic-leather
/product/reebok-classic-leather
/product/reebok-instap
/product/reebok-keith-haring-gl
/product/reebok-lite
/product/reebok-nano
/product/reebok-ultra
/product/reebok-vb-dual-court
/product/reebok-zig-dynamica
/server-error
/support
/terms

Будет создана структура:

dist/apps/store/browser/ru
├── cart
│ └── index.html
├── index.html
├── index.original.html
├── not-found
│ └── index.html
├── product
│ ├── classic-leather
│ │ └── index.html
│ ├── reebok-classic-leather
│ │ └── index.html
│ ├── reebok-instap
│ │ └── index.html
│ ├── reebok-keith-haring-gl
│ │ └── index.html
│ ├── reebok-lite
│ │ └── index.html
│ ├── reebok-nano
│ │ └── index.html
│ ├── reebok-ultra
│ │ └── index.html
│ ├── reebok-vb-dual-court
│ │ └── index.html
│ └── reebok-zig-dynamica
│ └── index.html
├── server-error
│ └── index.html
├── support
│ └── index.html
└── terms
└── index.html

Еще отметим, что приложение передается пара токенов:

{
provide: REQUEST,
useValue: req,
},
{
provide: RESPONSE,
useValue: res,
},

Эти два токена используются только один раз при отрисовке NotFoundPage, где в ответ серверу указывается статус код, с которым должна вернуться страница.

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

Конфигурация приложения будет следующей:

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

В данном случае появилось несколько надстроек, которые позволяют запускать серверный рендеринг:

"server": {
"executor": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/store/server",
"main": "apps/store/server.ts",
"tsConfig": "apps/store/tsconfig.server.json",
"inlineStyleLanguage": "scss",
"localize": false
},
"configurations": {
"production": {
"localize": ["ru-RU"],
"outputHashing": "media",
"fileReplacements": [
{
"replace": "apps/store/src/environments/environment.ts",
"with": "apps/store/src/environments/environment.prod.ts"
}
],
"bundleDependencies": true
},
"development": {
"localize": ["ru-RU"],
"optimization": false,
"sourceMap": true,
"extractLicenses": false
},
"ru": {
"optimization": false,
"sourceMap": true,
"extractLicenses": false
}
},
"defaultConfiguration": "production"
},

И дев версию сервернгого рендеринга:

"serve-ssr": {
"executor": "@nguniversal/builders:ssr-dev-server",
"configurations": {
"development": {
"browserTarget": "store:build:development",
"serverTarget": "store:server:ru"
},
"production": {
"browserTarget": "store:build:production",
"serverTarget": "store:server:production"
}
},
"defaultConfiguration": "development"
},

Конфигурация пререндера будет следующей:

"prerender": {
"executor": "@nguniversal/builders:prerender",
"options": {
"routesFile": "apps/store/routes.txt",
"guessRoutes": false
},
"configurations": {
"production": {
"browserTarget": "store:build:production",
"serverTarget": "store:server:production"
},
"development": {
"browserTarget": "store:build:development",
"serverTarget": "store:server:development"
}
},
"defaultConfiguration": "production"
}

Для запуска serve-ssr используется команда:

nx run store:serve-ssr

Для сборки SSR приложения без пререндера:

nx build && ng run store:server

В scripts команда будет иметь alias: “build:ssr”: “nx build && ng run store:server”,

Для сборки приложения с пререндером:

nx run store:prerender

Соберем обычное приложение, запустив команду :

yarn build:ssr

В результате будет получены следующие файлы:

.
└── store
├── browser
│ └── ru-RU
│ ├── 167.bc54892e19f85744.js
│ ├── 167.bc54892e19f85744.js.map
│ ├── 198.c6aacc7ef62b4124.js
│ ├── 198.c6aacc7ef62b4124.js.map
│ ├── 1.d223a1576c9c2495.js
│ ├── 1.d223a1576c9c2495.js.map
│ ├── 333.8554084ee2179a7e.js
│ ├── 333.8554084ee2179a7e.js.map
│ ├── 3rdpartylicenses.txt
│ ├── 44.72cf2cf0a8316581.js
│ ├── 44.72cf2cf0a8316581.js.map
│ ├── 504.f436cc6d3ac5d9e7.js
│ ├── 504.f436cc6d3ac5d9e7.js.map
│ ├── 510.1873f0df7ade191f.js
│ ├── 510.1873f0df7ade191f.js.map
│ ├── 545.09b76b3b09c780c0.js
│ ├── 545.09b76b3b09c780c0.js.map
│ ├── 54.d47c21fb2819f501.js
│ ├── 54.d47c21fb2819f501.js.map
│ ├── 671.fedda49aebb1a683.js
│ ├── 671.fedda49aebb1a683.js.map
│ ├── 743.0dc793219f4fd6d7.js
│ ├── 743.0dc793219f4fd6d7.js.map
│ ├── 887.8f3ac684ac4f6537.js
│ ├── 887.8f3ac684ac4f6537.js.map
│ ├── 901.4acb494faee3af14.js
│ ├── 901.4acb494faee3af14.js.map
│ ├── assets
│ │ └── images
│ │ ├── favicons
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── mstile-150x150.png
│ │ │ └── safari-pinned-tab.svg
│ │ ├── logo-name.svg
│ │ ├── logo.svg
│ │ ├── products
│ │ │ └── bg.png
│ │ └── user.svg
│ ├── browserconfig.xml
│ ├── common.4cee0810b3bad63e.js
│ ├── common.4cee0810b3bad63e.js.map
│ ├── favicon.ico
│ ├── index.html
│ ├── main.0f471d11128a667b.js
│ ├── main.0f471d11128a667b.js.map
│ ├── polyfills.15ca530b679c69bf.js
│ ├── polyfills.15ca530b679c69bf.js.map
│ ├── robots.txt
│ ├── runtime.30172837f421281e.js
│ ├── runtime.30172837f421281e.js.map
│ ├── sitemap.xml
│ ├── site.webmanifest
│ ├── styles.b3b807807b7fd4f6.css
│ └── styles.b3b807807b7fd4f6.css.map
└── server
└── ru-RU
├── 192.js
├── 22.js
├── 256.js
├── 336.js
├── 340.js
├── 3rdpartylicenses.txt
├── 478.js
├── 653.js
├── 962.js
├── 977.js
└── main.js

Для запуска приложения достаточно запустить команду:

node dist/store/server/ru-RU/main.js

Запустим:

Откроем исходный код и убедимся, что в качестве ответа от сервера, прилетает отрисованная страница view-source:http://localhost:4000/:

Но это не отрисованная страница!

Это связано с тем, что проверка идет явная:

// For prerender, use exists file
if (existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.render(indexHtml, {
req,
providers: [
{
provide: APP_BASE_HREF,
useValue: req.baseUrl,
},
{
provide: REQUEST,
useValue: req,
},
{
provide: RESPONSE,
useValue: res,
},
],
});
}

Так как ожидается, что будет использоваться пререндер, то файл index.html есть всегда, вне зависимости от его использования.

Откроем любую другую страницу, в отличие от главной http://localhost:4000/product/reebok-ultra:

и ее исходный код:

Так как кода достаточно много, то можно убедиться, что это уже отрисованная на сервере страница.

Также можно открыть network и посмотреть скорость ответа сервера:

Чуть ближе:

Из скриншота видно, что страница отдавалась около 876 миллисекунд. Это достаточно много для отдачи просто статики.

Время отдачи главной страницы — 16 миллисекунд:

Вот такая цена за использование серверного рендеринга в локальных условиях.

Для решения этой проблемы и существует пререндер, который может заранее собрать и сохранить все страницы.

Запустим сборку с пререндером.

Отметим, из-за того, что пререндер опирается на массив роутов, то мы подготавливаем его самостоятельно и передаем в конфигурацию:

"prerender": {
"executor": "@nguniversal/builders:prerender",
"options": {
"routesFile": "apps/store/routes.txt",
"guessRoutes": false
},
}

Выполним команду:

yarn prerender

Структура будет следующей:

dist/store/
├── browser
│ └── ru-RU
│ ├── 167.bc54892e19f85744.js
│ ├── 167.bc54892e19f85744.js.map
│ ├── 198.c6aacc7ef62b4124.js
│ ├── 198.c6aacc7ef62b4124.js.map
│ ├── 1.d223a1576c9c2495.js
│ ├── 1.d223a1576c9c2495.js.map
│ ├── 333.8554084ee2179a7e.js
│ ├── 333.8554084ee2179a7e.js.map
│ ├── 3rdpartylicenses.txt
│ ├── 44.72cf2cf0a8316581.js
│ ├── 44.72cf2cf0a8316581.js.map
│ ├── 504.f436cc6d3ac5d9e7.js
│ ├── 504.f436cc6d3ac5d9e7.js.map
│ ├── 510.1873f0df7ade191f.js
│ ├── 510.1873f0df7ade191f.js.map
│ ├── 545.09b76b3b09c780c0.js
│ ├── 545.09b76b3b09c780c0.js.map
│ ├── 54.d47c21fb2819f501.js
│ ├── 54.d47c21fb2819f501.js.map
│ ├── 671.fedda49aebb1a683.js
│ ├── 671.fedda49aebb1a683.js.map
│ ├── 743.0dc793219f4fd6d7.js
│ ├── 743.0dc793219f4fd6d7.js.map
│ ├── 887.8f3ac684ac4f6537.js
│ ├── 887.8f3ac684ac4f6537.js.map
│ ├── 901.4acb494faee3af14.js
│ ├── 901.4acb494faee3af14.js.map
│ ├── assets
│ │ └── images
│ │ ├── favicons
│ │ │ ├── android-chrome-192x192.png
│ │ │ ├── android-chrome-512x512.png
│ │ │ ├── apple-touch-icon.png
│ │ │ ├── favicon-16x16.png
│ │ │ ├── favicon-32x32.png
│ │ │ ├── mstile-150x150.png
│ │ │ └── safari-pinned-tab.svg
│ │ ├── logo-name.svg
│ │ ├── logo.svg
│ │ ├── products
│ │ │ └── bg.png
│ │ └── user.svg
│ ├── browserconfig.xml
│ ├── cart
│ │ └── index.html
│ ├── common.4cee0810b3bad63e.js
│ ├── common.4cee0810b3bad63e.js.map
│ ├── favicon.ico
│ ├── index.html
│ ├── index.original.html
│ ├── main.0f471d11128a667b.js
│ ├── main.0f471d11128a667b.js.map
│ ├── not-found
│ │ └── index.html
│ ├── polyfills.15ca530b679c69bf.js
│ ├── polyfills.15ca530b679c69bf.js.map
│ ├── product
│ │ ├── classic-leather
│ │ │ └── index.html
│ │ ├── reebok-classic-leather
│ │ │ └── index.html
│ │ ├── reebok-instap
│ │ │ └── index.html
│ │ ├── reebok-keith-haring-gl
│ │ │ └── index.html
│ │ ├── reebok-lite
│ │ │ └── index.html
│ │ ├── reebok-nano
│ │ │ └── index.html
│ │ ├── reebok-ultra
│ │ │ └── index.html
│ │ ├── reebok-vb-dual-court
│ │ │ └── index.html
│ │ └── reebok-zig-dynamica
│ │ └── index.html
│ ├── robots.txt
│ ├── runtime.30172837f421281e.js
│ ├── runtime.30172837f421281e.js.map
│ ├── server-error
│ │ └── index.html
│ ├── sitemap.xml
│ ├── site.webmanifest
│ ├── styles.b3b807807b7fd4f6.css
│ ├── styles.b3b807807b7fd4f6.css.map
│ ├── support
│ │ └── index.html
│ └── terms
│ └── index.html
└── server
└── ru-RU
├── 162.js
├── 167.js
├── 324.js
├── 3rdpartylicenses.txt
├── 545.js
├── 57.js
├── 734.js
├── 742.js
├── 81.js
├── 887.js
└── main.js

Как видно из структуры, пререндер создал на все предоставленные роуты соответствующие страницы.

Главная страница получилась в двух видах:

  • index.html — страница сгенерированная пререндером;
  • index.original.html — базовая страница, которая является простым билдом приложения, без пререндера.

Запустим сервер:

yarn serve:ssr

Откроем главную страницу:

Однако, в отличии от предыдущего раза, мы имеем уже отрисованную страницу:

Из забавного, можно открыть вкладку network и глянуть превью для localhost:

Конечно, javascript не работает, но сточки зрения стилей, все выглядит достаточно не плохо.

Последняя фича, которая реализована в приложении это TransferState.

TransferState — представляет собой инструмент, который позволяет пробрасывать значения между серверным приложением и браузерным.

В приложении это используется для передачи значения в state списка товаров, в файле libs/products/state/src/lib/product.effects.ts:

if (this.platformService.isServer && products.length) {
this.transferState.set<Product[]>(PRODUCTS_META, products);
}

Как видно из проверки, если это серверная версия приложения, то в этом случае устанавливается новый ключ в transferState.

Практически это работает просто добавлением в index.html готового ответа:

Ссылки

Оглавление

Предыдущая статья — Настройка SEO.

Следующая статья — Тестирование.

Все исходники находятся на github, в репозитории:

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответствующий тег — article.

Подписывайтесь на блог, чтобы не пропустить новые статьи про Angular, и веб-разработку. Medium | Telegram| VK |Tw| Ln

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock