dtaniguchi.com

blog - Vue3

Vue3 と Apple MapKit JS Ver.5 ② – Singleton Instance の Mutex

d.taniguchi

2024年8月15日 23:35

Post / コメント

  • AppleMaps
  • ChatGPT
  • Tips
  • TypeScript
  • Vue3

前回の続きである。

前回は地図を使用する UI 開発で、 Apple Maps の地図上に表示する Marker コンポーネントを、 Vue3 とどのように組み合わせて実装し、 Storybook に追加したかを書いた。

今回は、地図 UI を取り扱ういくつかのコンポーネントが、最初に呼び出す Apple Maps の SDK Object を、フレームワークとどの様に組み合わせればいいのかについて話をする。

Apple Maps ではまず、 Web ブラウザで地図表示を行うために必要な部品一式が実装されている、 MapKit JS と呼ばれる SDK Object の読み込みを済ませておく必要があった。具体的には公式で公開されている以下のコードである。

// This promise resolves when the browser finishes downloading and evaluating MapKit JS.
const mapKitJsLoadedPromise = new Promise(resolve => {
    const element = document.createElement("script");
    element.addEventListener("load", resolve, { once : true });
    element.src = "https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
    element.crossOrigin = "anonymous";
    document.head.appendChild(element);
});

このコード、SDK Object が定義されている js ファイルを動的制御で読み込む場合に使用する。読み込み処理自体は非同期だが、完了するまで次の処理を進めることができないため、実質は直列処理になる。この方法は MapKit JS を Full bundle でしか読み込めないため、処理速度の観点から公式で本番環境非推奨となっている。

普通 js ファイル読み込みには、以下のような script タグを head タグ内に宣言する方法をとる。

<script src="https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.core.js"
    crossorigin async
    data-callback="initMapKit"
    data-libraries="services,full-map,geojson"></script>

この場合、js ファイルの読み込み順序がフレームワークの読み込みより先に実行されるよう制御できる。複数のコンポーネントがバラバラな順序で SDK Object へアクセスしても、 Instance が存在しない可能性を考慮する必要がないし、Minimum bundle のライブラリを指定できるため、読み込み時間を短縮できる。

しかし今回は、以下の理由から動的制御で直列処理による SDK Object の読み込みが最善だと判断していた。

  • 特定の Map サービスに依存しない UI 設計を実現しているため、 DI するオブジェクトの差し替えで Map サービスの切り替えが可能な、ベンダーロックインしない構成が望ましい
  • 可読性や可変性維持のため疎結合と凝集度の向上を実現するには、ユーザーが地図情報にアクセスしたタイミングで、関連するライブラリの読み込みが実行される実装が最善
  • MapKit JS は Full bundle でも十分高速に読み込み可能で、ライブラリ使用直前の直列処理でも UX は悪化しない
  • 地図サービスによって、どのタイミングで費用が発生するか分からないため、ユーザーが地図機能を使用しなかった場合、費用が発生する可能性そのものが生じない設計こそ、懐とリードエンジニアの心理的安全に優しい

これらを踏まえると、実装は以下のようになった。(記事の趣旨に合わせて一部省略)

/lib/map/index.d.ts
interface NavigationMap {
  launch(latLng: LatLng): Promise<void>
  // 他の地図制御メソッド 省略
  dispose(): void
}
  
interface MarkerIndicationMap {
  launch(latLng: LatLng): Promise<void>
  // 省略
  dispose(): void
}
/lib/map/apple-maps/index.ts
import AppleNavigationMap from "./navigation-map"
import AppleMarkerIndicationMap from "./marker-indication-map"

export const getNavigationMap: (element: HTMLElement) => NavigationMap = 
  (element: HTMLElement) => new AppleNavigationMap(element)

export const getMarkerIndicationMap: (element: HTMLElement) => MarkerIndicationMap =
  (element: HTMLElement) => new AppleMarkerIndicationMap(element)
/lib/map/apple-maps/apple-maps.ts
type Mapkit = any

export default abstract class AppleMaps {

  private static mapKit: Mapkit

  protected static async getMapKit(): Promise<Mapkit> {

    if (this.mapKit) {
      return this.mapKit
    }

    await new Promise(resolve => {
      const element = document.createElement("script")
      element.addEventListener("load", resolve, { once : true })
      element.src = "https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
      element.crossOrigin = "anonymous"
      document.head.appendChild(element)
    })

    this.mapKit = await new Promise((resolve, reject) => {
      const mapKit = (globalThis as any).mapkit

      mapKit.init({
        // libraries: ["services", "full-map", "annotations"],
        authorizationCallback: function(done: any) {
          done("-- TOKEN --")
        },
        language: "ja"
      })

      mapKit.addEventListener("configuration-change", (event: any) => {
        switch (event.status) {
          case "Initialized":
            resolve(mapKit)
            return
          // case "Refreshed":
          // default:
        }
        reject(event)
      })

      mapKit.addEventListener("error", (error: any) => reject(error))
    })

    return this.mapKit
  }
}
/lib/map/apple-maps/marker-indication-map.ts
import AppleMaps from "./apple-maps"

