>  기사  >  웹 프론트엔드  >  Angular 18의 핵심 서비스 및 유틸리티 개발

Angular 18의 핵심 서비스 및 유틸리티 개발

DDD
DDD원래의
2024-09-13 22:19:07937검색

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

코어는 애플리케이션에서 사용되는 기능, 구성 및 유틸리티의 집합입니다.

일반적으로 핵심은 프로젝트 간에 드래그하는 것입니다. 이 경우 꼭 필요한 것만 가져가려고 노력했습니다.

핵심에 대한 간략한 설명:

  • api/params.ts - 매개변수 설정을 위한 유틸리티입니다. 예를 들어 쿼리 매개변수에는 Baggage=true가 있지만 true는 문자열이 아니라 부울이어야 합니다.
  • 통화/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 - HTTP 요청에 대한 Content-Type 결정
  • 측정항목 - 이벤트를 Google Analytics 및 Yandex 측정항목으로 전송하는 서비스
  • Navigation/mavigation.ts - 애플리케이션에서 경로 구현
  • styles/extra-class.service.ts - 교수형 수업을 위한 내 자전거;
  • 유형/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로 이벤트를 전송하는 서비스 - 측정항목:

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 및 Yandex 측정항목 구성:

  • ids - Google IDS 목록;
  • 카운터 - yandex metrika 카운터;
  • 도메인 - 분석 세션 결합을 위한 도메인 목록
  • 경로 - 리퍼러를 재설정해야 하는 경로 집합입니다.

이벤트 발송 일반 서비스:

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 - 카운터 초기화;
  • 네비게이션 - 페이지 전환 이벤트 보내기
  • 보내기 - 등록이벤트;
  • 설정 - 매개변수를 분석에 전달합니다.

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<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,
        });
      }
    }
  }
}

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<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);
  }
}

핵심

요청 매개변수 캐스팅을 위한 유틸리티 - 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;
}

통화 설정 - 통화/통화.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,
    },
  ];
}

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();
  }
}

사용자 정의 유형 - type/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 중국어 웹사이트의 기타 관련 기사를 참조하세요!

성명:
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.