Katashin .info

window.prompt を Vue.js で再発明する

window.prompt は JavaScript を一行書くだけでユーザーのテキスト入力を取得できる優れた API です。以下のように window.prompt を呼ぶだけで入力フォーム付きのダイアログが開き、戻り値でユーザーが入力した文字列を取得できます。

<script setup>
import { ref } from 'vue'

const list = ref([])

function onAdd() {
  const input = window.prompt('追加する文字列を入力してください')
  if (input) {
    list.value.push(input)
  }
}
</script>

<template>
  <button type="button" @click="onAdd">追加</button>

  <ul>
    <li v-for="(item, i) in list" :key="i">{{ item }}</li>
  </ul>
</template>

しかし、実際のアプリケーションでは独自ダイアログを実装することがほとんどです。window.prompt によって開かれるダイアログは、デザインや機能をカスタマイズできないためです。独自ダイアログが window.prompt と同様の使い勝手であれば良いのですが、大抵の実装には以降の節で解説する問題点があります。

本記事では、Vue.js で独自ダイアログを実装する例を通して、その実装によくある問題点を解説し、どうすれば window.prompt のようなシンプルなインターフェースの実装ができるかを解説します。

普通に独自ダイアログを実装した時の問題点 #

Vue.js で普通に独自ダイアログを実装すると以下のようになることが多いです。<DialogPrompt>window.prompt の代わりになるコンポーネントです。<DialogPrompt> コンポーネントはダイアログの開閉状態を open プロパティで受け取り、ユーザーからの入力値が確定された時に submit イベントを発火します。

<script setup>
import { ref } from 'vue'
import DialogPrompt from './DialogPrompt.vue'

const list = ref([])

// ダイアログの開閉状態を管理
const isOpenPrompt = ref(false)

function onAdd() {
  // 追加ボタンが押された時はダイアログを開くだけ
  isOpenPrompt.value = true
}

function onSubmitPrompt(input) {
  // 入力を受け取るのは onAdd とは別の関数で行わなければならない
  list.value.push(input)
  isOpenPrompt.value = false
}
</script>

<template>
  <button type="button" @click="onAdd">追加</button>

  <ul>
    <li v-for="(item, i) in list" :key="i">{{ item }}</li>
  </ul>

  <!-- ダイアログのコンポーネント -->
  <DialogPrompt :open="isOpenPrompt" @submit="onSubmitPrompt" />
</template>

window.prompt を使った実装と比較すると、<DialogPrompt> の実装には以下の問題点があります。

一連の処理が複数の関数に分散している
onAdd 関数では isOpenPrompt の値を更新してダイアログを開くだけで、ユーザーからの入力を受け取るのは別の関数 onSubmitPrompt で行っています。分ける必要のない処理が複数の関数にまたがっていて、読みづらくなっています。

処理の流れがテンプレートと JavaScript を行ったり来たりしている
onAdd 関数で isOpenPrompt の値を更新した後、テンプレートでその値を <DialogPrompt> に渡しています。その後、ユーザーの入力を submit イベントで受け取り、JavaScript 内の onSubmitPrompt 関数で処理を行っています。このコードを初めて読む人が処理の流れを追うと、テンプレートと JavaScript を行ったり来たりしないといけないため、理解しづらいです。

ダイアログの開閉状態を変数で管理する必要がある
<DialogPrompt> ではダイアログの開閉状態を isOpenPrompt の値で管理していますが、window.prompt では必要のない変数であり、状態が増えている分コードの複雑性が増しています。

このように、普通に独自ダイアログを実装すると、読みやすさの面で上記の問題点があります。次の節では、これらの問題点を解消し、window.prompt のように使えるダイアログを作ります。

window.prompt と同じインターフェースを持つダイアログの実装 #

まずは window.prompt と同じインターフェースを持つダイアログコンポーネントを考えます。以下のような使い方ができる <DialogPrompt> があれば window.prompt と同じように使えます。

<script setup>
import { ref, shallowRef } from 'vue'
import DialogPrompt from './DialogPrompt.vue'

const list = ref([])

// ダイアログコンポーネントの参照
const dialogPrompt = shallowRef()

async function onAdd() {
  // window.prompt とほぼ同じ書き方でダイアログを開き、入力を受け取れる
  const input = await dialogPrompt.value.showModal(
    '追加する文字列を入力してください',
  )
  if (input) {
    list.value.push(input)
  }
}
</script>

<template>
  <button type="button" @click="onAdd">追加</button>

  <ul>
    <li v-for="(item, i) in list" :key="i">{{ item }}</li>
  </ul>

  <!-- ダイアログのコンポーネント -->
  <DialogPrompt ref="dialogPrompt" />
</template>

window.prompt を呼び出していた部分で、コンポーネントから公開されている関数 showModal を呼びます。showModal は Promise オブジェクトを返し、ユーザーの入力が完了するまで処理を止めます。ユーザーの入力はその Promise オブジェクトが解決された時に受け取れます。

defineExpose で showModal 関数を公開 #

以下は <DialogPrompt> コンポーネントのベースとなるコードです。このコードを書きかえていって、window.prompt と同じインターフェースを持つダイアログコンポーネントにしていきます。今回の例では <dialog> 要素を使って実装していますが、<dialog> 要素を使わなくても同様の実装はできます。

<script setup>
import { ref, shallowRef } from 'vue'

