Risan Bagja

Lazy Load Google Maps API

Yesterday I was combing through the vue2-google-maps source code. It’s a collection of Google Maps components for Vue. Then I found an interesting bit of how it loads the Google Maps JavaScript API lazily (check the src/manager.js and the src/main.js).

It’s pretty useful, especially if you build a SPA (single page application) where there are pages that don’t actually use the Google Maps API. By lazy loading the Google Maps API, your users will only have to download the library once they hit the page that uses it.

Inject Google Maps API Programmatically

Let’s get to the code! I’ll use the snippets from vue2-google-maps, but making it a bit simpler. First, we’ll need a function to dynamically inject the Google Maps JavaScript API to our page:

let googleMapsScriptIsInjected = false;

const injectGoogleMapsApiScript = (options = {}) => {
  if (googleMapsScriptIsInjected) {
    throw new Error('Google Maps Api is already loaded.');
  }

  const optionsQuery = Object.keys(options)
    .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`)
    .join('&');

  const url = `https://maps.googleapis.com/maps/api/js?${optionsQuery}`;

  const script = document.createElement('script');

  script.setAttribute('src', url);
  script.setAttribute('async', '');
  script.setAttribute('defer', '');

  document.head.appendChild(script);

  googleMapsScriptIsInjected = true;
};

When you call this injectGoogleMapsApiScript function, it will inject the Google Maps API JavaScript into the document’s <head>. We also use the googleMapsScriptIsInjected variable to make sure that the Google Maps library is injected only once. Here’s how we call this function:

injectGoogleMapsApiScript({
  key: YOUR_API_KEY,
  callback: 'initMap',
});

And here’s an example of the <script> that being injected to the <head>.

// Here's the script that will be injected
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap"
    async defer></script>

Get Google Maps API Instance

The async and defer attributes are used to tell the browser to load the given script asynchronously. So even if you put your JavaScript code after the Google Maps API <script> tag, there’s no guarantee that Google Maps API instance is available for you to use.

That’s why you can pass the callback query parameter when loading the Google Maps API. The callback parameter defines the function name that will be called once the Google Maps API is loaded. When it’s loaded, the Google Maps API instance will be available globally under the google.maps name.

For a simple application, you can just put your Google Maps related code within the callback function and you’re good to go. But it’s a bit trickier for a SPA where the components that rely on the Google Maps API spread across multiple pages.

So how do we let our application knows when the callback function is invoked thus our code can access the Google Maps API instance? With Promise!

let googleMapsApiPromise = null;

const loadGoogleMapsApi = (apiKey, options = {}) => {
  if (!googleMapsApiPromise) {
    googleMapsApiPromise  = new Promise((resolve, reject) => {
      try {
        window.onGoogleMapsApiLoaded = resolve;

        injectGoogleMapsApiScript({
          key: apiKey,
          callback: 'onGoogleMapsApiLoaded',
          ...options,
        });
      } catch (error) {
        reject(error);
      }
    }).then(() => window.google.maps);
  }

  return googleMapsApiPromise;
};

The loadGoogleMapsApi function above will return a Promise where it’s resolve function is set as the Google Maps API callback parameter. This way when the Google Maps API is loaded, the Promise will be resolved and move to the next then block that returns the Google Maps API instance.

Here’s how you’re going to use this function:

loadGoogleMapsApi(YOUR_API_KEY)
  .then((maps) => {
    // Your google maps code
    const myMap = new maps.Map(element, options);
    const myMarker = new maps.Marker({ map: myMap });
  });

There’s still a drawback though, where you have to provide the API key every time you call the loadGoogleMapsApi function. You can of course hardcode the API key within the loadGoogleMapsApi or the injectGoogleMapsApiScript functions, but it’ll make your functions less reusable.

To solve this, we can wrap the returned Promise within the Lodash’s once function or this lazy-value function. It’s super simple though, so let’s write it ourself:

const lazyValue = (fn) => {
  let called = false;
  let returnValue;

  return () => {
    if (!called) {
      called = true;
      returnValue = fn();
    }

    return returnValue;
  };
};

const loadGoogleMapsApi = (apiKey, options = {}) => lazyValue(
  () => new Promise((resolve, reject) => {
    try {
      window.onGoogleMapsApiLoaded = resolve;

      injectGoogleMapsApiScript({
        key: apiKey,
        callback: 'onGoogleMapsApiLoaded',
        ...options,
      });
    } catch (error) {
      reject(error);
    }
  }).then(() => window.google.maps),
);

Now, all you have to do is creating a globally accessible variable that holds the return value of loadGoogleMapsApi function:

window.googleMapsApiLazyValue = loadGoogleMapsApi(YOUR_API_KEY);

// On your code.
window.googleMapsApiLazyValue()
  .then((maps) => {
    // Your google maps code
    const myMap = new maps.Map(element, options);
    const myMarker = new maps.Marker({ map: myMap });
  });

Put it Together

Let’s put it all the pieces together.

// loadGoogleMapsApi.js
let googleMapsScriptIsInjected = false;

const injectGoogleMapsApiScript = (options = {}) => {
  if (googleMapsScriptIsInjected) {
    throw new Error('Google Maps Api is already loaded.');
  }

  const optionsQuery = Object.keys(options)
    .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(options[k])}`)
    .join('&');

  const url = `https://maps.googleapis.com/maps/api/js?${optionsQuery}`;

  const script = document.createElement('script');

  script.setAttribute('src', url);
  script.setAttribute('async', '');
  script.setAttribute('defer', '');

  document.head.appendChild(script);

  googleMapsScriptIsInjected = true;
};

const lazyValue = (fn) => {
  let called = false;
  let returnValue;

  return () => {
    if (!called) {
      called = true;
      returnValue = fn();
    }

    return returnValue;
  };
};

const loadGoogleMapsApi = (apiKey, options = {}) => lazyValue(
  () => new Promise((resolve, reject) => {
    try {
      window.onGoogleMapsApiLoaded = resolve;

      injectGoogleMapsApiScript({
        key: apiKey,
        callback: 'onGoogleMapsApiLoaded',
        ...options,
      });
    } catch (error) {
      reject(error);
    }
  }).then(() => window.google.maps),
);

export default loadGoogleMapsApi;

Here’s how I use it on my Vue application, where I assign the loadGoogleMapsApi return value on the Vue’s prototype.

import Vue from 'vue';
import loadGoogleMapsApi from './loadGoogleMapsApi';

Vue.prototype.$loadGoogleMapsApi = loadGoogleMapsApi(
  process.env.VUE_APP_GOOGLE_MAPS_API_KEY,
);

This way I can easily access the Google Maps API instance from any components:

<template>
  <div ref="map" />
</template>

<script>
export default {
  data() {
    return {
      map: null,
    };
  },
  async mounted() {
    const maps = await this.$loadGoogleMapsApi();

    this.map = new maps.Map(this.$refs.map, {
      center: { lat: 40, lng: -100 },
      zoom: 4,
    });
  },
};
</script>