フューチャー技術ブログ

Vue3でモーダルダイアログの起動をいい感じに実装する

Reactでのダイアログの開閉制御については以前、別のエントリーで書きました。

Vue3でも、何か簡単に書ける方法はないかと試行錯誤して、ちょっといい感じかな? という方針を見つけたので、備忘がてら技術ブログに書いておきます。

使いやすいダイアログAPIとは

太古の昔より、便利なダイアログ機能というのは、呼び出し元はダイアログの開閉状態とか細かい制御は気にせず、必要な情報を渡して、結果だけもらうというものです。JavaScriptのブラウザのAPIにもありますよね。

const result = confirm("今日はいい天気でしたね")
// OKのときはtrue
console.log(result)

これはVisual Basicとかでもなんでも同じですね。ただし、JavaScriptだとconfirm()alert()prompt()は画面をブロックしてしまう、という問題があります。デザインを変えられないとか。

しかし、ブロッックさせないようにReact、Vue、Angularといったものを使い出すと、この使いやすいデザインとかは頭から抜け落ちて、状態管理をどうしよう、結果はコールバックかemitか、みたいな感じになりがちです。どちらにしても呼び出す側でコールバック関数やら何やらの準備が必要だし、パラメータも増えてしまうしで、あまり嬉しくないことになります。

がんばって、昔ながらのAPIに寄せてみようと思います。

方針

目指す方向性としては、ダイアログのrefを取得してそれ経由でダイアログをオープンすると同時に、結果が帰ってくるまでawaitするという感じですね。

const result = await myDialog.value?.openDialog()

実装

HTML標準になった<dialog>タグを使います。デザインは本題ではないのですが、daisyUIを使っています。どんなフレームワークにもマッチするのでお気に入りです。

ダイアログは <form method="dialog">なフォームを作って、そこにボタンを置けば、JSを書かずにダイアログを閉じられます。ただ、編集中は閉じさせないとか、細かい制御がうまくいかなかったので、自分でハンドラを作っています。

<template>
<dialog ref="dialog" class="modal">
<div v-if="open" class="modal-box">
<h3 class="font-bold text-lg">Hello!</h3>
<p class="py-4">Press ESC key or click the button below to close</p>
<div class="modal-action">
<!-- if there is a button in form, it will close the modal -->
<button class="btn btn-secondary" @click="onDialogAction('cancel')">Cancel</button>
<button class="btn btn-primary" @click="onDialogAction('close')">Close</button>
</div>
</div>
</dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'

// APIを公開
defineExpose({
openDialog
})

// dialogの参照を保持する変数
const dialog = ref<HTMLDialogElement | null>(null)
const open = ref(false)

// Promiseのresolveをキャッシュ
let resolve: (action: 'cancel' | 'close') => void

// 外部に公開するAPI。ダイアログを開いて、終了
async function openDialog() {
open.value = true
dialog?.value?.showModal()
const promise = new Promise<'cancel' | 'close'>((res) => {
resolve = res
})

const result = await promise
dialog?.value?.close()
open.value = false
return result
}

// ダイアログ側の閉じるボタンが押されたときに呼ばれるコールバック
function onDialogAction(action: 'cancel' | 'close') {
resolve(action)
}
</script>

これでだいたい動くのですが、標準のダイアログはescapeキーでも閉じてしまいます。その場合、Promiseのresolve()が呼ばれないので、終了待ちしている起動側のハンドラがずっと待ち続けてしまいます。escapeが押された場合にもきちんとPromiseを解決するようにします。

import { ref, refonMounted, onUnmounted } from "vue"

// escapeキーで閉じるのをフックして、実装しようとする終了と同じ流れに載せる
function handleEscape(event: Event) {
event.preventDefault()
resolve('cancel')
}

onMounted(() => {
dialog.value?.addEventListener('cancel', handleEscape)
})

onUnmounted(() => {
dialog.value?.removeEventListener('cancel', handleEscape)
})

呼ぶ側はこんな感じです。残念ながら、テンプレート側にダイアログのコンポーネントを置く、というのはサボれないですね。

<template>
<MyDialog ref="myDialog" />
</template>

<script setup>
async function 何かのハンドラ() {
// 'close'か'cancel'が帰ってくる
const result = await myDialog.value?.openDialog()
}
</script>

これはconfirm()相当ですが、当然他の情報もいろいろ返せます。また、 openDialog()の引数として追加の引数を渡すことも可能です。モーダルダイアログという特性上、おそらくpropsで渡すものはほとんどないんじゃないですかね。

既存のフレームワークのダイアログ機能と違い、<dialog/>で作った場合は、非表示であるものの、仮想DOMは内部的に作成処理が走ってしまいます。コンポーネントで初期値をpropsで受け取っている場合など、表示のタイミングにあわせて値を設定しても、すでに設定済みの値の変更ということになり、内部で状態を保持していじるようなケースだとちょっと困ったことになるかもしれません(なりました)。そのため、openというフラグをつけて、ダイアログの中に v-if="open"という条件判断を入れて、表示のタイミングで初めてDOMが作られるようにしています。

Promise.withResolvers()

太田さんより、ES2024で、Promise回りの書き方が楽になりますよ、と教えてもらいました。Promise.withResolvers()というメソッドが増えます。行数削減効果は1行ですが、async/awaitの時代でPromiseを直接触る機会が今後減っていく前提で考えれば、将来読む人の認知不可は減って良さそうです。

// 変更前
const promise = new Promise<'cancel' | 'close'>((res) => {
resolve = res
})

// 変更後
let promise: Promise<'cancel' | 'close'>
{ promise, resolve } = Promise.withResolvers();

ただ、このメソッド、Node.jsだと最新の22では使えますが、現在アクティブなLTSの20や開発版の21では使えません。Vue.jsの自動生成プロジェクトだと、@tsconfig/node20を参照していているせいか、vue-createでしゃっと作ったプロジェクトだと使えないですね。ちょびっとlibとかtargetいじってみたものの、なかなかVSCodeで有効化されないので、もうちょっと調べてみようと思います。

まとめ

Vue.jsでも標準の<dialog>と仲良くなれました。使う側との接点が最小のAPIを作れました。

開いて初期化を行うのと、終了時に結果を送り出す部分を1つのopenDialog()という関数にまとめることで、初期化と後始末で忘れずにセットで行う処理が散らばらずに済むようになりましたし、入力と出力も近くで扱えるので、利用側からするとインタフェースがわかりやすく、「コードを見たらすぐに使い方がわかる」コードになったと思います。

<script setup>方式で書いてみましたが、慣れるとだいぶスムーズに書けますね。ただ、この世界は素のJS世界に見せかけた仮想の世界なので、たまーにベースのAPIをラップしている、という事実が見え隠れしてきますね。

Reactの方では、カスタムフックを介して実現しました。Reactでも useImperativeHandle()を使えば、コンポーネントにメソッドを追加して、それを使って今回と同じような処理を実装できると思います。ですが、上記のドキュメントの最後のPitfallのところに書かれているように、Reactの基本方針としては props で済むなら ref は使うなよ、という感じですね。