今回の新規開発プロジェクトで、 Apple Maps を採用した。

このプロジェクトでは、立ち上げ初期から地図を使った UI をサービスコアバリューの 1 つとしてユーザーに提供することを計画していた。

地図の使用を想定している箇所は 2 つ。 1 つは、住所に対する補足情報としての地図表示。これは定番の UI で、ほとんどが Google Maps Embed を利用して作成されている。Google Maps Embed であれば費用は無料、API Key さえ取得してしまえば、あとは緯度経度を指定して呼び出すだけだ。

もう 1 つは Marker 表示。地図上に Marker を表示したい。地図は自由に移動でき、 Marker は条件に応じて自在に追加、削除できる必要性がある。

これも、地図を使用するアプリケーションで見かける UI の 1 つと言える。私も一度だけ開発の経験があった。この時は、デザイナーが作成した地図上に Marker を表示する UI だったため、 OpenLayers を使用した。

今回は、最新の地図上に Marker を配置することが求められる。要件を満たす地図サービスをリストアップし、デザインや価格、開発のしやすさなどを基準に 1 社を選定することにした。

採用の検討したサービスは以下の通り。

  • Google Maps
  • Apple Maps
  • Mapbox
  • Bing Maps
  • MapFan
  • Zenrin いつもNAVI

最も高価だったのが Google Maps だった。ライブラリやサンプルコードの充実、そしてストリートビューや口コミ等の情報へのアクセスのしやすさなど、価格に見合う機能の充実は、他サービスから一線を画している。

最も安価だったのが Apple Maps である。 1 日 25 万回の地図表示と 2 万 5 千回のサービスコールができ、年会費 99 ドル固定。冗談のような安さである。Apple は地図で商売をする気がないようだ。この価格で iOS 標準搭載の地図が自由に使えるのはお得というほかない。デザインもよく、機能面も問題がなさそうなので、とりあえず Apple Maps で開発を進めて、問題が生じるようであれば別のサービスを検討することになった。

コンポーネントの実装に Map ベンダー依存のコードが混入しないよう、境界部はすべて Interface で呼び出すよう設計、 DI する Object を差し替えるだけで、別の Map ベンダーのサービスに切り替えられるよう開発した。

さて、問題は Marker だ。

この Marker は地図上の任意の座標に配置し、アイコンやボタン、カウンターなどが含まれる Organisms レベルのコンポーネントである。今後の開発でさらに複雑なデザインになる可能性があった。

Vue3 で開発している以上、当然このコンポーネントも SFC で作成、Storybook で管理したい。ところが Vue3 は Vue2 で可能だった、SFC の動的なインスタンス化と DOM の生成、生成された DOM へのアクセスのための仕組みが削除されているのだ。

Marker は地図上に任意の数表示できる必要がある。コンポーネントの Factory Method を Map ベンダーが提供するライブラリの Callback に渡すことで、 自由に DOM 生成を行うことができなければならない。

例えば、Vue2 では SFC から動的に DOM を生成、アクセスすることができた。

/components/organisms/map/GuideViewer.vue
<script lang="ts">
import Vue from 'vue'
import Marker from '@/components/organisms/map/Marker.vue'

export default Vue.extend({
  components: {
    Marker
  },
  // 省略
  methods: {
    // 省略
    createMarker() {
      const marker = new Marker();// Vue2 の SFC をインスタンス化
      marker.$mount(); // インスタンス化した SFC から DOM を生成
      console.log(marker.$el) // 生成した DOM のルートポインターを確認
    }
  }
})
</script>

しかし Vue3 では new Vue() が削除されている。$mount には破壊的変更が入り、振る舞いが変わったことが公式移行ガイドに示されていた。

Vue3 公式移行ガイド
https://v3-migration.vuejs.org/ja/breaking-changes/mount-changes

