Core
- это набор функций, конфигов и утилит, которые используются в приложении.
Обычно,
core
- то, что вы тащите из проекта в проект. В данном случае, я пытался взять только необходимое.
Коротко о всем core
:
-
api/params.ts
— утилита для приведения параметров. Например, в query params есть baggage=true, а нужно чтобы true был не string, а boolean; -
currency/currency.ts
— конфиг валюты; -
env/environment.ts
— получение и использование.env
; -
form/form.ts
— немного типизацииFormGroup
; -
form/extract-changes.directive.ts
— директива обнаружения изменений вFormControl
; -
hammer/hammer.ts
— настройкаhammerjs
; -
interceptors/content-type.interceptor.ts
— опредениеContent-Type
дляHTTP
запросов; -
metrics
— сервис отрпавки событий вgoogle analytics
иyandex metrika
; -
navigation/mavigation.ts
— реализация путей в приложении; -
styles/extra-class.service.ts
— мой велосипед для навешивания классов; -
types/type.ts
— кастомные типов; -
utils
— утилиты работы с датами и сроками.
Особого внимания заслуживает только навигация.
Управление навигацией в приложении
Рассмотрим более детально реализацию управлением навигации.
Допустим у нас имеются роуты всего приложения:
export const PATHS = {
home: '',
homeAvia: '',
homeHotels: 'hotels',
homeTours: 'tours',
homeRailways: 'railways',
rules: 'rules',
terms: 'terms',
documents: 'documents',
faq: 'faq',
cards: 'cards',
login: 'login',
registration: 'registration',
notFound: 'not-found',
serverError: 'server-error',
permissionDenied: 'permission-denied',
search: 'search',
searchAvia: 'search/avia',
searchHotel: 'search/hotels',
searchTour: 'search/tours',
searchRailway: 'search/railways',
} as const;
PathValues
- тип, который будет представлять набор строк.
export type PathValues = (typeof PATHS)[keyof typeof PATHS];
type Filter<T extends string> = T extends `:${infer Param}` ? Param : never;
type Split<Value extends string> = Value extends `${infer LValue}/${infer RValue}` ? Filter<LValue> | Split<RValue> : Filter<Value>;
export type GetPathParams<T extends string> = {
[key in Split<T>]: string | number;
};
export interface NavigationLink<T extends PathValues = PathValues> {
readonly label: string;
readonly route: T;
readonly params?: GetPathParams<T>;
readonly suffix?: string;
}
NavigationLink
— интерфейс массива ссылок.
getRoute
разбивает строку с параметрами и заменяет найденные ключи на переданные значения:
export function getRoute<T extends PathValues>(path: T, params: Record<string, string | number> = {}): (string | number)[] {
const segments = path.split('/').filter((value) => value?.length);
const routeWithParams: (string | number)[] = ['/'];
for (const segment of segments) {
if (segment.charAt(0) === ':') {
const paramName = segment.slice(1);
if (params && params[paramName]) {
routeWithParams.push(params[paramName]);
} else {
routeWithParams.push(paramName);
}
} else {
routeWithParams.push(segment);
}
}
return routeWithParams;
}
Последние функции для "костылирования" путей в роутах приложения:
export function getChildPath(path: PathValues, parent: PathValues): string {
return path.substring(parent.length + 1);
}
export function childNavigation(route: NavigationChild, parent: PathValues): Route {
const redirectTo = route.redirectTo ? `/${route.redirectTo}` : undefined;
if (!route.path.length || route.path.length < parent.length + 1) {
return { ...route, redirectTo };
}
return {
...route,
redirectTo,
path: getChildPath(route.path, parent),
};
}
export function withChildNavigation(parent: PathValues): (route: NavigationChild) => Route {
return (route: NavigationChild) => childNavigation(route, parent);
}
Пример использования. В app.routes
:
export const routes: Routes = [
{
path: '',
loadComponent: () => import('@baf/ui/layout').then((m) => m.LayoutComponent),
children: [
{
path: PATHS.search,
loadChildren: () => import('./routes/search.routes').then((m) => m.searchRoutes),
},
],
},
];
В routes/search.routes.ts
:
import { Routes } from '@angular/router';
import { PATHS, withChildNavigation } from '@baf/core';
export const searchRoutes: Routes = [
{
path: PATHS.searchAvia,
title: $localize`:Search Page:Search for cheap flights`,
loadComponent: () => import('@baf/search/page').then((m) => m.SearchPageComponent),
},
].map(withChildNavigation(PATHS.search));
Аналитика
Сервис отрпавки событий в google analytics
и yandex metrika
- metrics
:
import { InjectionToken } from '@angular/core';
export interface MetricConfig {
readonly ids?: string[];
readonly counter?: number;
readonly domains: string[];
readonly paths: string[];
}
export const METRIC_CONFIG = new InjectionToken<MetricConfig>('MetricConfig');
MetricConfig
- конфигурация метрик google и yandeх:
-
ids
- список Google IDS; -
counter
- счетчик yandex metrika; -
domains
- список доменов для объединения сессий аналитики; -
paths
- набор путей, когда должны быть сброшены referrer'ы.
Общий сервис для отправки событий:
import { inject, Injectable } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { tap } from 'rxjs/operators';
import { GoogleAnalytics } from './google-analytics';
import { YandexMetrika } from './yandex.metrika';
export interface MetricOptions {
readonly [key: string]: unknown;
readonly ym?: Record<string, unknown>;
readonly ga?: Record<string, unknown>;
}
export function extractOptions(options?: MetricOptions): { readonly ym?: Record<string, unknown>; readonly ga?: Record<string, unknown> } {
let { ym, ga } = options ?? {};
if (options) {
if (!ym && !ga) {
ym = options;
ga = options;
}
}
return { ym, ga };
}
@Injectable()
export class MetricService {
private readonly router = inject(Router);
private readonly yandexMetrika = inject(YandexMetrika);
private readonly googleAnalytics = inject(GoogleAnalytics);
constructor() {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
tap((event) => this.navigation(event.urlAfterRedirects)),
takeUntilDestroyed(),
)
.subscribe();
}
init(customerId?: string | number) {
this.set({
ga: customerId ? { user_id: customerId } : undefined,
ym: customerId ? { UserID: customerId } : undefined,
});
}
navigation(url: string): void {
this.googleAnalytics.sendNavigation(url);
this.yandexMetrika.hit(url);
}
send(action: string, options?: MetricOptions): void {
const params = extractOptions(options);
this.googleAnalytics.sendEvent(action, params.ga);
this.yandexMetrika.reachGoal(action, params.ym);
}
set(options: MetricOptions): void {
const params = extractOptions(options);
if (params.ga) {
this.googleAnalytics.set(params.ga);
}
if (params.ym) {
this.yandexMetrika.set(params.ym);
}
}
}
Методы сервиса:
-
init
- инициализация счетчиков; -
navigation
- отправка события перехода на страницу; -
send
- регистрация event; -
set
- передача параметров в аналитику.
Реализация сервиса гугл аналитики:
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { METRIC_CONFIG } from './metrica.interface';
declare global {
interface Window {
readonly gtag?: (...params: unknown[]) => void;
}
}
@Injectable()
export class GoogleAnalytics {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly document = inject(DOCUMENT);
private readonly config = inject(METRIC_CONFIG);
private readonly title = inject(Title);
readonly gtag: (...params: unknown[]) => void;
constructor() {
if (this.isBrowser && typeof this.document.defaultView?.gtag !== 'undefined' && this.config.ids && this.config.ids?.length > 0) {
this.gtag = this.document.defaultView.gtag;
} else {
this.gtag = () => {};
}
}
set(payload: Record<string, unknown>): void {
this.gtag('set', payload);
}
sendEvent(action: string, payload?: Record<string, unknown>): void {
this.gtag('event', action, {
...payload,
event_category: payload?.['eventCategory'],
event_label: payload?.['eventLabel'],
value: payload?.['eventValue'],
});
}
sendNavigation(url: string): void {
if (
(this.isBrowser && !this.config.domains.every((domain) => this.document.referrer.indexOf(domain) < 0)) ||
!this.config.paths.every((path) => this.document.location.pathname.indexOf(path) < 0)
) {
this.set({ page_referrer: this.document.defaultView?.location.origin ?? '' });
}
if (this.config.ids) {
for (const key of this.config.ids) {
this.gtag('config', key, {
page_title: this.title.getTitle(),
page_path: url,
});
}
}
}
}
Яндекс метрика:
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { METRIC_CONFIG } from './metrica.interface';
declare global {
interface Window {
readonly ym?: (...params: unknown[]) => void;
}
}
@Injectable()
export class YandexMetrika {
private readonly document = inject(DOCUMENT);
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly config = inject(METRIC_CONFIG);
private readonly counter: (...params: unknown[]) => void;
constructor() {
if (this.isBrowser && typeof this.document.defaultView?.ym !== 'undefined' && !!this.config.counter) {
this.counter = this.document.defaultView.ym;
} else {
this.counter = () => {};
}
}
hit(url: string, options?: Record<string, unknown>): void {
let clearReferrer = false;
if (
(this.isBrowser && !this.config.domains.every((domain) => this.document.referrer.indexOf(domain) < 0)) ||
!this.config.paths.every((path) => this.document.location.pathname.indexOf(path) < 0)
) {
clearReferrer = true;
}
const optionsAll: { referer?: string } = { ...options };
if (clearReferrer) {
optionsAll.referer = '';
}
this.counter(this.config.counter, 'hit', url, optionsAll);
}
reachGoal(target: string, options?: Record<string, unknown>): void {
this.counter(this.config.counter, 'reachGoal', target, options);
}
set(params: Record<string, unknown>, options?: Record<string, unknown>): void {
this.counter(this.config.counter, 'userParams', params, options);
}
}
Core
Утилита для каста параметров запроса - api/params.ts
:
export type HttpParams = Record<string, string | number | boolean | ReadonlyArray<string | number | boolean>>;
export function castParams(all: Record<string, unknown>): HttpParams {
const params: HttpParams = {};
for (const [key, value] of Object.entries(all)) {
if (Array.isArray(value) && value.length > 0) {
params[key] = value.filter((val) => {
return typeof val === 'string' || typeof val === 'boolean' || typeof val === 'number';
});
} else if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') {
params[key] = value;
}
}
return params;
}
Установка валюты - currency/currency.ts
:
import { DEFAULT_CURRENCY_CODE, Provider } from '@angular/core';
export function provideCurrency(currencyCode: string): Provider {
return {
provide: DEFAULT_CURRENCY_CODE,
useValue: currencyCode,
};
}
Получение и использование переменных окружения - env/environment.ts
:
import type { ApplicationConfig} from '@angular/core';
import { APP_INITIALIZER, makeStateKey, TransferState } from '@angular/core';
export const ENV_KEY = makeStateKey<Environment>('Environment');
export interface Environment {
readonly aviasalesToken: string;
readonly hotellookToken: string;
}
export const ENV_DEFAULT: Environment = {
aviasalesToken: '',
hotellookToken: '',
};
export function provideEnv() {
return [
{
provide: APP_INITIALIZER,
useFactory: (transferState: TransferState) => {
return () => {
transferState.set<Environment>(ENV_KEY, {
aviasalesToken: process.env['AVIASALES_TOKEN'] ?? ENV_DEFAULT.aviasalesToken,
hotellookToken: process.env['HOTELLOOK_TOKEN'] ?? ENV_DEFAULT.hotellookToken,
});
};
},
deps: [TransferState],
multi: true,
},
];
}
export const envConfig: ApplicationConfig = {
providers: [provideEnv()],
};
Типизация форм - form/form.ts
:
import type { FormControl, FormGroup } from '@angular/forms';
export type FormFor<T> = {
[P in keyof T]: FormControl<T[P]>;
};
export type FormWithSubFor<T> = {
[P in keyof T]: T[P] extends Record<string, unknown> ? FormGroup<FormFor<T[P]>> : FormControl<T[P]>;
};
export function castQueryParams(queryParams: Record<string, unknown>, props?: string[]): Record<string, unknown> {
const mapped: Record<string, unknown> = {};
const keys = props ?? Object.keys(queryParams);
for (const key of keys) {
const value = queryParams[key];
if (typeof value === 'string' && value.length > 0) {
if (['true', 'false'].includes(value)) {
mapped[key] = value === 'true';
} else if (!isNaN(Number(value))) {
mapped[key] = Number(value);
} else {
mapped[key] = value;
}
} else if (typeof value === 'boolean') {
mapped[key] = value;
} else if (typeof value === 'number' && value > 0) {
mapped[key] = value;
}
}
return mapped;
}
Директива обнаружения изменений в FormControl
:
import type { FormControl, FormGroup } from '@angular/forms';
export type FormFor<T> = {
[P in keyof T]: FormControl<T[P]>;
};
export type FormWithSubFor<T> = {
[P in keyof T]: T[P] extends Record<string, unknown> ? FormGroup<FormFor<T[P]>> : FormControl<T[P]>;
};
export function castQueryParams(queryParams: Record<string, unknown>, props?: string[]): Record<string, unknown> {
const mapped: Record<string, unknown> = {};
const keys = props ?? Object.keys(queryParams);
for (const key of keys) {
const value = queryParams[key];
if (typeof value === 'string' && value.length > 0) {
if (['true', 'false'].includes(value)) {
mapped[key] = value === 'true';
} else if (!isNaN(Number(value))) {
mapped[key] = Number(value);
} else {
mapped[key] = value;
}
} else if (typeof value === 'boolean') {
mapped[key] = value;
} else if (typeof value === 'number' && value > 0) {
mapped[key] = value;
}
}
return mapped;
}
Настройка hammerjs
:
import type { EnvironmentProviders, Provider } from '@angular/core';
import { importProvidersFrom, Injectable } from '@angular/core';
import { HAMMER_GESTURE_CONFIG, HammerGestureConfig, HammerModule } from '@angular/platform-browser';
@Injectable()
export class HammerConfig extends HammerGestureConfig {
override overrides = {
swipe: { velocity: 0.4, threshold: 20 },
pinch: { enable: false },
rotate: { enable: false },
};
}
export function provideHammer(): (Provider | EnvironmentProviders)[] {
return [
importProvidersFrom(HammerModule),
{
provide: HAMMER_GESTURE_CONFIG,
useClass: HammerConfig,
},
];
}
Установка Content-Type
для HTTP
запросов -interceptors/content-type.interceptor.ts
:
import type { HttpInterceptorFn } from '@angular/common/http';
export const contentTypeInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.headers.has('Content-Type') && req.headers.get('enctype') !== 'multipart/form-data') {
req = req.clone({ headers: req.headers.set('Content-Type', 'application/json') });
}
return next(req);
};
Сервис для добавления классов компонентам и директивам - styles/extra-class.service.ts
:
import { DestroyRef, ElementRef, inject, Injectable, Renderer2 } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import type { Observable, Subscription } from 'rxjs';
import { tap } from 'rxjs';
type Styles = string | string[] | undefined | null;
export function toClass(value: unknown | undefined | null, prefix = 'is'): Styles {
return value ? `${prefix}-${value}` : undefined;
}
@Injectable()
export class ExtraClassService {
private readonly destroyRef = inject(DestroyRef);
private readonly render = inject(Renderer2);
private readonly elementRef: ElementRef<HTMLElement> = inject(ElementRef);
private styles: Record<string, Styles> = {};
private subscriptions: Record<string, Subscription> = {};
update(key: string, styles: Styles): void {
if (this.styles[key]) {
const lastStyles = this.styles[key];
if (Array.isArray(lastStyles)) {
lastStyles.map((style) => this.render.removeClass(this.elementRef.nativeElement, style));
} else if (lastStyles) {
this.render.removeClass(this.elementRef.nativeElement, lastStyles);
}
}
if (Array.isArray(styles)) {
styles.map((style) => this.render.addClass(this.elementRef.nativeElement, style));
} else if (styles) {
this.render.addClass(this.elementRef.nativeElement, styles);
}
this.styles[key] = styles;
}
patch(style: string, active: boolean): void {
if (active) {
this.render.addClass(this.elementRef.nativeElement, style);
} else {
this.render.removeClass(this.elementRef.nativeElement, style);
}
}
register(key: string, observable: Observable<unknown>, callback: () => void, start = true): void {
if (this.subscriptions[key]) {
this.unregister(key);
}
if (start) {
callback();
}
this.subscriptions[key] = observable
.pipe(
tap(() => callback()),
takeUntilDestroyed(this.destroyRef),
)
.subscribe();
}
unregister(key: string): void {
this.subscriptions[key].unsubscribe();
}
}
Кастомные типы - types/type.ts
:
export type ChangeFn = (value: any) => void;
export type TouchedFn = () => void;
export type DisplayFn = (value: any, index?: number) => string;
export type MaskFn = (value: any) => string;
export type StyleFn = (value?: any) => string | string[];
export type CoerceBoolean = boolean | string | undefined | null;
Утилиты работы с датами и сроками - utils
:
export function getDaysBetweenDates(startDate: Date | string, endDate: Date | string): number {
return Math.round((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000);
}
Короткая реализация для uuid
:
export function uuidv4(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
И функция для гуманизации:
export function camelCaseToHumanize(str: string): string {
return str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
}
В следующей статье будем разрабатывать свой UI KIT.
Ссылки
Все исходники находятся на github, в репозитории - github.com/Fafnur/buy-and-fly
Демо можно посмотреть здесь - buy-and-fly.fafn.ru/
Top comments (0)