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 などで独自のダイアログを作る時の参考にすると良いでしょう。コンポーネントのインスタンスで公開されている関数を親コンポーネントから呼び出すのは、あまり行われないパターンですが、このようなケースでは有効な手段だと思います。