В Angular взаимодействие с Google Maps JavaScript API логично выносится в сервисный слой, поскольку карта представляет собой долгоживущий императивный объект, не вписывающийся в декларативную модель компонентов. Сервисы позволяют изолировать:
Такой подход формирует стабильную точку управления картографическим состоянием приложения и снижает связность UI-компонентов с внешним API.
Первичная задача — корректная инициализация библиотеки. Скрипт Google Maps загружается динамически, чтобы избежать блокировки загрузки приложения и обеспечить контроль над повторной инициализацией.
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class GoogleMapsLoaderService {
private static promise: Promise<typeof google> | null = null;
load(apiKey: string): Promise<typeof google> {
if (GoogleMapsLoaderService.promise) {
return GoogleMapsLoaderService.promise;
}
GoogleMapsLoaderService.promise = new Promise((resolve, reject) => {
if (typeof google !== 'undefined' && google.maps) {
resolve(google);
return;
}
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places,geometry`;
script.async = true;
script.defer = true;
script.onl oad = () => resolve(google);
script.oner ror = (err) => reject(err);
document.head.appendChild(script);
});
return GoogleMapsLoaderService.promise;
}
}
Ключевая особенность — использование статического кеша
promise, предотвращающего повторную загрузку API при
множественных обращениях из разных частей приложения.
После загрузки API требуется централизованное управление объектом
google.maps.Map. В Angular это обычно реализуется через
отдельный сервис, который хранит ссылку на карту и предоставляет методы
взаимодействия.
import { Injectable, NgZone } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class MapService {
private map?: google.maps.Map;
private centerSubject = new BehaviorSubject<google.maps.LatLngLiteral | null>(null);
center$ = this.centerSubject.asObservable();
constructor(private ngZone: NgZone) {}
initMap(mapElement: HTMLElement, options: google.maps.MapOptions): google.maps.Map {
this.map = new google.maps.Map(mapElement, options);
this.map.addListener('center_changed', () => {
const center = this.map?.getCenter();
if (center) {
this.ngZone.run(() => {
this.centerSubject.next({
lat: center.lat(),
lng: center.lng()
});
});
}
});
return this.map;
}
setCenter(lat: number, lng: number): void {
if (!this.map) return;
this.map.setCenter({ lat, lng });
}
getMap(): google.maps.Map | undefined {
return this.map;
}
}
Использование NgZone критично: события Google Maps
происходят вне Angular Zone, и без явного возврата в зону изменения не
будут корректно отражаться в UI.
При росте функциональности карта начинает включать множество сущностей: маркеры, инфо-окна, полигоны. Централизация всей логики в одном сервисе приводит к перегрузке. Поэтому выделяется отдельный слой управления маркерами.
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class MarkerService {
private markers: google.maps.Marker[] = [];
addMarker(map: google.maps.Map, position: google.maps.LatLngLiteral, title?: string): google.maps.Marker {
const marker = new google.maps.Marker({
position,
map,
title
});
this.markers.push(marker);
return marker;
}
clearMarkers(): void {
this.markers.forEach(m => m.setMap(null));
this.markers = [];
}
setMarkers(map: google.maps.Map, positions: google.maps.LatLngLiteral[]): void {
this.clearMarkers();
positions.forEach(pos => this.addMarker(map, pos));
}
}
Такой сервис позволяет централизованно управлять жизненным циклом объектов карты и упрощает очистку при смене маршрута или данных.
Компонент не должен напрямую работать с google.maps.Map
или DOM-скриптами. Его роль ограничивается передачей контейнера и
отображением состояния.
import { Component, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { GoogleMapsLoaderService } from './google-maps-loader.service';
import { MapService } from './map.service';
@Component({
selector: 'app-map',
template: `<div #mapContainer class="map-container"></div>`
})
export class MapComponent implements AfterViewInit {
@ViewChild('mapContainer', { static: true }) mapElement!: ElementRef<HTMLDivElement>;
constructor(
private loader: GoogleMapsLoaderService,
private mapService: MapService
) {}
async ngAfterViewInit(): Promise<void> {
const google = await this.loader.load('API_KEY');
this.mapService.initMap(this.mapElement.nativeElement, {
center: { lat: 51.1694, lng: 71.4491 },
zoom: 10
});
}
}
Компонент остается максимально «тонким», делегируя всю логику сервисам.
Для синхронизации состояния карты между различными частями приложения используется RxJS. Карта рассматривается как источник событий: изменение центра, масштаба, выбранных объектов.
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface MapState {
zoom: number;
center: google.maps.LatLngLiteral;
}
@Injectable({ providedIn: 'root' })
export class MapStateService {
private stateSubject = new BehaviorSubject<MapState | null>(null);
state$ = this.stateSubject.asObservable();
updateState(state: MapState): void {
this.stateSubject.next(state);
}
}
Интеграция с MapService позволяет синхронизировать
изменения:
this.map.addListener('zoom_changed', () => {
const zoom = this.map?.getZoom();
const center = this.map?.getCenter();
if (zoom && center) {
this.ngZone.run(() => {
this.stateService.updateState({
zoom,
center: {
lat: center.lat(),
lng: center.lng()
}
});
});
}
});
Google Maps активно использует собственный event loop. Это приводит к ситуации, когда изменения происходят вне зоны Angular, что блокирует автоматическое обновление представления.
Использование NgZone.runOutsideAngular снижает нагрузку
на change detection:
this.ngZone.runOutsideAngular(() => {
this.map.addListener('idle', () => {
const center = this.map.getCenter();
console.log(center?.toJSON());
});
});
При необходимости обновления UI управление возвращается в Angular зону.
Для повышения стабильности кода часто вводятся собственные интерфейсы поверх Google Maps API:
export interface MapPoint {
lat: number;
lng: number;
label?: string;
}
export interface MapConfig {
center: MapPoint;
zoom: number;
disableDefaultUI?: boolean;
}
Это позволяет абстрагироваться от внешней библиотеки и снижает связанность доменной логики с Google Maps.
Сервисы должны учитывать необходимость освобождения ресурсов при уничтожении компонентов или смене маршрутов. Особенно это важно при работе с подписками и слушателями событий карты.
destroyMap(): void {
if (!this.map) return;
google.maps.event.clearInstanceListeners(this.map);
this.map = undefined;
}
Для маркеров аналогично требуется очистка, иначе возникают утечки памяти при динамической навигации.
В сложных приложениях карта может использоваться в нескольких модулях. Повторное создание экземпляра приводит к деградации производительности. Решение — хранение единственного экземпляра в сервисе.
private mapInstance?: google.maps.Map;
getOrCreateMap(element: HTMLElement, options: google.maps.MapOptions): google.maps.Map {
if (this.mapInstance) {
return this.mapInstance;
}
this.mapInstance = new google.maps.Map(element, options);
return this.mapInstance;
}
Такой подход превращает сервис в singleton-менеджер состояния карты.
По мере усложнения логики вводится дополнительный слой абстракции:
Разделение на слои позволяет независимо расширять функциональность без модификации базового сервиса карты.
Вынос логики в сервисы упрощает модульное тестирование. Вместо работы с DOM и API Google Maps можно мокировать зависимости.
const mockMap = jasmine.createSpyObj('Map', ['setCenter', 'getCenter']);
TestBed.configureTestingModule({
providers: [
MapService
]
});
Изоляция логики позволяет проверять поведение сервисов без реальной загрузки Google Maps API.
API-ключ и параметры подключения не должны быть зашиты в код.
export const environment = {
googleMapsApiKey: 'KEY',
mapDefaultZoom: 12
};
Сервис загрузки API использует конфигурацию через DI или environment слой, что обеспечивает переносимость между окружениями.
При работе с сотнями маркеров стандартный подход становится неэффективным. Используются оптимизации:
Кластеризация обычно выносится в отдельный сервис, который агрегирует точки и минимизирует количество DOM-объектов на карте.
Сервисы формируют единый слой событийной коммуникации:
Эта модель позволяет строить реактивную архитектуру, где карта становится источником потоков данных, а Angular-компоненты лишь потребляют их через Observable-интерфейсы.