React と地図ライブラリの共存について


React Advent Calendar 2016 の 2 日目の記事です。

お題がライブラリの共存についてなので、少し抽象的な話が多いかもしれません。
従って、React をある程度理解されている方を対象とします。

React と地図ライブラリ (Leaflet.js) をうまく組み合わせてフレームワークっぽいものをオープンソースで実験的に作っています。
そこで得た課題とそれに対してどう取り組んでいるかを記してみます。

少しでも開発するサービスやアプリで地図を扱うことがあれば、こんなこと考えてるやつもいるのかーとご笑覧いただければ幸いです。

Leaflet.js とは?

まず、Leaflet.js を知らないという方もいるかと思いますので、簡単に説明します。

Leaflet.js とは Web 上に地図を作成するためのオープンソース ライブラリです。GitHubFacebookPinterest などの有名な Web サービスで使われています。

シンプルに地図を表示するためのライブラリなので、例えば Google Maps API のように、背景レイヤーや検索サービスなどの API はありませんが、独自の Web サービスと組み合わせる際の柔軟さと軽量であることが強みです。
また、足りない機能はプラグインで補います。プラグインはいろいろなコントリビューターが開発・公開しています。jQuery に似たエコシステムですね。

Web 地図はコンポーネントとして扱うべきか?

さて、ここからが本題です。
見出しで言っている Web 地図とは Leaflet.js を指しています。

Leaflet.js に限った話ではないんですが、React のようなコンポーネント志向で仮想 DOM を扱うようなライブラリの場合に、Web 地図の扱いってどうあるべきなんだろう?
というのは、ずっと抱えていた疑問でした。

このような疑問を抱く背景は次のパートに書きます。

たとえば、React で地図を扱おうとした場合に、店舗紹介ページに地図を載せて、店舗位置にマーカーを落とすという利用目的であれば、props に緯度経度を入力するようなコンポーネントは普通にありえます。
地図の用途と管理すべき地図の状態がシンプルであれば、それに応じてコンポーネントとしてまとめてしまえばよいでしょう。

<StoreMap location={[51.505, -0.09]} zoom={16} storeName={'店舗A'} />

jsfiddle でサンプルを書いてみたのでご覧ください。
https://jsfiddle.net/ynunokawa/ewL1s9jn/1/

Web 地図で仮想 DOM は使えない

上記のように、地図表示を目的に応じてコンポーネント化して、地図のレンダリング部分をうまく隠して扱いやすくすることに一定のメリットはあると思います。

ただし、ソースコードを見ていただければわかると思いますが、実体は状態の初期化も更新も React のライフサイクルから適切なタイミングで props を抜き出して、Leaflet.js で処理をしています。

実際には Web 地図は多様なインタラクションに対応するように、地図の操作に応じて、さまざまなイベントが発火します。状態もめまぐるしく変わりますし、地図のレンダリングはライブラリが実 DOM を直接操作します。
また、Web 地図自体がレイヤーを重ねてデータ可視化をするため、Leaflet.js 側で地図を組み立てるためのデータ構造を管理しており、React と結びつける場合には、2 つの異なる世界を扱うような感覚があります。

たとえば、React で Leaflet.js を扱うための代表的なライブラリに react-leaflet があります。

<Map center={position} zoom={13}>
  <TileLayer
    url='http://{s}.tile.osm.org/{z}/{x}/{y}.png'
    attribution='&copy; <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
  />
  <Marker position={position}>
    <Popup>
      <span>A pretty CSS3 popup.<br/>Easily customizable.</span>
    </Popup>
  </Marker>
</Map>

これも内部的にやっていることは、最初に紹介したサンプルとさほど変わりません。
ただ、ライブラリとして汎用的に扱えるように、やや原理主義的ですが Leaflet.js のクラス群を React コンポーネント化しています。

これらの各コンポーネントは、Leaflet.js では JavaScript で記述していた構成を JSX で記述できるようにしています。Leaflet.js のクラスとの対応関係は以下の通り。

Leaflet.js react-leaflet 機能
L.Map <Map> 地図ビュー
L.TileLayer <TileLayer> 背景レイヤー
L.Marker <Marker> ポイントマーカー
L.Popup <Popup> テキスト表示用のポップアップ

もうお分かりだと思いますが、これらは render しても、React が直接のレンダリング処理をするわけでなく、実体としてはそれにしたがって、Leaflet.js がビューの変更処理をします。
宣言的で見通しが良いし、ケースバイケースでこれで OK という人もたくさんいると思います。仮想 DOM は使わないにせよ、React の文脈で自然に Leaflet.js を扱えるメリットはあるでしょう。

React で Web 地図を扱うためのデザインパターン

一方、Leaflet.js において地図のコアとなる L.Map をどこで扱うか?

最初に紹介したサンプルでは componentDidMount で地図ビュー用の <div> がレンダリングされた後に L.Map を初期化をして、this.map に置いておくことで、横断的に扱えるようにしています。

これが管理している地図の状態を複数の React コンポーネントに流したいときに、どういうデータの流れになるのか?地図とコンポーネントのやりとりは、どうしても地図の状態反映や地図とのインタラクションが一般的です。