// <dialog> 要素への参照
const dialog = shallowRef()

// ダイアログのタイトル
const title = ref('')

// ユーザーの入力値
const input = ref('')

function onCancel() {
  // Cancel をクリックされた時に呼び出される
}

function onSubmit() {
  // OK をクリックされた時に呼び出される
}

function onClose() {
  // ESC キーを押された時などに呼び出される
}
</script>

<template>
  <dialog ref="dialog" @close="onClose">
    <p>{{ title }}</p>

    <input type="text" v-model="input" />

    <button type="button" @click="onCancel">Cancel</button>
    <button type="button" @click="onSubmit">OK</button>
  </dialog>
</template>

まずは親コンポーネントから呼び出す関数を defineExpose を使って公開します。ダイアログを開いてユーザーの入力を受け取る showModal 関数と、ダイアログを閉じる close 関数の二つを公開します。

function showModal(_title) {
  // 親コンポーネントからダイアログを開くために呼ぶ関数
}

function close(result) {
  // 親コンポーネントからダイアログを閉じるために呼ぶ関数
}

// showModal と close 関数を外部に公開
defineExpose({
  showModal,
  close,
})

ダイアログを開く時に返す Promise オブジェクトとその resolve 関数を管理 #

ダイアログを開く showModal 関数の中では、作成した Promise オブジェクトを返すとともに、その resolve 関数を変数に保持します。

// Promise オブジェクトと、それを解決する resolve 関数を保持
let promise, resolve

function showModal(_title) {
  // Promise オブジェクトが保持されているということはすでにダイアログが開いているので
  // 保持した Promise オブジェクトを返して何もしない
  if (promise) {
    return promise
  }

  // 引数で渡されたタイトルを設定
  title.value = _title

  // 入力値をリセット
  input.value = ''

  // Promise オブジェクトを作成し、その resolve 関数を保持
  promise = new Promise((_resolve) => {
    resolve = _resolve
  })

  // ダイアログを開く
  dialog.value.showModal()

  // Promise オブジェクトを返す
  return promise
}

ここで保持した resolve 関数をユーザーの入力が完了した時に呼び出すことで、showModal の戻り値からユーザーの入力を受け取れます。

resolve 関数を変数として保持するのは hacky に見えるかもしれませんが、このパターンは様々な OSS プロジェクトで使われていて、このユースケースをカバーするために Promise.withResolvers という API が提案されています。

ダイアログを閉じる時に resolve 関数を呼び出す #

上記で保持した resolve 関数は、ダイアログを閉じる close 関数の中で呼び出します。resolve の引数に showModal 関数の戻り値として返したい値を渡します。ダイアログを閉じた後は保持している Promise オブジェクトと resolve 関数を破棄します。

function close(result) {
  // ダイアログを閉じる
  dialog.value.close()

  // showModal で返したい値を渡して resolve 関数を呼び出す
  resolve?.(result)

  // Promise オブジェクトと resolve 関数を破棄
  promise = resolve = undefined
}

ユーザーが Cancel ボタンを押した時は close 関数に値を渡さずに呼び、OK ボタンを押した時は入力された値を渡して呼び出します。これにより、OK ボタンを押した時のみ showModal から入力値を受け取れます。

function onCancel() {
  // 値を返さずにダイアログを閉じる
  close()
}

function onSubmit() {
  // 入力された値を返してダイアログを閉じる
  close(input.value)
}

function onClose() {
  // ESC キーなどで閉じられた後に呼び出されるので、
  // onCancel, onSubmit などで resolve されていない時はその処理をここで行う
  resolve?.()
  promise = resolve = undefined
}

以上で <DialogPrompt> の実装は完了です。これを使う側のコードは以下の通りです(再掲)。

<script setup>
import { ref, shallowRef } from 'vue'
import DialogPrompt from './DialogPrompt.vue'

const list = ref([])

// ダイアログコンポーネントの参照
const dialogPrompt = shallowRef()

async function onAdd() {
  // window.prompt とほぼ同じ書き方でダイアログを開き、入力を受け取れる
  const input = await dialogPrompt.value.showModal(
    '追加する文字列を入力してください',
  )
  if (input) {
    list.value.push(input)
  }
}
</script>

<template>
  <button type="button" @click="onAdd">追加</button>

  <ul>
    <li v-for="(item, i) in list" :key="i">{{ item }}</li>
  </ul>

  <!-- ダイアログのコンポーネント -->
  <DialogPrompt ref="dialogPrompt" />
</template>

これまでの実装をまとめると以下のように動作します。

ダイアログコンポーネントの応用 #

今回は window.prompt を再現する Vue.js コンポーネントを作成しましたが、showModal 関数の戻り値を変えることで様々な種類のダイアログを実装できます。

例えば、真偽値を返すようにすれば window.comfirm と同じことができますし、値を気にしなければ window.alert のように使えます。また、文字列や真偽値などのプリミティブに限らず、任意のオブジェクトを返せるので、アプリケーション独自の複雑なフォームなどもダイアログにできます。

結論 #

window.prompt のインターフェースはシンプルで、Vue.js などで独自のダイアログを作る時の参考にすると良いでしょう。コンポーネントのインスタンスで公開されている関数を親コンポーネントから呼び出すのは、あまり行われないパターンですが、このようなケースでは有効な手段だと思います。