💡

【2023年】Nuxt3+firebase hostingでgoogle maps apiを使う

2022/04/10に公開

google maps apiのライブラリ

vue3やnuxt3でgoogle maps apiを使うにおいて、@fawmi/vue-google-mapsなどvue3用のライブラリはいくつかありますが、どれもバグが多く2022年4月現在まともに使えるものがありませんでした。

結局一番シンプルなライブラリであるgooglemaps/js-api-loaderを使うのが最も手っ取り早いのですが、firebase hostingにデプロイしたら動かなくなるトラブルで数時間苦しんだので、メモしときます。

更新

  • 2023年11月 書き方を更新
  • 2023/3/18 nuxt3の正式リリース版に合わせて、pluginsの記述方法を変更しました。

@googlemaps/js-api-loaderを用いたローカル開発

インストール

$ npm i @googlemaps/js-api-loader
$ npm i -D @types/google.maps

ページ作成

ドキュメント通りですが、vue3に合わせて少し書き方を変えています。

HTMLエレメントにgoogleマップを描画するので、loaderの実行はDOM生成後つまりonMountedで実行しましょう。(DOM生成前に実行しようとすると、window is not finedエラーとなります)

pages/map.vue
<script setup lang="ts">
import { Loader } from '@googlemaps/js-api-loader';

const gmap = ref<HTMLElement>()

const loader = new Loader({
    apiKey: "******************",
    version: "weekly",
    libraries: ["places"]
});

const mapOptions = {
  center: {
    lat: 34.60,
    lng: 135.52
  },
  zoom: 15
};

onMounted(()=>{
    loader
    .load()
    .then((google) => {
        new google.maps.Map(gmap.value, mapOptions);
    })
    .catch(e => {
        // do something
    });
})
</script>

<template>
    <h1>マップ</h1>
    <div ref="gmap" class="h-[500px] w-[800px]"></div>
</template>

firebaseエミュレーターでの失敗と対処

エミュレーター実行

NITRO_PRESET=firebase yarn buildを実行し、firebase emulators:startでlocalhost:5001/mapを開いてみてください。

おそらくページ表示は失敗し、以下のようなエラーメッセージが表示されているでしょう。

>  Named export 'Loader' not found. The requested module '@googlemaps/js-api-loader' is a CommonJS module, which may not support all module.exports as named exports.
>  CommonJS modules can always be imported via the default export, for example using:
>  
>  import pkg from '@googlemaps/js-api-loader';
>  const { Loader } = pkg;

対処①

エラーメッセージの通り、以下のように修正します

- import { Loader } from '@googlemaps/js-api-loader';
+ import * as pkg from '@googlemaps/js-api-loader';
+ const {Loader} = pkg

//以下略

再度、NITRO_PRESET=firebase yarn buildを実行し、firebase emulators:startでlocalhost:5001/mapを開いてみます。

今度は、「500」と「Loader is not a constructor」という表示がされるはずです。

対処②

Loaderはコンストラクタなのに変だぞ、、、とめちゃくちゃ検証しました。

結論から書きます。
Loaderのインスタンス化はonMountedで実行する必要があります。

<script setup lang="ts">
import * as pkg from '@googlemaps/js-api-loader';
const {Loader} = pkg

const gmap = ref<HTMLElement>()

// setupでインスタンス化しようとすると失敗する
// const loader = new Loader({
//     apiKey: "*******************",
//     version: "weekly",
//     libraries: ["places"]
// });

const mapOptions = {
  center: {
    lat: 34.60,
    lng: 135.52
  },
  zoom: 15
};

onMounted(async()=>{
    // インスタンス化はonMountedで実行する
    const loader = new Loader({
        apiKey: "********************",
        version: "weekly",
        libraries: ["places"]
    });

    const {Map} =  await loader.importLibrary("maps")
    new Map(
        gmap.value,
        {
            center:new google.maps.LatLng(35,135),
            zoom:12
        }
    )
})
</script>

<template>
  <h1>マップ</h1>
  <div ref="gmap" class="h-[500px] w-[800px]"></div>
</template>

実装

loaderをpluginsに移行

上記の書き方ではapiキーをページ内にベタ書きしているのでセキュリティ上良くありませんし、見た目も乱雑です。

apiキーを用いた認証はページ表示時に一度実行すれば良いので、loaderはpluginsで実行するようにしましょう。

注意点として、google maps apiはクライアントサイドのみで実行する必要があるので、pluginsのファイル名には「client」をつけます。