そこで、上位のコンポーネントで、地図の状態で使いたいものだけを取り出すためのインターフェイスを提供して、それを下位の React コンポーネントに流し込んでいくような構造を考えてみました。

概念としては、@icoxfog417 さんの React.js 実戦投入への道 で紹介されている Mediator/Observerの導入 を参考にしました。

※画像はReact.js 実戦投入への道 より引用

React WebMap Framework

これが冒頭に言った試作フレームワークです。
Mediator/Observer のパターンを参考に、地図の扱いは Leaflet.js に任せて、そこから流したいデータは React に流していくといった付き合い方を模索してみました。

まだ、最適な答えは出てませんが、現状のフレームワークの仕様について簡単に紹介します。

地図の構成を JSON 化

確かに JavaScript で作り上げていく地図のレイヤー構成は見通しが悪い。

そこで、地図の構成は JSON で外部化。
これはウチのサービスなんですが、マップ ID の参照だけで地図のイニシャライズが可能です。
この Leaflet.js で地図化するライブラリも別プロジェクトで作っています。

ArcGIS for Developers という開発者向けサービスのアカウントがあれば無償でいくつでも地図が作れます。
地図作成はこちらですぐに始められます。

これを使えば、ある程度 JavaScript でレイヤーを構成するコードは省けるので、地図とそれ以外のコンポーネントとのやりとりに集中できます。

フレームワークの構成

このフレームワークは MediatorMapViewReactors の 3 つの構成要素で成り立ちます。

MapView

地図のビューであり、実体はただの <div> です。ID 参照によって取得した地図の JSON をもとに地図コンポーネントを構成します。L.Map による初期化は Mediator に移譲しています。

Mediator

Mediator/Observer 的な役割を担う上位コンポーネントです。
MapView の状態やイベントを制御します (L.Map に触れるインターフェイスを提供) 。ここで Leaflet.js を扱い、地図の各要素の状態遷移を Reactors に流していきます。
また、Reactors のイベントを Mediator に渡して、Leaflet.js の世界あるいは別の Reactors に反映するという方向性も持ちます。
いずれにせよ、必ず Mediator を経由してビューに反映します。

Reactors

純粋な React コンポーネントです。Leaflet.js の世界で起きたことを props を経由して、ビューに反映します。

実例: <HomeButton /> の場合

<HomeButton /> はクリックすると初期表示時の地図範囲に戻してくれるボタンです。
簡単にワークフローを書きだすとこんな感じです。

  1. HomeButton.propsMediator から渡された地図の初期表示状態 (ズームと中心座標) が代入され render
  2. ボタンクリック時に HomeButton.props.onGetHome(this.props.center, this.props.zoom) を実行
  3. Mediator.setView() に 2 で実行された関数の引数を引き渡す
  4. Mediator.setView()L.Map.setView(center, zoom) を実行
  5. 地図が初期表示時の位置に戻る

L.map.setView() は Leaflet.js の地図表示位置を設定するためのメソッドです。

以下、コードです。

src/lib/reactors/HomeButton/HomeButton.js
import React from 'react';
import { Button, Glyphicon } from 'react-bootstrap';

class HomeButton extends React.Component {
  constructor (props) {
      super(props);
      this._onGetHome = this._onGetHome.bind(this);
  }

  _onGetHome () {
    this.props.onGetHome(this.props.center, this.props.zoom);
  }

  render () {
    return (
      <div>
        <Button onClick={this._onGetHome}>
          <Glyphicon glyph="home" />
        </Button>
      </div>
    );
  }
}

HomeButton.propTypes = {
  center: React.PropTypes.array,
  zoom: React.PropTypes.number,
  onGetHome: React.PropTypes.func
};

HomeButton.defaultProps = {
  center: [35, 139],
  zoom : 5,
  onGetHome: function () {}
};

HomeButton.displayName = 'HomeButton';

export default HomeButton;
src/example/App.js
<HomeButton
 center={this.state.initialCenter}
 zoom={this.state.initialZoom}
 onGetHome={this.setView}
/>
src/lib/Mediator.js
setView (center, zoom) {
  const map = this.state.map;
  map.setView(center, zoom);
}

実戦投入してみた

ハッカソンで作ったモバイル Web アプリに、この試作フレームワークを採用してみました。
よかったら触ってみてください。

蛇足ですが、ページ遷移が前提だったので react-router とか使うところなんだと思いますが、今回は JavaScript によるスタイル操作でごり押ししちゃいました。

課題

試行錯誤しながら作り上げているデザインパターンなので、まだまだ不備もあると思います。また、冒頭で説明したように、Leaflet.js のロジックを React のライフサイクルにうまく繋ぎあわせて React コンポーネント化してしまうこともアプローチとしては間違っていないと思います。

現在は Mediator/Observer のデザインパターンを参考にしていますが、Flux 実装にヒントがあるかもしれません。代表的な Redux 然り、まだ学習が進んでいないので、そちらも俯瞰しながら React WebMap Framework をよりよいアーキテクチャにしていければと思っています。

Flux に詳しい方はアドバイスいただけると大変喜びます。
興味ある方は気軽に Fork、Issue、プルリクよろしくお願いします。

Leaflet.js の自由度を活かしたまま React とうまく共存していく方法を今後も探っていきます。