今回の新規開発プロジェクトで、 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 を生成
      marker.$el // 生成した DOM のルートポインター
    }
  }
})
</script>

しかし Vue3 では new Vue() も $mount も削除となっている。

Vue3 の公式移行ガイドには、Mount API は破壊的に変更され、振る舞いとメソッド名が変わったことが示されていた。

https://v3-migration.vuejs.org/ja/breaking-changes/mount-changes

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

Vue2 公式ドキュメント vm.$mount (RootComponent ではなく MyComponent としており、 生成したコンポーネントの DOM を appendChild で Root に追加している) https://v2.vuejs.org/v2/api/#vm-mount

Vue2 では、SFC で作成したコンポーネントの DOM を Script で生成できたのに対し、Vue3 ではできなくなったことへの説明があるべきだろう。 createApp はすでに生成済みの 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