しかし、このドキュメントの説明はおかしい。Vue2 の $mount は、個別のコンポーネントの DOM 生成と紐付けを手動で行うことができる関数であり、Vue3 の mount はルートコンポーネントをどの DOM と紐付けするかを設定する関数である。用途も作用も異なる 2 つのメソッドが、なぜ同一線上で語られているのだろうか。そして、なぜこの API は破壊的変更をする必要があったのだろうか。これが分からないことには、 移行中のシステムのコード修正がフレームワークの設計思想に沿ったものなのか判断がつかない。

Vue2 公式ドキュメント vm.$mount (RootComponent ではなく MyComponent としており、4つ目の Example に関して移行ガイドの言及がない) https://v2.vuejs.org/v2/api/#vm-mount

Vue2 では、SFC で作成したコンポーネントの DOM を Script で生成できたのに対し、Vue3 ではできなくなったことへの言及があるべきだろう。 Vue3 の mount はすでに生成済みの DOM の innerHTML を、ルートコンポーネントと差し替えるだけである。差し替える DOM が存在しない場合、DOM が生成されることはない。

結局、 Vue3 では必要な API が存在しないため、 SFC で実装したコンポーネントの Factory Method は作成できないという結論になった。 document.createElement メソッドを使用するしかない。

仕方がないので ts ファイルでコンポーネントを定義する。

/components/organisms/map/Marker.ts
export type Props = {
  count: number
  onClickButton: (e: Event) => void
}

export default ({
  count,
  onClickButton
}: Props) => {
  const marker = createMarker()
  const counter = createCounter(count)
  const button = createButton()
  button.onclick = onClickButton
  marker.appendChild(counter)
  marker.appendChild(button)
  return marker
}

const createMarker = () => document.createElement("div")

const createCounter = (count :number) => {
  const counter= document.createElement("p")
  counter.textContent = String(count)
  return counter
}

const createButton = () => document.createElement("button")

さて、ようやくここで表題の話になる。このコンポーネント、一体どのように Storybook に追加すればよいのだろうか?

Storybook は Vue や React にとどまらず、Svelte や Web Components など、幅広いフレームワークに対応している。調べてみたところ、今回 ts ファイルで作成したコンポーネントの場合 “HTML” で対応するようだった。

試しに空のプロジェクトを作成してみる。

mkdir storybook
cd storybook
npm init
npx storybook@latest init --type html

npm init コマンドで初期プロジェクトの作成をしないと、storybook の init コマンドに渡した引数は無視され、 “HTML” でのプロジェクトインストールに失敗した。

サンプルで作成されているコンポーネントを見る限り “HTML” で間違いなさそうだ。

開発中のプロジェクトに移動して init コマンドを実行してみる。

npx storybook@latest init --type html

このコマンドは正常に実行されインストールは完了したが、プロジェクトは破壊されてしまった。残念ながら、現状 Storybook は複数のフレームワーク同時対応ができないようである。

https://github.com/storybookjs/storybook/discussions/24693

もちろん同一プロジェクト内で複数の Storybook の使用もしたくない。 Vue3 向けの API の中で解決するより他に方法はなさそうだ。

試行錯誤の結果、以下のコードで ts ファイルによるコンポーネントの Story 追加に成功した。

import type { Meta, StoryObj } from "@storybook/vue3"
import createMarker, { type Props as MarkerProps } from "@/components/organisms/map/Marker"

const meta: Meta<MarkerProps> = {
  title: "organisms/Marker",
}

export default meta
type Story = StoryObj<MarkerProps>;

export const Default: Story = {
  args: {
    counter: 1,
    onClickButton: () => {}
  },
  render: (args) => ({
    setup: () => {
      setTimeout(() => {
        const marker = createMarker(args)
        document.getElementById("marker")!.appendChild(marker)
        return args
      })
    },
    template: "<div id='marker' />"
  })
}

Vue3 の SFC と、 createElement メソッドを使用した ts ファイル、いずれのコンポーネントも同じ Storybook 上で取り扱い可能だった。

プロジェクトで使われているコンポーネントの一部が Storybook で管理されていないという事態は回避された。

comments