Angular 18 でのコア サービスとユーティリティの開発

DDD
リリース: 2024-09-13 22:19:07
オリジナル
809 人が閲覧しました

Разработка core сервисов и утилит в Angular 18

コアは、アプリケーションで使用される関数、構成、ユーティリティのセットです。

通常、コアはプロジェクト間でドラッグするものです。今回は必要なものだけを取るようにしてみました。

すべての核心を簡単に説明します:

  • api/params.ts - パラメータを設定するためのユーティリティ。たとえば、クエリパラメータにはbaggage=trueがありますが、trueは文字列ではなくブール値である必要があります;
  • 通貨/通貨.ts — 通貨設定;
  • env/environment.ts - .env の取得と使用;
  • form/form.ts - FormGroup;
  • の少しの入力
  • form/extract-changes.directive.ts - FormControl の変更検出ディレクティブ;
  • Hammer/hammer.ts - Hammerjs 設定;
  • interceptors/content-type.interceptor.ts - HTTP リクエストの Content-Type を決定します;
  • メトリクス - Google Analytics および yandex メトリクスにイベントを送信するためのサービス;
  • 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 `:${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 < 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 にイベントを送信するためのサービス - メトリクス:

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 および Yandex メトリクスの構成:

  • id - Google IDS リスト;
  • カウンター - yandex metrika カウンター;
  • ドメイン - 分析セッションを結合するためのドメインのリスト;
  • paths - リファラーをリセットする必要があるときのパスのセット。

イベント送信のための一般的なサービス:

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); } } }
ログイン後にコピー

サービス方法:

  • init - カウンターの初期化;
  • ナビゲーション - ページ遷移イベントの送信;
  • 送信 - 登録イベント;
  • set - 分析にパラメーターを渡します。

Google Analytics サービスの実装:

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) < 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, }); } } } }
ログイン後にコピー

Yandex メトリクス:

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) < 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): void { this.counter(this.config.counter, 'reachGoal', target, options); } set(params: Record, options?: Record): void { this.counter(this.config.counter, 'userParams', params, options); } }
ログイン後にコピー

コア

リクエストパラメータをキャストするためのユーティリティ - 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.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, }, ]; }
ログイン後にコピー

HTTP リクエストの Content-Type の設定 -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

以上がAngular 18 でのコア サービスとユーティリティの開発の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

ソース:dev.to
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート
私たちについて 免責事項 Sitemap
PHP中国語ウェブサイト:福祉オンライン PHP トレーニング,PHP 学習者の迅速な成長を支援します!