Core - это набор функций, конфигов и утилит, которые используются в приложении.
Обычно, core - то, что вы тащите из проекта в проект. В данном случае, я пытался взять только необходимое.
Коротко о всем core:
Особого внимания заслуживает только навигация.
Рассмотрим более детально реализацию управлением навигации.
Допустим у нас имеются роуты всего приложения:
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 `:${infer Param}` ? Param : never; type Split = Value extends `${infer LValue}/${infer RValue}` ? Filter | Split : Filter ; export type GetPathParams = { [key in Split ]: string | number; }; export interface NavigationLink { readonly label: string; readonly route: T; readonly params?: GetPathParams ; readonly suffix?: string; }
NavigationLink — интерфейс массива ссылок.
getRoute разбивает строку с параметрами и заменяет найденные ключи на переданные значения:
export function getRoute(path: T, params: Record = {}): (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 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 - конфигурация метрик google и yandeх:
Общий сервис для отправки событий:
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; readonly ga?: Record ; } export function extractOptions(options?: MetricOptions): { readonly ym?: Record ; readonly ga?: Record } { 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); } } }
Методы сервиса:
Реализация сервиса гугл аналитики:
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): void { this.gtag('set', payload); } sendEvent(action: string, payload?: Record ): 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) this.document.location.pathname.indexOf(path) Яндекс метрика:
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): void { let clearReferrer = false; if ( (this.isBrowser && !this.config.domains.every((domain) => this.document.referrer.indexOf(domain) this.document.location.pathname.indexOf(path) ): void { this.counter(this.config.counter, 'reachGoal', target, options); } set(params: Record , options?: Record ): void { this.counter(this.config.counter, 'userParams', params, options); } } Core
Утилита для каста параметров запроса - api/params.ts:
export type HttpParams = Record>; export function castParams(all: Record ): 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'); 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 (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= { [P in keyof T]: FormControl ; }; export type FormWithSubFor = { [P in keyof T]: T[P] extends Record ? FormGroup > : FormControl ; }; export function castQueryParams(queryParams: Record , props?: string[]): Record { const mapped: Record = {}; 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= { [P in keyof T]: FormControl ; }; export type FormWithSubFor = { [P in keyof T]: T[P] extends Record ? FormGroup > : FormControl ; }; export function castQueryParams(queryParams: Record , props?: string[]): Record { const mapped: Record = {}; 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= inject(ElementRef); private styles: Record = {}; private subscriptions: Record = {}; 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 , 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/
Мои группы: telegram, medium, vk, x.com, linkedin, site
Clause de non-responsabilité: Toutes les ressources fournies proviennent en partie d'Internet. En cas de violation de vos droits d'auteur ou d'autres droits et intérêts, veuillez expliquer les raisons détaillées et fournir une preuve du droit d'auteur ou des droits et intérêts, puis l'envoyer à l'adresse e-mail : [email protected]. Nous nous en occuperons pour vous dans les plus brefs délais.
Copyright© 2022 湘ICP备2022001581号-3