export default class AppleNavigationMap extends AppleMaps implements NavigationMap {

  private element: HTMLElement
  private map: any = undefined

  constructor(element: HTMLElement) {
    super()
    this.element = element
  }

  private async getMap() {
    if (this.map) {
      return this.map
    }
    const mapKit = await AppleMaps.getMapKit()
    this.map = new mapKit.Map(this.element, {
      // 省略
    })
    return this.map
  }

  async launch(latLng: LatLng) {
    const mapKit = await AppleMaps.getMapKit()
    const map = await this.getMap()
    map.region = new mapKit.CoordinateRegion(
      // 省略
    )
  }

  // 他の地図制御メソッド 省略

  dispose() {
    this.map?.destroy()
  }
}
/lib/map/apple-map/navigation-map.ts
// marker-indication-map.ts と同様

最初の Interface は、地図 UI を担当するコンポーネントから呼び出すことを想定している。コンポーネントの分だけ定義され、地図の制御に必要なメソッドを用意しておく。地図制御に係る Map ベンダー固有のコードを、コンポーネントの実装から分離できるため、DI の設定次第で異なる Map ベンダーに切り替えることが可能になる。

そして、地図ベンダーのサービスに依存するコードをまとめる Concrete class に続く。

Abstract class を定義、ここに SDK Object である MapKit の Getter を用意する。この Getter には MapKit の Instance 生成処理も含み、 最初のアクセスが来るまで Instance 生成を保留する。 Lazy initialization だ。一度生成された Instance はその後ずっと再利用される。

継承する class からしか使用しない関数なので、アクセス修飾子は Protected 、Singleton instance なので、static を指定する。

特段問題なさそうに見える。試しに動かしてみると、以下の Warning が出た。

[MapKit] Mapkit namespace already exists; did you import MapKit more than once?

嫌な予感はあった。問題の発生箇所はやはり Abstract class の getMapKit メソッドだった。SDK Object の読み込み処理が複数回実行されている。

Singleton にしたい SDK Object の Instance 生成に時間を要すため、生成完了より前に別のコンポーネントの表示制御が、別スレッドで生成処理をキックしてしまう。getMapKit メソッドを Mutex にしなければ Singleton を保証できないのだ。

しかし、どの様なコードを書けばいいのだろうか?

例えば、 Java であれば Lazy initialization には定番の書き方がある。 synchronized を使う方法がすぐに思い浮かぶ。

Java
abstract class AppleMaps {

  private static MapKit mapKit;

  static synchronized MapKit getMapKit() {
    if (mapKit != null) {
      return mapKit;
    }
    // MapKit 生成処理
  }
}

Kotlin に至っては Syntax が存在する。

Kotlin
abstract class AppleMaps {

  protected companion object {
    val mapKit by lazy {
      // MapKit 生成処理
    }
  }
}

Java や Kotlin であれば上記のコードで問題は解決する。

しかし JavaScript でスレッド制御や Lazy initialization に関する Syntax は聞いたことがない。Promise を使えば作れそうな気もするが、少し考えただけではコードを思いつくことができなかった。 Google 検索で実現方法を調べてみる。

検索で上位にでてきた記事は、 Third-party 製のライブラリ紹介だった。主に 2 つが紹介されている。

これらは、JavaScript で Semaphore や Mutex を使用する場合、よく使用される Package のようだ。試しにプロジェクトに追加してみると動作しない。いずれもソースコード内で process object を使用しており、 Vite を使用している Project で使用する場合、単純な Package 追加だけでは動作しないことが分かった。

実際に問題なく動作する Mutex の実装を掲載しているページを発見することができたのは、調査をはじめてしばらく経過してからだった。記事の内容を基にテストすると、必要な振る舞いをすることが確認できた。

Danny Kim / Simple TypeScript Mutex Implementation
https://dev.to/0916dhkim/simple-typescript-mutex-implementation-5544

ひとまず、設計の見直しは免れたようである。

