dtaniguchi.com

blog - Storybook

Vue3 と Apple MapKit JS Ver.5 ① – Storybook に createElement で生成した Marker (Annotation) コンポーネントを追加したい

d.taniguchi

2024年7月8日 23:40

Post / コメント

  • AppleMaps
  • Storybook
  • Tips
  • Vue3

今回の新規開発プロジェクトで、 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 で管理されていないという事態は回避された。

yarn から npm に戻した話 ① – Storybook が起動しない編

d.taniguchi

2023年10月6日 6:19

Post / コメント

  • Node.js
  • npm
  • Storybook

引き続き、Vue3 による新規開発プロジェクトで仕事をしている。

プロジェクト基盤の整備が終わり、コンポーネント開発に着手した。 Composition API でボタンや入力コンポーネントを作成したり、Router に Page コンポーネントと Path の紐付けを追加したり、といった作業を進める。

Vue Router4 に Nested Routes があるのは嬉しかった。今回 Modal 表示を Path で制御したい箇所がある。Next.js の App Router では Layout 作成がしやすかった。この機能は積極的に使いたい。

いくつかコンポーネントができたので、 Storybook7 を導入して作成したコンポーネントを登録することにした。まずは Local 上で Project に Storybook を Install 、正常に起動した。続いて docker-compose.yml にコンテナを追加して起動しようとしたとき問題が発生した。起動しないのである。またかい。Vite が Docker で動作しないに引き続きである。

Dockerfile は以下のような内容だ。

Dockerfile
FROM node:18.18 AS development

ENV LANG C.UTF-8
ENV TZ Asia/Tokyo

RUN apt-get update

WORKDIR /app
COPY ["package.json", "yarn.lock", "./"]

RUN yarn

エラー表示は以下の通り。

storybook  | 🔴 Error: It looks like you are having a known issue with package hoisting.
storybook  | Please check the following issue for details and solutions: https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092
storybook  | 
storybook  | 
storybook  | /app/node_modules/cli-table3/src/utils.js:1
storybook  | const stringWidth = require('string-width');
storybook  |                     ^
storybook  | 
storybook  | Error [ERR_REQUIRE_ESM]: require() of ES Module /app/node_modules/string-width/index.js from /app/node_modules/cli-table3/src/utils.js not supported.
storybook  | Instead change the require of index.js in /app/node_modules/cli-table3/src/utils.js to a dynamic import() which is available in all CommonJS modules.
storybook  |     at Object.<anonymous> (/app/node_modules/cli-table3/src/utils.js:1:21)
storybook  |     at Object.<anonymous> (/app/node_modules/cli-table3/src/table.js:2:15)
storybook  |     at Object.<anonymous> (/app/node_modules/cli-table3/index.js:1:18)
storybook  |     at Object.<anonymous> (/app/node_modules/@storybook/core-server/dist/index.js:66:2799)
storybook  |     at Object.<anonymous> (/app/node_modules/@storybook/cli/dist/generate.js:11:4494)
storybook  |     at Object.<anonymous> (/app/node_modules/@storybook/cli/bin/index.js:26:1)
storybook  |     at Object.<anonymous> (/app/node_modules/storybook/index.js:3:1) {
storybook  |   code: 'ERR_REQUIRE_ESM'
storybook  | }

御丁寧なことにエラーに直接 Issue の URL が明記されている。世の中にそんなエラー表示があったのか。

https://github.com/storybookjs/storybook/issues/22431#issuecomment-1630086092

Issue を要約すると次のようになった。

  • yarn ではなく、 pnpm を使用したら動いた
  • yarn ver. 1 系のバグ
  • yarn ver.3 系にアップグレードすると直る
  • yarn.lock がある状態で yarn コマンドを実行すると依存性解決に失敗する
  • package.json だけの状態から yarn コマンドを実行すると依存性解決は成功する

試しに正常動作している Local の node_modules を削除して yarn コマンドを再実行すると、 問題が出現した。原因は Docker ではなく yarn だった。yarn install コマンドでは yarn.lock ファイルがある場合と、ない場合で異なる処理系を用いているのだろうか。悪い設計の典型だ。

依存性解決に yarn.lock ファイルを利用できないということは、コンテナイメージが作られる度に、 Install されるライブラリの Version が微妙に異なるコンテナができあがることを意味する。仮にE2E テストが失敗しても、テストに使ったコンテナが消えた途端、テストが失敗したライブラリの Version 情報が失われることになる。こんな方式は採用できない。

呆れながら npm i コマンドを実行し、node_modules を削除後 npm ci コマンドを実行したら、 Storybook は正常に立ち上がり、 Docker 上でも問題なく動いた。

Vue3 や Storybook の公式を見てみると npm をデフォルトとして表示している。 Storybook 公式での並び順は、今回の Issue を反映しているかのようだ。もう yarn を使用する理由はないようだ。

私のプロジェクトでも、すべて npm で作業を進めることにした。Dockerfile やドキュメント類のコマンドはすべて yarn から npm に戻した。