Несколько месяцев назад я загорелся желанием написать небольшой pet-проект, который был бы посвящен разработке сайта визитки на Angular. И так как Angular достаточно громоздкий фреймворк, в котором нет SSR* из коробки, да и настройка SEO требует немалых телодвижений**, то сама идея выглядела достаточно сомнительной.
Как говорил современник - Выбор — делать дичь, прям лютую грязь-грязь, либо делать грязь, но не прям совсем грязь.
Сегодня я расскажу, что из этого вышло и стоит ли делать сайты визитки на Angular.
* Universal не берем в расчет, так как он накладывает ряд ограничений при разработке, которые необходимо соблюдать, без этого бесшовная интеграция невозможна.
** Вам придется написать свой генератор sitemap, или по крайней мере составить ее руками, добавить robots, manifest, meta теги и прочее.
В результате получилось небольшое приложение, которое посвящено продаже кроссовок.
Из-за того, что существуют такие инструменты как Adobe Phpotoshop Gimp и Adobe Premiere Davinci Resolve, выше представленное может быть фейком и поэтому я решил развернуть проект на сервере, где можно потыкать приложение.
С демо проекта можно ознакомиться здесь - https://banshop.fafn.ru.
Исходники на github: https://github.com/Fafnur/banshop
В качестве сервера используется ubuntu, на которой установлен nginx и nodejs. В качестве процесс менеджера использовался pm2.
Арпиори настраивать сервер руками является плохой практикой. Сейчас есть такие инструменты как kubernetes, которые могут взять на себя всю рутину и дать полноценный масштабируемый кластер.
Однако, я подумал, что если мне нужен кластер для разворачивания сайта визитки, то точно моя электричка везёт меня туда, куда я не хочу.
Формирование требований к приложению
Сайт визитка - это расплывчатое понятие. В данном случае под сайтом визиткой будем понимать небольшое web приложение, которое выводит информацию о “компании”, а также содержит список товаров, которые можно купить, оформив заказ на сайте.
Делать полноценный e-commerce
я не решился за бесплатно, и поэтому немного ограничил требования.
Приложение должно содержать следующие модули:
Модуль товаров - каталог товаров, которые выводятся в виде списка карточек товаров. Карточка товара ведет на страницу с детальным описанием товара.
Модуль корзины - функциональность приложения, которая позволяет добавлять в корзину товары с выбранными опциями (размеры, цвета, …). Страница корзины должна отображать список товаров добавленных в корзину, а также предоставлять функциональность изменения/удаления товаров из корзины.
Модуль оформления заказа - функциональность приложения, которая позволяет пользователю указать персональную информацию о себе и оформить заказ, где в заказе будут товары, которые были добавлены в корзину ранее.
Модуль службы поддержки - отдельная страница приложения, где пользователь может связаться с оператором и задать интересующие его вопросы.
Модуль правовой информации - страница, на которой будет представлена информация о продавце(компании), а также порядок продажи товаров и принципы использования информационного ресурса.
С точки зрения отображения, приложение должно быть реализовано для трех платформ:
мобильный телефон (
handset
);планшет (
tablet
);веб приложение для настольных компьютеров (
web
).
Так как приложение применяется для онлайн продаж, то необходимо реализовать SEO настройку приложения, которое должно помочь в продвижении сайта в поисковых системах. Список требований к SEO:
Приложение содержит карту сайта (
sitemap
), в которой указаны основные страницы приложения, а также включены все страницы с реализуемыми товарами.Добавлен
robots.txt
для настройки правил индексации поисковыми роботамиДобавлен файл
manifest
, который поможет настроитьPWA
в дальнейшем.Для каждой уникальной страницы должны быть добавлены мета теги (
keywords
,title
,description
) включая мета теги для og.Поисковая система должна получать готовую, отрисованную страницу (
SSR
), а не пустойHTML
файл с подключенным файломjavascript
.
Конечно, еще хотелось добавить требования вида фильтрации товаров по выбранным фильтрам, но видимо это будет только в платной версии.
Выбор тематики, продукции и дизайна
Сначала я хотел сделать сайт визитку на Angular с яхтами и шлюпками, но потом понял, что я далек от яхт. И поэтому в качестве тематики я выбрал продажу кроссовок.
Дальше дело стояло за дизайном приложения. Так как дизайнер из меня посредственный, я изучил сайты и приложения популярных спортивных брендов и прикупил себе несколько пар кроссовок. Вспомнив о первоначальной задаче, я остановился на бренде Reebok.
Не все же Дудю бесконечно пиарить Adidas.
Если вы разрабатывали сайты на заказ, то знаете, что компетенции людей использующие их в дальнейшем - очень ограничены. Поэтому, для того, чтобы упростить управление сайтом, было принято решение разместить каталог товаров в гугл таблице и дать к ней доступ владельцу ресурса, чтобы он сам мог менять товары, не трогая идеально разработанный код.
Взяв несколько товаров с сайта Reebok и бесчестно украдя один из логотипов компании, я составил табличку в Google Sheet.
Хотя бренды тратят много денег на маркетинг, но как ни крути дизайн у них в большинстве случаев полный отстой. Мне хотелось чего-то легкого и простого, что было бы понятно программисту, но и от которого не воротило глаз.
И тогда я обратился к лучшему другу всех программистов и посмотрел рекомендации Google. Material Design выглядел ничего, поэтому я взял Angular Material за основу UI KIT’а и решил добавить пару кастомных компонентов.
Старт проекта
Когда выбор предметной области остался позади, а требования стали конкретны и понятны, то тогда стало возможно перейти к самому интересному - реализации проекта.
Я создал новый репозиторий на гитхабе - banshop. Первый коммит был сделан 24 января 2022 года. Прошло более двух месяцев и я закончил проект, где на момент написания статьи 176 коммитов.
Думаю, если я бы работал в аутсорсинговой компании, то меня непременно бы уволили за неправильную оценку времени, так как я планировал сделать проект за неделю. На разработку ушло около двух месяцев, где вечерами я шел с работы на работу делать свое небольшое приложение.
Стек технологий, который был использован в приложении:
Nx - инструменты для создания и управления монорепозиторием.
Angular - фронтенд фреймворк.
Ngrx - одна из реализаций
Redux
в Angular.Universal - реализация Server Side Rendering.
Angular Localization - модуль локализации.
Jest - фреймворк для unit тестирования.
Cypress - фреймворк для
e2e
тестирования.
Внешние библиотеки, помимо описанных выше, но без которых сложно обойтись:
Hammerjs - библиотека для отслеживание тапов, свайпов и т.д.;
@angular-builders/custom-webpack - кастомизация билдеров Angular’а;
ng-mocks - библиотека для мокирования сущностей в Angular;
ts-mockito - библиотека для мокирования чего угодно в Typescript;
angular-imask - библиотека с масками.
Все остальные пакеты в приложении - это вкусовщина (husky
, eslint
,...).
Если почитать другие мои статьи, то весь выше описанный стек есть почти в каждом моем проекте. Одним словом у меня есть определенный стек и я его придерживаюсь.
Процесс разработки приложения
Весь процесс разработки можно разбить на несколько шагов:
Создание
workspace
с установкой всех необходимых библиотек.Создание
core
модулей. Так как каждый разработчик придерживается определенных решений, то на старте проекта есть смысл заложить библиотеки и решения как часть ядра разрабатываемого приложения.Создание
UI KIT
, который должен содержать все общие компоненты, которые планируется использовать в приложении. Ярким примером являются модули сеток, контейнеров, слайдеры, ссылки, кнопки и прочее.Реализация функциональных модулей, таких как модуль товаров, модуль корзины, модуль чата, модуль оформления заказа.
Настройка
SEO
, где в приложение добавляются все необходимые файлы и настройки для поисковой оптимизации.Настройка
SSR
и пререндера. На данном шаге производится заточка всего приложения под корректную работуSSR
.Тестирование. Конечно, тесты пишутся вместе с реализацией модулей, но есть некоторые места c
TODO: Add test
, которые на данном шаге нужно устранить. Да и e2e разрабатываются на этом шаге, так как их написание на более ранних шагах бессмысленна.Оптимизация приложения с точки зрения используемых ресурсов и поддержки современных веб стандартов. Как одним из инструментов можно использовать
Lighthouse
отGoogle chrome
.
Вкратце опишу каждый из шагов, чтобы было понимание того, что пришлось сделать, чтобы сайт заработал.
Отмечу, что весь процесс разработки подробно описан в моем цикле статей на медиуме.
Создание workspace
Создания нового workspace очень легко, где нужно запустить одну команду и NX CLI создадут новый workspace и если выбрать опцию с Angular, то еще будет сгенерировано новое Angular приложение.
Достаточно запустить следующую команду:
yarn create nx-workspace --package-manager=yarn
Если добавить библиотеки вышеописанного стека с помощью nx, то тогда в приложении будут автоматически созданы необходимые файлы конфигурации.
Например конфигурация SSR:
yarn nx add @nguniversal/express-engine
Для настройки Ngrx достаточно создать Root State, которая добавит в проект необходимые зависимости.
nx g @nrwl/angular:ngrx rs --module=path/to/app.module.ts
Настройка локализации:
nx add @angular/localize
Настройка Angular Material:
nx add @angular/material
Установка внешних библиотек:
yarn add hammerjs angular-imask
Установка дополнительных библиотек:
yarn add -D ng-mocks ts-mockito
И немного вкусовщины:
yarn add -D eslint-plugin-import eslint-plugin-jsdoc eslint-plugin-ngrx eslint-plugin-prettier eslint-plugin-simple-import-sort
Запустив все команды и применив немного магии для eslint
’а, то тогда создаться новое рабочее пространство (workspace
) с Angular приложением.
Создание core модулей
Core модули - набор решений, которые позволяют решить узкие места фреймворка.
Примеры узких мест:
Получение доступа к Window;
Реализация кроссплатформенных хранилищ
localStroge
,sessionStrorage
;Интерфейсы и утилиты для создания тестовых объектов (
PageObject
) и DI мок-сервисов;Унификация навигации в приложении;
Маппинг
FormControl
изFormGroup
;ApiService
- обертка надHttpClient
, которая управляет созданием корректных путей и логированием ошибок при необходимости.
Большая часть core
- это системные части, которые расширяют базовые возможности фреймворка или решают проблемы с платформами, такими как предоставление доступа к web хранилищам в серверной платформе, в которой нет window
.
В качестве примера приведу решение с доступом к window
:
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class WindowService {
constructor(@Inject(DOCUMENT) public readonly document: Document) {}
get window(): Window | null {
return this.document.defaultView;
}
}
Теперь вне зависимости от платформы, можно обращаться к window:
const navigator = this.windowService.window?.navigator
Конечно, в серверной платформе window будет null, но лучше null, чем ошибка компиляции приложения.
В сервис можно добавить проверку на доступность window и привести сервис только к явному использованию браузерной версии:
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class WindowService {
constructor(@Inject(DOCUMENT) public readonly document: Document) {}
get window(): Window {
const window: Window | null = this.document.defaultView;
if (window === null) {
throw new Error('Default view is not defined!');
}
return window;
}
}
Остальные решения, которые представлены в core модулях также тривиальны, как и решение с Window. Ознакомиться с ними можно на гитхабе - banshop/core.
Разработка UI KIT
Возможно одним из главных решений при разработке приложения, которое будет влиять на стоимость его поддержки - это разработка UI KIT.
UI KIT - набор общих компонентов, которые используются для построения приложения.
Если сначала не определить общие компоненты, то в дальнейшем, похожие компоненты будут дублировать код, для реализации похожего функционала. И чем больше будет становиться приложение, тем больше придется заниматься поддержкой проекта.
А так как мой внутренний перфекционист требует масштабирования бизнеса, то UI KIT для сайта визитки жизненно необходим.
Так как я решил использовать в качестве основы Angular Material, то уже из коробки достаточно много доступных компонентов.
Сформировать тему для material очень легко. Это делается в момент установки пакета материала.
Единственное, что на мой взгляд плохо освещено в документации по Angular Material - это создание лейаутов. Подробнее о лейаутах в документации.
Angular Material предоставляет возможность создать три платформы для отображения с помощью средств CDK. В CDK есть константа Breakpoints со следующими размерами:
export declare const Breakpoints: {
XSmall: string;
Small: string;
Medium: string;
Large: string;
XLarge: string;
Handset: string;
Tablet: string;
Web: string;
HandsetPortrait: string;
TabletPortrait: string;
WebPortrait: string;
HandsetLandscape: string;
TabletLandscape: string;
WebLandscape: string;
};
Если сделать ряд манипуляций и создать сервис, который используя BreakpointObserver
будет возвращать текущий тип платформы.
Сервис может быть реализован следующим образом:
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable, tap } from 'rxjs';
export const LAYOUT_SHORT_TYPES_MAP = {
[Breakpoints.Handset]: Breakpoints.Handset,
[Breakpoints.HandsetPortrait]: Breakpoints.Handset,
[Breakpoints.HandsetLandscape]: Breakpoints.Handset,
[Breakpoints.Tablet]: Breakpoints.Tablet,
[Breakpoints.TabletPortrait]: Breakpoints.Tablet,
[Breakpoints.TabletLandscape]: Breakpoints.Tablet,
[Breakpoints.Web]: Breakpoints.Web,
[Breakpoints.WebPortrait]: Breakpoints.Web,
[Breakpoints.WebLandscape]: Breakpoints.Web,
};
export const LAYOUT_TYPES = [Breakpoints.Handset, Breakpoints.Tablet, Breakpoints.Web];
@Injectable({
providedIn: 'root',
})
export class LayoutService {
private readonly layoutSubject$ = new BehaviorSubject<string>(Breakpoints.Handset);
get layoutType$(): Observable<string> {
return this.layoutSubject$.asObservable();
}
get snapshotLayoutType(): string {
return this.layoutSubject$.value;
}
constructor(private readonly breakpointObserver: BreakpointObserver) {
this.breakpointObserver
.observe(LAYOUT_TYPES)
.pipe(
tap((result) => {
let type;
for (const query of Object.keys(result.breakpoints)) {
if (result.breakpoints[query]) {
type = LAYOUT_SHORT_TYPES_MAP[query];
break;
}
}
this.layoutSubject$.next(type ?? Breakpoints.Handset);
})
)
.subscribe();
}
is(size: string): boolean {
return size === this.snapshotLayoutType;
}
}
Вышеописанные шаги позволят реализовать решение, которое позволит использовать три состояния, для отображения контента, в частности мобильного телефона, планшета и ПК.
Помимо лейаута я создал несколько компонентов для создания выравнивания контента на странице: GridModule
и ContainerModule
.
Первый модуль позволяет создавать сетки по аналогии с сетками в Twitter Bootstrap.
Второй модуль реализует аналог класса контейнера из Twitter Bootstrap, где у контента есть фиксированная ширина и блок позиционируется по центру.
И последними компонентами в UI KIT стали модули для отображения карусели. Карусель реализована топорно, где выводится список полученных изображений, и добавлены элементы навигации.
Реализация функциональных модулей
Реализация всех функциональных модулей, таких как модуль товаров, модуль корзины, модуль оформления заказа и модуль чата.
Я использовал следующую последовательность действий для разработки каждого модуля.
Сначала создавалась библиотека, которая включала в себя все интерфейсы и абстракции для текущего модуля.
Вынесение всех сущностей в отдельную библиотеку имеет одно весомое преимущество - это позволяет разделить абстракции и реализации друг от друга, при этом шансы создания циклических зависимостей минимальны.
После того, как были созданы все интерфейсы, создавался новый state (feature state), который включал в себя всю логику с изменением данных для модуля. В процессе разработки state
были созданы экшены и сформирован редьюсер, описаны все состояния state, а также были реализованы цепочки событий с помощью эффектов и добавлены соответствующие методы в фасад.
Следующим шагом было создание UI компонентов для каждого из модулей. Сначала создавались видимые модули, которые использовались на страницах модулей - страница товаров, страница корзины, страница оформления заказов, а уже потом все остальные компоненты, которые добавляли вспомогательный функционал. Примеры вспомогательного функционала - это кнопки добавления в корзину, где при клике добавить товар в корзину, открывалось диалоговое окно, в котором отображалась карточка товара и была возможность добавить товар в корзину с выбором вариационных полей.
После формирования UI компонентов создавалась страница модуля, где подключались ранее созданные компоненты из UI KIT и UI компонентов модуля.
В конце разработки модуля создавались гуарды (can-active
), которые проверяли права доступа к страницам.
Настройка локализации
Я же в проекте, добавил полноценную локализацию, где в качестве базового языка используется рунглиш английский, а само приложение использует русский язык.
Это единственный шаг, который можно было не делать совсем - это прикручивать локализацию в Angular. Точнее сказать, сконфигурировать базовые настройки локализации нужно, но делать шаблоны мультиязычными - точно нет.
Так как приложение не подразумевает другие языки, то потребность в переводе отсутствует. Я добавил локализацию только потому, что не хотел использовать русский в шаблонах и компонентах. С другой стороны получилось показать на практике как использовать стандартную локализацию, а также привести пример того, какие части приложения не стоит локализовать.
Локализация Angular работает с помощью использования файла локализации, который генерируется на основе поиска локализованных частей приложения с помощью применения пайпа i18n
и глобальной переменной $localize
.
Для того, чтобы добавить локализацию в Angular нужно всего-лишь добавить пакет:
nx add @angular/localize
И в браузерной версии, все будет замечательно. Помечаете части приложения с локализацией специальным пайпом (i18n
) и генерируете файл локализации.
Пример файла локализации:
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en-US" datatype="plaintext" original="ng2.template">
<body>
<trans-unit id="5888897186275347598" datatype="html">
<source>Cart | Online store Banshop</source>
<target state="translated">Корзина | Online store Banshop</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/cart/page/src/lib/cart-page-routing.module.ts</context>
<context context-type="linenumber">17</context>
</context-group>
<note priority="1" from="meaning">Cart meta</note>
</trans-unit>
</body>
</file>
</xliff>
Проблемы начинаются, когда появляется SSR. Не знаю с чем это связано, но сейчас не найти примера приложения angular + universal + localization
в официальной документации.
Если погуглить локализацию, то можно найти мои статьи 2019 года, в которых я привожу примеры настройки связки angular + universal + localization.
Не могу не отметить, что единственная полезная статья у меня в блоге на медиуме. Я занимаюсь настройкой локализации примерно раз в год, и спустя год уже забываешь все конфигурации и нюансы. И статья меня уже дважды выручала.
Если в двух словах, то при добавлении локализации в univesral
появляются префиксы при билде es/ru/en-IN
. И если серверная сборка адекватно может обрабатывать локализацию, то dev-server
не может. И тут начинают танцы с бубном, как заставить дев сервер работать.
Решением является выключение локализации для серверной версии, где в конфигурациях серверной версии уже руками для каждой конфигурации прописывается локаль, и создается одна конфигурация без локали.
Настройка SEO
Как я и писал выше, для Angular необходимо выполнить несколько шагов, для того, чтобы поисковые системы стали адекватно индексировать приложение.
Сначала я создал набор favicon’ов, где вместе с набором иконок создались файлы манифеста. Далее создал robots.txt, в котором запретил индексировать пути к API.
Следующей задачей стала реализация мета тегов.
Так как Angular не предлагает готового решения, пришлось изобрести свой велосипед
и уехать в глухомань.
Если грубо очертить, то нужен сервис, который в зависимости от страницы приложения указывал следующий набор мета тегов и сопутствующих тегов: canonical
, title
, description
, keywords
. Мета теги OG: title
, description
, type
, locale
, siteName
, image
, imageType
, imageWidth
, imageHeight
.
Реализация подобного сервиса может быть следующей:
import { DOCUMENT } from '@angular/common';
import { Inject, Injectable, LOCALE_ID, Optional } from '@angular/core';
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
import { Router } from '@angular/router';
import { EnvironmentService } from '@banshop/core/environments/service';
import {
META_CONFIG,
META_CONFIG_DEFAULT,
META_CONFIG_OG,
META_CONFIG_OG_DEFAULT,
MetaConfig,
MetaConfigOg,
} from '@banshop/core/meta/common';
@Injectable({
providedIn: 'root',
})
export class MetaService {
private readonly metaConfig: MetaConfig;
private readonly metaConfigOg: MetaConfigOg;
constructor(
private readonly titleService: Title,
private readonly router: Router,
private readonly meta: Meta,
private readonly environmentService: EnvironmentService,
@Inject(DOCUMENT) private readonly document: Document,
@Inject(LOCALE_ID) private readonly localeId: string,
@Optional() @Inject(META_CONFIG) metaConfig: MetaConfig | null,
@Optional() @Inject(META_CONFIG_OG) metaConfigOg: MetaConfigOg | null
) {
this.metaConfig = metaConfig ?? META_CONFIG_DEFAULT;
this.metaConfigOg = metaConfigOg ?? META_CONFIG_OG_DEFAULT;
}
update(metaConfig?: Partial<MetaConfig>, metaConfigOg?: Partial<MetaConfigOg>): void {
const config: MetaConfig = { ...this.metaConfig, ...metaConfig };
const configOg: MetaConfigOg = { ...this.metaConfigOg, ...metaConfigOg };
this.setCanonicalUrl(config.url);
this.titleService.setTitle(`${config.title} | ${this.environmentService.environments.brand}`);
this.setMetaProperty('description', config.description);
this.setMetaProperty('keywords', config.keywords);
this.setMetaProperty('og:title', `${configOg.title ?? config.title} | ${this.environmentService.environments.brand}`);
this.setMetaProperty('og:description', configOg.description ?? config.description);
this.setMetaProperty('og:type', configOg.type);
this.setMetaProperty('og:locale', configOg.locale ?? this.localeId);
this.setMetaProperty('og:site_name', configOg.siteName ?? this.environmentService.environments.brand);
this.setMetaProperty('og:image', `${this.environmentService.environments.appHost}${configOg.image}`);
this.setMetaProperty('og:image:type', configOg.imageType);
this.setMetaProperty('og:image:width', configOg.imageWidth);
this.setMetaProperty('og:image:height', configOg.imageHeight);
}
private setCanonicalUrl(url?: string): void {
const link = (this.document.getElementById('canonical') ?? this.document.createElement('link')) as HTMLLinkElement;
link.setAttribute('rel', 'canonical');
link.setAttribute('id', 'canonical');
link.setAttribute('href', this.getCanonicalURL(url));
if (!this.document.getElementById('canonical')) {
this.document.head.appendChild(link);
}
}
private getCanonicalURL(url?: string): string {
return `${this.environmentService.environments.appHost}${url ?? this.router.url}`;
}
private setMetaProperty(name: string, content: string): void {
const id = `meta-${name}`;
const has = !!this.document.getElementById(id);
const meta: MetaDefinition = { id, name, content };
if (has) {
this.meta.updateTag(meta);
} else {
this.meta.addTag(meta);
}
}
}
Я пробовал разные способы задания мета тегов, но лучшим решением является создание мета тегов при конфигурации роута страницы.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RouteData } from '@banshop/core/navigation/common';
import { CartPageComponent } from './cart-page.component';
const routes: Routes = [
{
path: '',
component: CartPageComponent,
data: {
sitemap: {
loc: '/cart',
priority: '1.0',
},
meta: {
title: $localize`:Cart meta|:Cart | Online store Banshop`,
description: $localize`:Cart meta|:It is very easy to buy on banshop. To place an order, click the order button.`,
keywords: $localize`:Cart meta|:cart, banshop`,
},
} as Partial<RouteData>,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class CartPageRoutingModule {}
Для того, чтобы теги изменялись сами, я обычно создаю новый класс ngrx эффектов, которые подписываются на окончание смены роута и после этого, вызывается соответствующий метод сервиса, который обновляет мета теги для страницы.
Последней задачей с SEO - это создание карты сайта.
Снова Angular предоставляет вам возможность блеснуть талантом и сделать все самому.
Однажды я написал решение на NodeJS, которая просматривает все файлы в проекте и ищет модули, которые настраивают навигацию в приложении. В найденных файлах ищется конфиг в data, который описывает требования к генерации пути в sitemap. Если конфигурация найдена, то данный путь добавляется в карту сайта.
Пример данного решения:
import { config } from 'dotenv';
import * as fs from 'fs';
import * as https from 'https';
import * as path from 'path';
import { SitemapConfig } from '@banshop/core/navigation/common';
import { environment } from './src/environments/environment.prod';
config({ path: 'apps/store/.env' });
const routes = new Set<string>();
/**
* Find file on folders
*/
export function fromDir(startPath: string, filter: string): string[] {
if (!fs.existsSync(startPath)) {
console.warn('no dir ', startPath);
return [];
}
const founded = [];
const files = fs.readdirSync(startPath);
for (const file of files) {
const filename = path.join(startPath, file);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
const foundedIn = fromDir(filename, filter);
founded.push(...foundedIn);
} else if (filename.indexOf(filter) >= 0) {
founded.push(filename);
}
}
return founded;
}
/**
* Find sitemap config on file content
*/
export function parseSitemapConfig(source: string): Partial<SitemapConfig> {
let sitemapConfig = source
.slice(8)
.replace(/\n|\t|\s/g, '')
.replace(/'/g, '"')
.trim();
if (sitemapConfig[sitemapConfig.length - 1] === ',') {
sitemapConfig = sitemapConfig.slice(0, sitemapConfig.length - 1);
}
sitemapConfig = sitemapConfig + '}';
sitemapConfig = sitemapConfig.replace(
/(\w+:)|(\w+ :)/g,
(matchedStr: string) => '"' + matchedStr.substring(0, matchedStr.length - 1) + '":'
);
return JSON.parse(sitemapConfig);
}
/**
* Generate sitemap url
*/
export function getSitemapUrl(sitemap: Partial<SitemapConfig>): string {
if (sitemap.loc) {
routes.add(sitemap.loc.length > 0 ? sitemap.loc : '/');
}
return `<url><loc>${environment.appHost}${sitemap.loc}</loc><lastmod>${(sitemap.lastmod
? new Date(sitemap.lastmod)
: new Date()
).toISOString()}</lastmod><changefreq>${sitemap.changefreq ?? 'daily'}</changefreq><priority>${sitemap.priority ?? 0.8}</priority></url>`;
}
/**
* Load data and generate sitemap config
*/
export function getServerData(cb: (data: string) => void): void {
let data = '';
const { GOOGLE_KEY, GOOGLE_ID, GOOGLE_NAME } = process.env;
if (GOOGLE_KEY && GOOGLE_ID && GOOGLE_NAME) {
const options = {
hostname: 'sheets.googleapis.com',
path: `/v4/spreadsheets/${GOOGLE_ID}/values/${GOOGLE_NAME}?key=${GOOGLE_KEY}`,
method: 'GET',
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
const response = JSON.parse(body);
if (response.values) {
for (const product of response.values) {
data += getSitemapUrl({
loc: `/product/${product[0]}`,
lastmod: new Date().toISOString(),
changefreq: 'daily',
priority: '0.8',
});
}
}
cb(data);
});
});
req.end();
}
}
export function getUrls(): string {
let data = '';
const files = [...fromDir('./apps/store/src', '-routing.module.ts'), ...fromDir('./libs', '-routing.module.ts')];
for (const file of files) {
const fileContent = fs.readFileSync(file, 'utf8');
const sources = fileContent.replace(/\s+/, ' ').match(/sitemap:\s{[^}]+/g);
if (sources) {
for (const source of sources) {
data += getSitemapUrl(parseSitemapConfig(source));
}
}
}
return data;
}
export function generate(): void {
const urls = getUrls();
getServerData((data) => {
fs.writeFileSync(
'apps/store/src/sitemap.xml',
// eslint-disable-next-line max-len
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd" xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}${data}</urlset>`
);
const routePaths = [...Array.from(routes), '/not-found', '/server-error'].sort().join('\n');
fs.writeFileSync('apps/store/routes.txt', routePaths);
});
}
// generate
generate();
Генератор содержит несколько функций:
fromDir
— читает содержимое директории и ищет файлы по шаблону;parseSitemapConfig
— парсит содержимое файла и пытается найти конфигурациюsitemap
;getSitemapUrl
— генерация пути в sitemap на основании входящего конфига;getServerData
— загрузка внешнего файла и генерация url’ов на основе полученных данных;getUrls
— функция, которая парсит приложение и библиотеки для конкретного приложения на наличие файлов роутинга;generate
— функция, которая запускает генерацию карты сайта.
Так как пример написан на Typescript, то необходимо добавить tsconfig, чтобы скрипт можно было запустить из корня проекта.
{
"extends": "../../tsconfig.base.json",
"files": [],
"include": [],
"compilerOptions": {
"module": "CommonJS",
"lib": ["DOM", "ESNext"]
}
}
Пример генерации карты сайта:
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://banshop.fafn.ru/cart</loc>
<lastmod>2022-03-19T09:38:02.917Z</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>
Помимо статичных путей, в карту сайта попали и динамические пути. Это решение применимо только для этого решения, так как список всех товаров находится в Google Sheets, то во время выполнения скрипта можно запустить загрузку файла и потом в добавить в карту сайта все динамические страницы.
Из важного, стоит отметить, что вместе с генерацией карты сайта, генерируется массив страниц для prerender’а:
const routePaths = [...Array.from(routes), '/not-found', '/server-error'].sort().join('\n');
fs.writeFileSync('apps/store/routes.txt', routePaths);
Это позволяет в фазе prerender’а отрисовать все требуемые страницы в приложении.
Настройка SSR
Одним из мотиваторов разработки приложения было именно показать, как настраивать и использовать Universal.
Настройка SSR - один из моих любимых вопросов на собеседовании. Правда со мной на эту тему никто не хочет разговаривать.
Как было сказано ранее, большой плюс Angular это схематики и настройки. При добавлении SSR, большая часть настроек будет сконфигурирована.
yarn nx add @nguniversal/express-engine
Однако, если не соблюдать правила разработки под SSR, то приложение не запуститься.
Какие нюансы стоит учитывать, если планируется использовать SSR:
Доступ к
Window
. Везде, где используются web технологии (localstrorage
,navigator
, …) необходимо ставить проверки на платформу, что код будет запускаться только в браузерной версии приложения.Контроль
observable
, которые не должны уходить в бесконечный цикл. Например использованиеinterval
без обертки платформы или правил, которые принудительно завершат поток приведет к тому, что приложение не будет собрано, так как процесс компиляции (чаще пререндер) повиснет на стадии ожидания завершения потока, который никогда не завершится.Контроль observable guard’ов, которые ожидают данные, но которые могут быть не получены. В данном случае, тоже умрет пререндер, который не сможет дождаться завершения потока.
При разработке стилей, все глобальные стили в приложении будут грузится с помощью style.css, а это значит, что элементы на странице могут дергаться после загрузки страницы. Поэтому есть смысл сократить использование глобальных стилей, оставив там только ключевые стили для Angular CDK и Angular Material
Контроль state приложения.
Так как в Universal есть пререндер, то нужно сделать еще ряд манипуляций, чтобы заставить приложение на NodeJS раздавать отрендеренные страницы вместо полноценной отрисовки.
Теперь все по шагам, что было сделано в приложении.
После установки зависимостей я немного переименовал файлы и создал еще один файл модуля для приложения:
app.module.ts
- общий модуль приложения, который используется как в браузерной версии, так и в сервернойapp.browser.module.ts
- модуль только для браузреной версии. Модуль может подключать библиотеки, которые работают только в браузере, но не будут корректно работать в среде nodeapp.server.module.ts
- модуль только для сервреной версии. В данной платформе можно использоватьpath
,fs
и все остальные плюшки и библиотеки отnodejs
.
Переименовал main.ts
в main.browser.ts
, чтобы было понимание, какая платформа запускается.
В самом файле сервера, я изменил правила отрисовки страниц:
// All regular routes use the Universal engine
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,
},
],
});
}
});
В данном случае, сначала по заданному пути ищется соответствующий файл, который мог быть создан prerender’ом. Если такой файл есть, то тогда он отдается сервером, иначе запускается полноценная отрисовка приложения на сервере.
Запуск отрисовки стрницы сервером - очень дорогая операция.
Только если вы не Джеф Безос и можете позволить себе сервера на амазоне.
Если тестировать локально, то время отдачи главной страницы SSR - 800 милисекунд.
Если же отдавать страницу, созданную пререндером, то тогда ответ сервера 20 миллисекунд.
Разница ощутима.
А если еще делегировать отдачу статики на балансировщик, где статичные файлы будут отдаваться напрямую nginx, то можно ускорить от сервера еще в несколько раз. И тогда не будет необходимости проверять файлы статики с помощью ноды.
Последний нюанс связанный с SSR - это передача состояния с серверной платформы в браузерную версию.
Например, в данном приложении запрашивается список товаров, который потом складывается в Ngrx state. Если отрисовать страницу пререндером, который успешно загрузит данные по API, то в ответе от сервера, клиенту прилетит готовая страница.
Однако, так как в браузерной версии нет данных от API, то при запуске страницы, будет выполнен запрос на загрузку данных по API еще раз, но в этот раз с браузерной версии.
Тут есть несколько решений, чтобы отрисованная страница не пропадала, а потом снова отрисовывалась с теми же данными.
Вариант 1. Если приложение разработано по уму, и не может быть такого, что браузерное приложение может получить данные, отличные от тех данных, что были получены в серверной версии, то тогда, запрос в браузерной версии можно вообще не делать. Для этого используется TransferState
- передаваемые значения между платформами в Angular. Перед выполнением реального запроса, можно проверить значение в TransferState
. Если значение в TransferState
нету, то тогда необходимо выполнить запрос.
Для того, чтобы задать значение в TransferState
, в серверной платформе, в случае успешного выполнения запроса записывается значение по ключу. И когда, браузерная версия попробует выполнить запрос, она сначала проверит наличие значения, и только если значения не будет, то выполнит запрос.
load$ = createEffect(() => {
return this.actions$.pipe(
ofType(ProductActions.load),
fetch({
id: () => 'load-products',
run: () =>
this.productApiService.load().pipe(
map((products) => {
this.localAsyncStorage.setItem(ProductKeys.Products, products);
if (this.platformService.isServer && products.length) {
this.transferState.set<Product[]>(PRODUCTS_META, products);
}
return ProductActions.loadSuccess({ products });
})
),
onError: (action, error) => {
console.error(`Error: ${action.type}\n`, error);
return ProductActions.loadFailure({ error });
},
})
);
});
Вариант 2: Ничего не ограничивать, и давать браузерной версии выполнять запросы. Однако, стоит учесть это в логике отображения компонентов. Например, также использовать TransferState
и подставлять значения с сервера, но если вдруг данные будут расходиться, то Angular сможет их безболезненно обновить.
Конечно, что-то будет прыгать и дергаться, но это в любом случае лучше, чем ничего не показывать пользователю.
Если правильно заточить UI KIT то можно добиться следующего эффекта:
Отдаваемая страница от сервера целиком отрисована.
Javascript не работает, но сточки зрения стилей, все выглядит достаточно не плохо
Оптимизация приложения
После разработки приложения, есть смысл проверить все библиотеки и убедиться, что весь неиспользуемый код удален, а все что возможно оптимизировать - оптимизировано.
Lighthouse - один из инструментов оптимизации вашего приложения, который можно увидеть в консоли разработчика Google Chrome.
Lighthouse позволит проверить скорость отрисовки приложения, проверить качество соблюдения современных стандартов, а также проверит удобность разработанного приложения.
Если при запуске теста, вы сможете увидеть результаты на черном фоне - можно вас поздравить!
Для моего проекта, результаты следующие:
Для desktop: ~99/100/100/100:
Для mobile: ~80/100/100/100
Основная проблема моего приложения - это внешние картинки, которые можно сжать чуть не в 2 раза. Наверное, если разместить картинки на своем хостинге, то скорее всего, результаты будут получше. Но в целом результаты достаточно хорошие.
Можете протестировать различные сайты на Angular, например:
Mvideo
ПСБ Банк
Банк точка
Тинькофф Бизнес
Можно глянуть список проектов на Angular - тут.
Рекомендации по устранению проблем написаны четко и исправить недочеты, обычно, не составляет труда.
Конечно, я должен отметить, что при разработке я сразу закладывал то, что приложении будет использоваться SSR и соответственно вся верстка и логика это учитывало.
Установка SSR до момента разработки большой производительности не даст, так как вряд ли вы будете использовать ssr-serve
для разработки. Это связано с тем, что ssr-serve
будет пересобирать все платформы, что является очень ресурсоемко и следовательно очень долго.
Оптимизация сервера, на котором размещается приложение выходит за рамки статьи.
Но я ее и не делал, единственное попытался добавить gzip для запросов nginx’а. Но в целом, опытный devOps может неплохо так наколдовать и ускорить приложение.
Тестирование Angular приложения
Последней частью разработки является тестирование. Это не говорит о том, что тесты пишутся в конце разработки, но сам процесс подразумевает тестирование реализованного функционала.
Да, это не TDD. Пока не встречал ни одной продуктовой компании, где это работало бы хорошо.
Тестирование в Angular есть из коробки в виде karma
, а использование Nx позволяет сделать его еще более комфортным, предоставляя возможность использовать jest
.
Часто разработчики пренебрегают написанием тестов. И каждый говорит о том, что тестирование важно, но сам он тесты не пишет.
В Angular можно тестировать все. Основные моменты, которые подлежат обязательному тестированию:
Тестирование работы сервисов в Angular. Каждый метод должен быть покрыт хотя бы одним тестом.
Тестирование связки HTML + JavaScript. Это тестирование Angular компонентов, где при вызове соответствующих событий, должно изменяться состояние компонента.
Тестирование пайпов и директив. Все трансформации данных и декорирование компонентов также должны быть покрыты тестами.
Тестирование Ngrx State|Redux. Если в проекте используется Redux, то все структурные элементы state должны быть протестированы. В случае с redux это селекторы, редьюсер, эффекты и фасад.
E2E тестирование для проверки работоспособности приложения - великая вещь. Она позволяет сократить количество ошибков в разы, но правда очень большой ценой.
В Angular по умолчанию используется protractor, Nx же предоставляет cypress.
Я не писал e2e тесты, так как я мухожук. И так слишком много букв.
В приложении написаны тесты на большую часть функционала. Примерно половина тестов на компоненты покрывает все кейсы, остальная половина тестов проверяет лишь корректность отображения.
В проекте было реализовано 72* библиотеки:
nx run-many --target=test --all --parallel --maxParallel=8
Все тесткейсы проходят успешно.
Резюме
В результате получилось небольшое приложение для продажи кроссовок, где можно выбрать товар, добавить в корзину и оформить заказ.
Основные задачи, которые нужно решить для сайта визитки, если используется Angular:
Настроить Server Side Rendering
Реализовать компоненты Angular, которые будут ожидаемо вести себя при SSR
Для устранения дублирования логики, необходимо реализовать обмен состояниями между браузерной и серверной версиями
Настроить SEO, в частности реализовать генерацию sitemap и мета тегов
Если разрабатывать приложение в полном цикле - с тестированием, локализацией, то время разработки может быть неоправданно дорогой.
Плюсы разработки приложения на Angular
Вся мощь фреймворка для удовлетворения всех нужд.
Простой и понятный процесс разработки.
Минусы разработки:
Процессы разработки требуют много времени.
SEO самое узкое место, которое требует настройки SSR, которое влечет подстраивание приложения под universal.
Когда стоит разрабатывать сайт визитку на Angular?
Никогда.
Можно попробовать, конечно, но за это никто не заплатит столько, сколько это стоит.
Надеюсь статья будет полезна.
Комментарии (11)
fosihas
03.04.2022 09:58+2Красивая популяризация Angular.
ps:
Хотя по личным понятиям, сайт-визитка должен использовать что полегче.
fafnur Автор
03.04.2022 17:35Да разработка очень затратна и приносит не так много профита. Я подумываю сделать что-то альтернативное на React, но думаю для сайта визитки можно попробовать и статичные генераторы сайтов.
tmin10
03.04.2022 10:38+2Но разве это сайт-визитка? Выглядит скорее как pet-project, чтобы показать свои скилы в дальнейшем. Визитка всё-таки должна представлять самого автора, кто он, откуда, контакты.
fafnur Автор
03.04.2022 17:30Да, возможно. Отмечу, что в проекте есть страница с условиями покупки, в которой приведена вся информация о компании/юридическом лице. Но как я и говорил в статье, понятие очень расплывчатое.
dimuska139
03.04.2022 20:43Слышал, такое мнение, что у Angular получается большой размер бандла - поэтому для сайтов, где планируется SEO-оптимизация, отдают предпочтение NextJS (React). Подскажите, пожалуйста, действительно ли это актуально, и какой размер бандла у вас получился?
fafnur Автор
04.04.2022 03:30+1Не сжатая будет примерно 850 kB для показа главной страницы. Сжатая версия будет примерно 200 kB.
Состав Main.js на 618 kB
angular core - 118.92 kB
angular router - 68.86 kB
angular animation - 60.4 kB
angular common 58.96 kB
angular material - 53.58 kB
angular cdk - 37.24 kB
angular forms - 32.77
angular platform - 24.8
ngrx - 48.39 kB
rxjs - 32.18 kB
hammerjs - 20.64 kB
custom core - 50 kB
localization - 5.47 kB
app - 2.41 kB
Есть практики, которые позволяют минимизировать объем итогового билда. Но это нужно отдельно настраивать сборку и немного менять приложение.
В целом, если сравнивать с React, то Angular в дефолтных настройках поиграет из-за большого количества используемых библиотек.
muturgan
05.04.2022 07:35+1Весьма интересно, спасибо за статью.
Скажите, а вы пробовали другие стейт менеджеры для angular кроме ngrx?
fafnur Автор
05.04.2022 07:49Я разбирал одно время ngxs, но в продакшне никогда не использовал. Akita мне не зашла концептуально, но это было давно. Возможно если посмотреть сейчас, то может пересмотрю свое отношение к Akita.
Одно время работал с vuex в Vue, но тоже давненько.
Сейчас, немного глубже погрузившись в ядро Angular, я хочу попробовать реализовать всю необходимую логику приложения без Redux, а с помощью сервисов Angular и правильной организации потоков. Основная цель - сравнить производительность.Просто многие хейтят Ngrx за громоздкость, но под капотом, там много оптимизации. И если сильно не жестить с вложенностью объектов и делать простые мутации state, то в целом, все не так грустно.
muturgan
05.04.2022 11:37Думаю у вас достаточно свежая нода для поддержки async iterator.
Предлагаю немного видоизменить функцию getServerData.
Следующую конструкцию:const req = https.request(options, (res) => { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => { const response = JSON.parse(body); if (response.values) { for (const product of response.values) { data += getSitemapUrl({...}); } } cb(data); }); });
заменить на:
const req = https.request(options, async (res) => { let body = ''; for await (const chunk of res) { body += chunk; } const response = JSON.parse(body); if (response.values) { for (const product of response.values) { data += getSitemapUrl({ loc: `/product/${product[0]}`, lastmod: new Date().toISOString(), changefreq: 'daily', priority: '0.8', }); } } cb(data); });
abyssSoft
Хорошая статья и написана с юмором. Я сам не люблю Angular и пишу на Vue/Nuxt +Node но ваша статья мне понравилась.