Components: Document approaches for lazy-loading the Google Maps API

Created on 27 Nov 2019  ยท  16Comments  ยท  Source: angular/components

Feature Description

So great to see google maps being added as apart of this library. I have had an internal version for my own projects for some time(back since angular.js and now upgraded/rewrite for angular 2+) now which wrapped the google maps lib to provide an angular integration.

One of the major issues we had was you do not want to wait for or load the google maps api on every page when it isn't needed. For our use case we were only using it on a details page for 2 records + on some reports if they happened to have a map. It required some heavy changes to make all google map related calls actually start from a promise/observable which loads the sdk first if it isn't already present.

I get that there is the async and other script tags to defer loading but still once you need this feature it is basically a must have and it also requires some hard API changes so I'd really hope it's could be applied sooner rather than later.

I currently have directives/components for the following:

  • routes
  • markers
  • heat maps
  • the map itself

@mbehrlich From release notes I think you are the primary person working on maps. Would be great to get your thoughts on this.

Use Case

Most apps aren't loading maps on every page and the maps sdk is quite large if you aren't using it.

P3 google-maps docs feature help wanted

Most helpful comment

https://github.com/angular/components/pull/20492/files
https://github.com/angular/components/tree/master/src/google-maps#lazy-loading-the-api

This new doc lazyload example would have an issue with
You have included the Google Maps JavaScript API multiple times on this page. This may cause unexpected errors. when the same Google map component is used multiple times within a single-page app.

any better solutions?

All 16 comments

@epelc to clarify, are you talking about dynamically loading the _Angular component_ or dynamically loading the underlying Google Maps API? Currently the Angular component shouldn't have any opinions about how/when the underlying maps API is loaded.

I am saying to dynamically load the underlying maps api. Usually you could
manage yourself but I guess youโ€™d need the library to either expose this
loading(I used this option to great effect) or have it be able to request
the maps lib be loaded when it is needed. Does that make sense?

The Angular component doesn't care when the Google Maps API is loaded, so long as it's before you try to _show_ the Angular component. Here's an example that dynamically loads the Google Maps API before showing the component: https://stackblitz.com/edit/angular-sym9gg

I agree with @epelc on loading the SDK only when you are actually showing an instance of a map. I required 2-way-binding with map markers and polygons so I developed a similar Google Maps Angular wrapper for internal use.

To achieve this I had a GoogleMapsApiLoaderService inject the maps api script and provide an observable to fire when the script is loaded. The map component would init this service and wait for the script before attempting to access anything under google.maps namespace. Components nested in the Map components gets a reference to the Map so they, in turn, wait for the map to initialize before attempting to use the google.maps namespace.

Regardless of how you are async loading the maps api script, it opens up a can of worms. Here are some of the problems I ran into:

  • The maps api uses lots of enums. You cannot use these enums in configuration constants unless the api is already loaded. Thus, I had to redeclare enums and options interfaces for static configuration.
  • I needed to store the map marker locations, zoom, polygons, etc in state and in form controls. This meant I had to constantly convert from MVCObjects to object literals and vice versa.

Here is an example of a Polygon directive with 2-way binding: polygon.directive.ts

Would be nice to have an onReady() output event similar to the one in the agm library here.

@Output() mapReady: EventEmitter<any> = new EventEmitter<any>();

I toggle the google-map with an *ngIf and I need to know when it appears again to add my Drawing Controls on it.

https://github.com/angular/components/pull/20492/files
https://github.com/angular/components/tree/master/src/google-maps#lazy-loading-the-api

This new doc lazyload example would have an issue with
You have included the Google Maps JavaScript API multiple times on this page. This may cause unexpected errors. when the same Google map component is used multiple times within a single-page app.

any better solutions?

Hi @weilinzung looks unrelated to this issue but I took a quick peak.

Basically that example is loading the api every time you use that component even if it's only 1 page and you end up navigating via router to that page, another page, then back again you end up with the google maps api loaded multiple times. That example looks like it is broken outside of a single page single component demo.

Fixing duplicate loading

You should be able to refactor the httpClient.jsonp('https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE', 'callback') and surrounding api loading into a service so that it can act as a singleton and prevent multiple loads. Note you will want to add providedIn: 'root' to your @Injectable so that it does not create an instance per module and thus load the api for each instance.

@epelc thanks for that info. I am kind of new with Angular providedIn: 'root', do you know any simple examples for that? thank you!

@weilinzung should explain it here https://angular.io/tutorial/toh-pt4

Basically run ng generate service GMapLoader then move the apiLoaded field onto that and start using the service instead of the raw field on your components.

@epelc If use the service method and add to the shared map component, wouldn't still load httpClient.jsonp('https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE', 'callback') multiple times?

I had to move everything to the top top level, so I think that way could work for me.

I can't fix duplicate loading even after creating a singleton service. Can you please explain what does "move everything to the top top level" mean?

A singleton service by itself isn't enough because the Observable created from HttpClient sends an HTTP request every time it is subscribed to. We solved this by creating an injection token that only issues the HTTP request the first time the observable is subscribed to.