動作したのはいいのだが、ソースコードを読むと Semaphore をシングルスレッドで動作させることで Mutex を実現しているように見える。実装がやや複雑で、何をしているのかすぐに理解することが難しい。記事の基になっている Package を追加して使う前に、必要なコードだけ抜き出して振る舞いを理解しようとした時、ふと ChatGPT の存在を思い出した。最近、変数の命名で迷った時、相談に来ていたのだ。ChatGPT に Mutex を質問したら、どの様な返答をしてくれるだろう。

ChatGPT が回答した TypeScript による Mutex の実装と使用例
class Mutex {
  private mutex = Promise.resolve();

  async lock<T>(fn: () => Promise<T>): Promise<T> {
    let release: () => void;
    const releasePromise = new Promise<void>(resolve => release = resolve);

    // finally の代わりに then と catch を使ってロックを解放
    const result = this.mutex.then(() => fn())
      .then(
        (value) => {
          release(); // 正常終了時にリリース
          return value;
        },
        (error) => {
          release(); // エラー時にも必ずリリース
          throw error;
        }
      );

    this.mutex = releasePromise;

    return result;
  }
}

const mutex = new Mutex();

async function criticalSection(id: number) {
  console.log(`Task ${id} is waiting to enter the critical section.`);
  await mutex.lock(async () => {
    console.log(`Task ${id} has entered the critical section.`);
    // クリティカルセクションに入った後、非同期の処理をシミュレート
    await new Promise(resolve => setTimeout(resolve, 1000)); 
    console.log(`Task ${id} is leaving the critical section.`);
  });
}

async function runTasks() {
  await Promise.all([
    criticalSection(1),
    criticalSection(2),
    criticalSection(3)
  ]);
}

runTasks();

1 箇所 ES2018 以降でないと使用できない Syntax が含まれていたが、一発で正解を出してきた。これこそ、欲しかった答えだった。

ChatGPT のコードは Promise を 2 つ、巧みに組み合わせることでスレッド制御を行い、 Mutex を実現していた。シンプルで可読性の高いコードである。これをプロジェクトに採用することにした。修正した getMapKit メソッドは以下のようになった。

/lib/map/apple-maps/apple-maps.ts
type Mapkit = any

export default abstract class AppleMaps {

  private static mapKit: Mapkit
  private static mutex = new Mutex()

  protected static async getMapKit(): Promise<Mapkit> {
 
    await this.mutex.lock(async () => {

      if (this.mapKit) {
        return
      }
      
      await new Promise(resolve => {
        const element = document.createElement("script")
        element.addEventListener("load", resolve, { once : true })
        element.src = "https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.js"
        element.crossOrigin = "anonymous"
        document.head.appendChild(element)
      })
  
      this.mapKit = await new Promise((resolve, reject) => {
        const mapKit = (globalThis as any).mapkit;
        (globalThis as any).mapkit = undefined
        
        mapKit.init({
          // libraries: ["services", "full-map", "annotations"],
          authorizationCallback: function(done: any) {
            done("-- TOKEN --")
          },
          language: "ja"
        })
        
        mapKit.addEventListener("configuration-change", (event: any) => {
          switch (event.status) {
            case "Initialized":
              resolve(mapKit)
              return
            // case "Refreshed":
            // default:
          }
          reject(event)
        })
        
        mapKit.addEventListener("error", (error: any) => reject(error))
      })
    })
    
    return this.mapKit
  }
}

ChatGPT への質問は Google 検索と比較して、どのぐらい効率よく答えにたどりつけたのだろう。このテーマに関する Google 検索の成果は、あまり芳しいものではなかった。欲しい答えの一部にたどり着いた段階で、かなりの時間と労力を費やしていた。

キーワードから関連しそうなネット記事の候補を一覧表示するだけなのか、疑問に対してそのものズバリの答えを返すのか、この差を考えれば当然の結果ではある。

なんということだろう。今回、技術的な調査で最初に用いるソリューションを更新することになった。ググるという言葉は、もう過去のものになろうとしている。

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 で管理されていないという事態は回避された。

Vue Ver.3.4 Release と DevTools

d.taniguchi

2024年4月10日 9:34

Post / コメント

  • TypeScript
  • Vue3

昨年末である 2023/12/28 に、 Vue の Ver.3.4 が Releaseとなった。

The Vue Point “Announcing Vue 3.4”
https://blog.vuejs.org/posts/vue-3-4

Template Parser の全面書き換えによる 44% の SFC コンパイル速度向上や、状態監視の改良により、computed、 effect、 watch、 watchEffect 各関数の呼び出しの実行が、実値更新時のみになるといったパフォーマンス上の改善もさることながら、コーディング上のうれしい改善も 2 つ含まれていた。

