
核心是應用程式中使用的一組函數、設定和實用程式。
通常,核心是您從一個專案拖曳到另一個專案的內容。在這種情況下,我嘗試只採取必要的措施。
簡單介紹所有核心內容:
只有導航值得特別注意。
讓我們仔細看看導航控制的實作。
假設我們有整個應用程式的路由:
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 指標的配置:
發送事件的一般服務:
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);
}
}
}
服務方式:
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;
}
設定貨幣 -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,
},
];
}
設定 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<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/
Мои группы: telegram, medium, vk, x.com, linkedin, site
以上是Angular 18 中核心服務與實用程式的開發的詳細內容。更多資訊請關注PHP中文網其他相關文章!