今回の新規開発プロジェクトで、 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 を生成、アクセスすることができた。
<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 ファイルでコンポーネントを定義する。
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