ReactでGoogle Maps JavaScript APIを組み込む

React+TypeScriptでGoogle Mapsを組み込んだページを作る際、これまではreact-google-mapsのようなライブラリを組み込んで実装してきました。 久しぶりにGoogle Mapsを使ったReactアプリケーションを作るにあたって現在の状況を調べてみたところ、公式のドキュメントにReact アプリケーションに Map と Marker を追加するというページがあり、@googlemaps/react-wrapperというものを使って組み込む方法が紹介されていました。

これまでサードパーティーのラッパーライブラリを使ってきた際、Google Mapの細かい制御をしようとした際に結局Google Mapsインスタンスを直接操作することになりがちだったこともあり、より公式に近い方法があるのであればそちらに乗り換えたほうがいいと考え、このガイドを参考に実装してみました。

ライブラリのインストール

必要なライブラリとして、@googlemaps/react-wrapperのほかに、Google Mapsの型定義@types/google.mapsを入れておく必要があります。

$ npm install @googlemaps/react-wrapper
$ npm install --save-dev @types/google.maps

実装

@googlemaps/react-wrapperはWrapperというReactコンポーネントを提供します。 このコンポーネントのchildrenはGoogle Maps JavaScript APIがロードされた状態でのみレンダリングされるようになっています。

Wrapperだけだと生々しいのでGoogleMapWrapperという名前でimportしてみました。

import React from 'react';
import { styled } from '@mui/material';
import { Wrapper as GoogleMapWrapper } from '@googlemaps/react-wrapper';

type Props = {
  apiKey: string;
};

const Frame = styled('div')`
  width: 100%;
  height: 100%;
`;

const IndexPage: React.FC<Props> = ({ apiKey }) => (
  <Frame>
    <GoogleMapWrapper apiKey={apiKey}>
      <MapWidget />
    </GoogleMapWrapper>
  </Frame>
);

export default IndexPage;

次に、マップの表示ですが、レンダリング先のdivをrefで参照し、divのElementを指定してMapのインスタンスを作成して描画します。 作成したインスタンスはあとの処理のためにstateに保持しておきます。イベントはこのmapのインスタンスにイベントリスナーを登録することで登録できます。

import { useEffect, useRef, useState } from 'react';
import { styled } from '@mui/material';

type Props = {};

const Frame = styled('div')`
  width: 100%;
  height: 100%;
`;

const DefaultProjection = {
  center: { lat: 35.6753778, lng: 139.7727147 },
  zoom: 12,
};

const MapWidget = () => {
  const ref = useRef<HTMLDivElement>(null);
  const [map, setMap] = useState<google.maps.Map>();

  // マウント時の処理(マップの作成)
  useEffect(() => {
    if (!ref.current || map) return;
    
    // 新しいマップのインスタンスを作成
    const newMap = new google.maps.Map(ref.current, {
      center: DefaultProjection.center,
      zoom: DefaultProjection.zoom,
    });
    
    // あとの処理で利用できるよう、stateにmapのインスタンスを保持
    setMap(newMap);
  }, []);

  // Mapの初期化(イベントハンドラ結合等)
  // 状況によってはmap再生成するコードを書くかもしれないのでuseEffectを分割した
  useEffect(() => {
    if (!map) return;
    
    // 例としてドラッグ後に座標をconsole.logに書く
    map.addListener('idle', () => {
      console.log(map.getCenter());
    });
  }, [map]);

  // 描画先のdiv (heightをstyleで当てておく必要あり)
  return <Frame ref={ref} />;
}

export default  MapWidget;

カスタムコントロールにReactコンポーネントを描画する

カスタムコントロールを使うと、マップの中に任意UIとしてエレメントを追加することができます。

サンプルでは document.createElement を使って気合いで生成していますが、Reactアプリケーションの中なのでReactコンポーネントを描画したいはずです。

これをするためには、document.createElementで作った親となるdivに対してReactDOM.createRootを使い、任意のコンポーネントを描画させます。

// 指定されたReactコンポーネントが描画されたdivエレメントを返す
const mountedDiv = (elem: React.ReactNode): HTMLDivElement => {
  const div = document.createElement('div');
  ReactDOM.createRoot(div).render(elem);
  return div;
};

const MapWidget = ({ onRequestCurrentLocation }) => {
  // ...

  // Mapの初期化(イベントハンドラ結合等)
  // 状況によってはmap再生成するコードを書くかもしれないのでuseEffectを分割した
  useEffect(() => {
    if (!map) return;
    
    // ...
    
    // カスタムコントロールを追加
    newMap.controls[window.google.maps.ControlPosition.RIGHT_TOP].push(
      mountedDiv(<CurrentLocationButton onClick={onRequestCurrentLocation} />),
    );

  }, [map]);
};  

AdvancedMarkerElementでマーカーを描画する

現在、これまでのマーカー(google.maps.Marker)は2024 年2月21日でサポートが終了し、高度なマーカーへ移行せよとされています。

マーカーのクラスはgoogle.maps.marker.AdvancedMarkerElementですが、これを使うためには事前にマーカーライブラリの追加読み込みが必要となります。Wrapperの引数で追加読み込みするライブラリを指定できます。

また、Map IDが必要となりますので、事前にGoogle Cloud Consoleのマップ管理画面で作っておきます。(とりあえず試したいだけならDEMO_MAP_IDを指定しても良いらしいです)

const IndexPage: React.FC<Props> = ({ apiKey, mapId }) => (
  <Frame>
    <GoogleMapWrapper apiKey={apiKey} libraries={['marker']}>
      <MapWidget mapId={mapId} />
    </GoogleMapWrapper>
  </Frame>
);
// 指定されたReactコンポーネントが描画されたdivエレメントを返す
const mountedDiv = (elem: React.ReactNode): HTMLDivElement => {
  const div = document.createElement('div');
  ReactDOM.createRoot(div).render(elem);
  return div;
};

const MapWidget = ({ mapId, onRequestCurrentLocation }) => {
  // ...
  
  // マウント時の処理(マップの作成)
  useEffect(() => {
    // ...
    
    // 新しいマップのインスタンスを作成
    const newMap = new google.maps.Map(ref.current, {
      mapId,  // <- これが必要になった
      center: DefaultProjection.center,
      zoom: DefaultProjection.zoom,
    });
    
    // ...
  }, []);

  // Mapの初期化(イベントハンドラ結合等)
  // 状況によってはmap再生成するコードを書くかもしれないのでuseEffectを分割した
  useEffect(() => {
    // ...

    // マーカーの作成
    const marker = new google.maps.marker.AdvancedMarkerElement({
      map,
      position: { lat: 35.6753778, lng: 139.7727147 },
      title: 'sample',
    });
  }, [map]);

  // ...
};  

実装してみて、マップ部分ではReactらしさは失われてしまうものの、SDKの追従などがしやすい環境になるメリットの方が大きいのではないかと思いました。