defineModel マクロの stable 格上げと、v-bind の shorthand 追加だ。いずれも欲しかった Syntax である。簡単なコンポーネントでどの程度差が出るか比較する。

Vue 3.3 までのテキスト入力コンポーネント

InputText.vue
<script setup lang="ts">
import { type HTMLAttributes } from "vue"

const props = withDefaults(defineProps<{
  modelValue?: string
  placeholder?: string
  disabled?: boolean
  inputmode?: HTMLAttributes["inputmode"]
}>(), {
  modelValue: "",
  placeholder: "入力してください。",
  inputmode: "text",
})

const emit = defineEmits<{
  (e: "update:modelValue", value: string): void
}>()
</script>

<template>
  <input
    type="text"
    v-model="modelValue"
    :placeholder="placeholder"
    :disabled="disabled"
    :class="{ disabled: disabled }"
    :inputmode="inputmode"
    autocomplete="off"
  />
</template>

Vue 3.4 の defineModel と shorthand を使用したテキスト入力コンポーネント

InputText.vue
<script setup lang="ts">
import { type HTMLAttributes } from "vue"

const text = defineModel({ default: "" })

const props = withDefaults(defineProps<{
  placeholder?: string
  disabled?: boolean
  inputmode?: HTMLAttributes["inputmode"]
}>(), {
  placeholder: "入力してください。",
  inputmode: "text",
})
</script>

<template>
  <input
    type="text"
    v-model="text"
    :placeholder
    :disabled
    :class="{ disabled: disabled }"
    :inputmode
    autocomplete="off"
  />
</template>

可読性の改善は著しい。

現在、私が開発を進めているプロジェクトは昨年の 9 月から始めたものだから、 defineModel はまだ実験段階だった。忸怩たる思いで modelValue の defineEmits を書いていたのだが、どこかのタイミングで defineModel に修正したい。

さて、新機能の動作確認当たり Vue Ver 3.4 の初期プロジェクトを Scaffolding tool で生成しようとした際、見慣れない文字列を見つけた。

実験段階の DevTools なるもののインストール確認項目が追加さたようだ。これは非常に興味深い。試しにこれも追加して、開発サーバーを立ち上げたら以下のような画面が表示された。

一見いつもの初期プロジェクトだが、中央下に小さなアイコンがある。これが追加された DevTools らしい。Vue アイコンをクリックすると以下の画面が出てきた。

新しい DevTools はブラウザの拡張機能ではなく、開発中プロジェクトのページ上に表示されるらしい。なるほどと思い次の項目を選択して驚愕した。

現在ブラウザで表示している Path の画面内に存在するコンポーネント一覧がツリーで表示されるのだ。ご丁寧なことに、それが画面上のどの DOM で そのコンポーネントの props には何が設定されているのかまで分かる。

Toggle Component Inspector を有効にすると、Hover 中の DOM が何のコンポーネントで、どのファイルの何行目のコードで生成されているか分かる。

これまで、表示している画面上の UI が、何のコンポーネントで、それはどのファイルでどこにあり、どの様に呼び出されているかは、ソースコードを読まなければ分からなかった。コンポーネントの構造にルールを設け、より直感的に UI 部品の構成を理解できるように考え出されたのが Atomic Design だ。

私がテックリードを務めるプロジェクトでは、コンポーネントのルート要素のクラス名に命名規則を設けることで、コンポーネントが簡単に特定できる工夫をしていた。これは必要なくなる。

これは小さからぬ事件ではないだろうか。フロントエンド開発のベストプラクティスを前進させている。

担当になりたての既存プロジェクトで改修対象のファイル特定のため、レンダリング後の HTML の中からキーワードを拾って Grep 、ファイルを特定するなどという非効率な作業は消滅する。Storybook では Page Component まで定義する必要がなくなるかもしれない。

このツールには他に、有効な画面の Path 一覧、プロジェクト内の画像ファイル一覧、Pinia の 状態表示、コンポーネント間の依存関係グラフ表示などの機能があった。Vite にしても、この DevTools にしても、Vue Framework の開発力と発展速度には恐れ入るばかりだ。

これほどのものなら、現在開発中のプロジェクトで試したくなる。たった 3 行の追加と Package インストールで使用可能になる。