plugins/google-map.client.ts
import * as pkg from '@googlemaps/js-api-loader';
const { Loader } = pkg;

export default defineNuxtPlugin((nuxtApp) => {
    return {
        provide:{
            loader: new l.Loader({
                    apiKey: "****************",
                    version: "weekly",
                    libraries: ["places"]
                })
        }
    }
  });

※ Nuxt3の正式リリース版では、plugins内ファイルにimport { defineNuxtPlugin } from "#app";の記述は必要なくなりました

runtimeConfigを使って、apiKeyを隠す

runtimeConfigの使い方は今回の趣旨ではありませんので詳細は割愛しますが、以下のような感じで書いてください

.env
NUXT_PUBLIC_GOOGLE_MAPS_API_KEY="**************"
nuxt.config.ts
export default defineNuxtConfig({
  ・・・,
  runtimeConfig:{
    public:{
      googleMapsApiKey:process.env.NUXT_PUBLIC_GOOGLE_MAPS_API_KEY,
      ・・・
    }
  }
  ・・・,
})
plugins/google-map.client.ts
import * as pkg from '@googlemaps/js-api-loader';
const { Loader } = pkg;

export default defineNuxtPlugin((nuxtApp) => {
    return {
        provide:{
            loader: new l.Loader({
                    apiKey: config.public.googleMapsApiKey,
                    version: "weekly",
                    libraries: ["places"]
                })
        }
    }
  });

ちなみにnuxt.config.tsのgoogleMapsApiKeyを書かなくても、開発環境上は地図が使えますが、エミュレーター環境(本番環境)では使えなくなるので注意。

コンポーネントでの使い方

js-api-loaderをplugins内に書いたことにより、コンポーネントは非常にシンプルに書き直せます。

pages/map.vue
<script setup lang="ts">
const {$loader} = useNuxtApp()

const map = ref<HTMLElement>() 

onMounted(async()=>{
    const {Map} = await $loader.importLibrary("maps")
    map.value = new Map(
        document.getElementById('map') as HTMLDivElement,
        {
            center:new google.maps.LatLng(35,135),
            zoom:12
        }
    )
    
})

</script>


<template>
  <div>
    <div class=" font-bold">テスト</div>
    <div ref="gmap" class="h-[500px] w-[800px]"></div>
  </div>
</template>

マーカーと情報ウィンドウを表示する

マーカーをクリックしたら情報ウィンドウを表示するようにします。

ポイントはinfoWindowインスタンスの作成をマーカーインスタンスと別に行うことです。
これにより、infoWindowインスタンスはただ一つとなりますので、あるマーカーをクリックした後で別のマーカーをクリックすると、infoWindowは切り替わったように見えます。

onMounted(()=>{
   ・・・省略
            const map = new google.maps.Map(mapElem.value,mapOptions) 
            if(!markers.length)return
            const infoWindow = new google.maps.InfoWindow() //ポイント
            markers.forEach(d=>{
                const marker = new google.maps.Marker({
                    position: d.position,
                    map:map,
                    title:d.title,
                    label:d.label,
                });

                marker.addListener("click", (e) => {
                    infoWindow.close();  
                    infoWindow.setContent(`
                        ${d.title.slice(0,20)}
                        ${d.title.slice(62,80)}
                    `);
                    infoWindow.open(marker.getMap(), marker);
                  
                    emits('clicked',marker.getTitle())                    
                });
            })
      })
})

下のようにマーカーごとにinfoWidowインスタンスを作成すると、別のマーカーをクリックしても前に開いていたinfoWindowは自動で閉じません(これはこれで使い道ありそうですが)

onMounted(()=>{
・・・省略
            const map = new google.maps.Map(mapElem.value,mapOptions) 
            if(!markers.length)return
            
            markers.forEach(d=>{
                const marker = new google.maps.Marker({
                    position: d.position,
                    map:map,
                    title:d.title,
                    label:d.label,
                });
		const infoWindow = new google.maps.InfoWindow() //ここだと一つのマーカーに一つのウィンドウが紐づく
                marker.addListener("click", (e) => {
                    infoWindow.close();  
                    infoWindow.setContent(`
                        ${d.title.slice(0,20)}
                        ${d.title.slice(62,80)}
                    `);
                    infoWindow.open(marker.getMap(), marker);
                  
                    emits('clicked',marker.getTitle())                    
                });
            })
      })
})

Discussion