export const MAP_LOADED = new InjectionToken<Observable<boolean>>('Google Maps API Loaded');

// in module
{provide: MAP_LOADED, useFactory: loadMapApi, deps: [HttpClient]}

function loadMapApi(httpClient: HttpClient) {
    return httpsClient.jsonp('https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE', 'callback).pipe(
        shareReplay({bufferSize: 1, refCount: true),
        map(() => true),
        catchError(e => {
            console.log(e);
            return of(false);
        })
    );
}

// in component
constructor(@Inject(MAP_LOADED) public mapLoaded$: Observable<boolean>) { }

<google-map *ngIf="mapLoaded$ | async"></google-map>

However, this approach does not work if you want to lazy load the map API in different modules with different libraries as the URL is not the same.

Hi guys! What about that?

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { environment } from 'src/environments/environment';

@Injectable({
  providedIn: 'root',
})
export class GoogleMapsService {
  private isApiLoaded: BehaviorSubject<boolean> = new BehaviorSubject(false);
  isApiLoaded$ = this.isApiLoaded.asObservable();

  constructor(httpClient: HttpClient) {
    console.log('๐Ÿš€ ~ GoogleMapsService ~ loading google maps api...');

    const key = environment.googleMapsApiKey;
    httpClient
      .jsonp(`https://maps.googleapis.com/maps/api/js?key=${key}`, 'callback')
      .subscribe(
        () => {
          console.log('๐Ÿš€ ~ GoogleMapsService ~ google maps api loaded');
          this.isApiLoaded.next(true);
        },
        (error) => {
          console.log('๐Ÿš€ ~ GoogleMapsService ~ google maps api cannot be loaded', error);
        }
      );
  }
}

Hi guys! What about that?

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { environment } from 'src/environments/environment';

@Injectable({
  providedIn: 'root',
})
export class GoogleMapsService {
  private isApiLoaded: BehaviorSubject<boolean> = new BehaviorSubject(false);
  isApiLoaded$ = this.isApiLoaded.asObservable();

  constructor(httpClient: HttpClient) {
    console.log('๐Ÿš€ ~ GoogleMapsService ~ loading google maps api...');

    const key = environment.googleMapsApiKey;
    httpClient
      .jsonp(`https://maps.googleapis.com/maps/api/js?key=${key}`, 'callback')
      .subscribe(
        () => {
          console.log('๐Ÿš€ ~ GoogleMapsService ~ google maps api loaded');
          this.isApiLoaded.next(true);
        },
        (error) => {
          console.log('๐Ÿš€ ~ GoogleMapsService ~ google maps api cannot be loaded', error);
        }
      );
  }
}

That seems like it would generally work but you'd have to be careful about providing that service separately at the component level as it would create a new instance and therefore load the script twice.

@Component({ provide: [GoogleMapsService] // will load a second time })
I'd still suggest storing the replay of the observable.

>

That seems like it would generally work but you'd have to be careful about providing that service separately at the component level as it would create a new instance and therefore load the script twice.

@Component({ provide: [GoogleMapsService] // will load a second time })
I'd still suggest storing the replay of the observable.

first of all, thank you for your suggestions ๐Ÿ˜„
I'm using this service in 3 different ionic pages (โ€œpageโ€ is just terminology to identify a component that is being used as a screen in an Ionic application, there is nothing special that differentiates a page to a normal Angular component.):

import { Component, OnInit } from '@angular/core';
import { GoogleMapsService } from 'src/app/core/services/internal/google-maps.service';

@Component({
  selector: 'app-asset-form',
  templateUrl: './asset-form.page.html',
  styleUrls: ['./asset-form.page.scss'],
})
export class AssetFormPage implements OnInit {
  isMapApiLoaded$: Observable<boolean>;
  mapOptions: google.maps.MapOptions;
  mapMarkerOptions: google.maps.MarkerOptions = {
    draggable: false,
  };
  mapMarkerPositions: google.maps.LatLngLiteral[] = [];

  constructor(private readonly googleMapsService: GoogleMapsService) {}

  ngOnInit() {
    this.initMap();
  }

  private initMap() {
    this.isMapApiLoaded$ = this.googleMapsService.isApiLoaded$;
    this.mapOptions = {
      center: {
        lat: 38.8303836,
        lng: 0.1041089,
      },
      disableDefaultUI: true,
      gestureHandling: 'cooperative',
      zoom: 5,
    };
  }
}

I didn't see nor You have included the Google Maps JavaScript API multiple times on this page. This may cause unexpected errorsnot๐Ÿš€ ~ GoogleMapsService ~ google maps api loaded messages in console

Is this ok or I must to improve something?

Thanks in advance!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

Daugoh picture Daugoh  ยท  79Comments

jmcgoldrick picture jmcgoldrick  ยท  59Comments

julianobrasil picture julianobrasil  ยท  78Comments

abdulkareemnalband picture abdulkareemnalband  ยท  165Comments

kara picture kara  ยท  94Comments