package.json
{
  // 省略
  "devDependencies": {
    // 省略
    "vite-plugin-vue-devtools": "^7.0.25", // 追加
    // 省略
}
vite.config.ts
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueDevTools from 'vite-plugin-vue-devtools' // 追加

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    VueDevTools(), // 追加
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

DevTools の公式によると Vue3 であればどのバージョンでも対応するそうである。Ver 3.3.10 で開発をスタートした私のプロジェクトではすんなり動いた。UI の Hover で、コンポーネントごとにファイル名が表示された時は感動があった。残念ながらその様子をここでお見せすることは出来ないが、コンポーネントグラフであれば問題ないので残しておこう。

不必要な関数やコンポーネントの発見、削除に役立ちそうだ。

Vite が Docker で動作しない

d.taniguchi

2023年9月23日 22:55

Post / コメント

  • Docker
  • Tips
  • Vite
  • Vue3

Vue3 による新規開発プロジェクトが始まり、プロジェクト基盤の整備を行っている。

Scaffolding tool で 初期 Project を生成、Router や Linter、Validator 等、使用ライブラリの選定・設定や、環境毎の DI 設定など、前準備の真最中だ。

概ね必要な部品群は揃い、いよいよ Dockerfile を追加して docker-compose で画面が立ち上がれば準備完了という段に来て、これまで堅調に進んでいた構築作業は水を差された。これは恒例行事だった。Docker の設定、この作業がスムーズにいった試しはない。悪いことに、今回はいつも以上にしぶとい抵抗を見せるのだった。どうやっても Vite が下記のエラーで立ち上がらない。

Segmentation fault

この時の Vite の Version は 4.4.9 、Docker for Mac の Version は 4.23 であった。調べてみると Docker の GitHub リポジトリに Issue が立っていた。

https://github.com/docker/for-mac/issues/6824

このエラー、 Docker の開発陣にも原因がよく分かっていないようだった。以下条件で発生するという。

  • Intel Mac の一部機種
  • Node Version 18 〜 20 で実行
  • Docker for Mac Version 4.19 以上
  • Docker ではなく、 Mac の Hypervisor の問題

CPU に Core i7 や i9 を搭載している機種で多く発生していて、 i5 だと動くことが多いようである。私の使用していた iMac は i9 だ。手元に i5 搭載の MacBook があったのでこちらにリソースを移して docker-compose をたたいてみたらすんなり動いた。

問題が発生しても対応は簡単で、Docker の GUI から Setting > General 内にある VirtioFS のチェックを osxfs(Legacy) に変更、Use Virtualization framework のチェックを外せばいいだけである。

これにより Vite が立ち上がるようになった。

React18 にするか、Vue3 にするか

d.taniguchi

2023年9月22日 19:14

Post / コメント

  • React
  • Vue3

ありがたいことに、2023年9月より再び Frontend のリードエンジニアとして新規開発プロジェクトに参画している。しかも、今回 Frontend の担当者は私1人だけだ。

CTO、BE、FE にそれぞれ一人、総勢3名の小さなチーム。開発予定のソフトウエアはまだ構想のみ、要件定義も、画面デザインも何もないところからスタートである。私の最初の課題は技術選定だ。

仕様を考慮すると SPA が最善という結論に至ったが、フレームワークを React 18 にするか Vue 3 にするかはすぐ選ぶことができなかった。 Web 業界では、 Vue が一世を風靡したかと思ったら、また React へ軸足を戻してしまった。理由はよくわかる。 Vue がとどまることを知らない素晴らしい進化を続けるからだ。 Vite を生み出し、あっという間に TypeScript に対応、さらにデフォルトの API まで変更したと思ったら、 Vue2 の 2023 年末 EOL まで決めてしまった。背筋の凍る早さだ。 Vue3 には破壊的変更がある。Vue2 からたやすく移行を行えない。

今回案件探しをしている中に、 Vue2 から Vue3 への移行プロジェクトがあった。 Vue3 に触ってみたいだけの気軽さで応募したところ、「移行経験ない人お断り!」とけんもほろろに断られてしまった。この切迫感、当然である。いつ自分たちで修正不能なクリティカルレベルの脆弱性が見つかるとも知れないプロダクトが本番環境で動いていると思うとゾッとする。いや、むしろもうすでに山のような・・・。

Vue2 の公式では、やや申し訳なさそうに Vue3 はどうしても破壊的変更が不可避だったこと、 Vue2 の EOL 、そして今後のリリースは、下位互換を大切にしたいとする思いが語られている。

https://v2.vuejs.org/lts/#Upgrade-to-Vue-3

信用できない。長期的な運用を考慮するとやはり Vue を選ぶのは怖い。 React を Vite で動かすのが現状最善と考え CTO に提案したら、是非 Vue を採用してほしいと頼まれてしまった。

かくして私は Vue3 開発を行うことになったのだった。React であれば新鮮さもないが、Vue3 だと新しいことだらけだ。TypeScriptへの対応ぶり、Composition API の書き心地など、今から楽しみなのである。