Abstract
ユーザーのタイミングでPromiseをresolveする記事が先日投稿されました。その記事ではReactによる実装が行われていますが、Vueを愛用している筆者にとってはVueの実装が欲しくなります。
そこで本記事では、ユーザーのタイミングでPromiseをresolveするVue 3の実装を紹介します。
Introduction
最近こちらの記事: 6歳娘「パパ、ユーザーのタイミングでPromiseをresolve()できないの?」を読みました。こちらの記事ではReactによる実装が紹介されていますが、Vue3愛好家の筆者的には「これVue.jsでも似たようなことしたいよね」と思いました。ネットの海を漂うと、似たような記事はありますが、最新の記法(script setup)でかつTypeScriptで書かれている記事が見当たりませんでした。そこで本記事ではVue 3のscript setup記法で、ユーザーのタイミングでresolveする実装をしてみました。
前提など
- TypeScriptの文法の解説はしません
- 基本アイデアは6歳娘「パパ、ユーザーのタイミングでPromiseをresolve()できないの?」をお読みください。
- script setupの快適環境作成は付録にて。
実装
まずは実装を。
アイデアは以下。
- composablesを使ってダイアログの状態を管理
- App.vueにダイアログを1個だけ置いておいて、teleportさせることで実現
$mkdir src/composables
とかでcomposablesフォルダを作成してください。
import { ref } from 'vue'
//* Global states *//
//! Nuxt3 の場合は以下の宣言部分は関数内で宣言する。
const isOpen = ref(false)
// eslint-disable-next-line @typescript-eslint/no-empty-function
const _resolve = ref<(ans: boolean) => void>(() => { })
export function useDialog() {
const confirm = async () => {
return new Promise<boolean>((resolve) => {
isOpen.value = true
_resolve.value = resolve
})
}
const close = () => {
isOpen.value = false
// eslint-disable-next-line @typescript-eslint/no-empty-function
_resolve.value = () => {}
}
const ok = () => {
_resolve.value(true)
close()
}
const cancel = () => {
_resolve.value(false)
close()
}
return {
confirm,
ok,
cancel,
close,
isOpen
}
}
<script setup lang="ts">
import { useDialog } from "@/composables/dialogs"
interface Props {
message?: string
}
withDefaults(defineProps<Props>(), {
message: ''
})
const { isOpen, cancel, ok } = useDialog()
</script>
<template>
<teleport to="body">
<div class="modal" v-if="isOpen" @click.prevent="cancel()">
<div class="modal-content">
<p class="message">{{ message }}</p>
<div class="btn-container">
<button class="btn btn-ok" @click="ok()">OK</button>
<button class="btn btn-cancel" @click="cancel()">Cancel</button>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.modal {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
z-index: 9999;
}
.modal-content {
text-align: center;
padding: 1rem 2rem 1rem 2rem;
background-color: white;
border-radius: 20px;
box-shadow: 5px 5px 4px rgba(0, 0, 0, 0.8);
}
.btn-container {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.btn {
border-radius: 20px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.btn-ok {
width: 6rem;
border: 5px double blue;
}
.btn-cancel {
width: 5rem;
border: 1px solid grey;
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { useDialog } from "@/composables/dialogs"
import ConfirmDialog from '@/components/dialogs/ConfirmDialog.vue';
const { confirm } = useDialog()
const userChoice = ref(false)
async function someProcess() {
const answer = await confirm()
userChoice.value = answer
}
</script>
<template>
<ConfirmDialog message="本当に処理しますか?" />
<button @click.prevent="someProcess">
何かの処理をします
</button>
<p>
{{ userChoice }}
</p>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
少しだけ解説
なおコード中にある// eslint-disable-next-line @typescript-eslint/no-empty-function
はESLintのエラー抑制です。本質ではないです。
composables/dialog.ts
グローバルステートとして2つ導入しています。
-
isOpen
- ダイアログが表示されているかを保持
-
_resolve
-
confirm
で作られたPromise
のresolve
を保持
-
Vue.js 3 ではuseXXX
の外側にref()
で定義しないと、コンポーネント呼び出すたびに初期化された値を返すローカルなステートとなります。なおNuxt 3 ではuseXXX
の中で定義すればグローバルステートになります。(そもそもNuxt 3ではuseXXX
の外側にref()
するのは禁止)
components/dialogs/ConfirmDialog.vue
表示されるダイアログを定義するコンポーネントです。
const { isOpen, cancel, ok } = useDialog()
で表示非表示を定義するisOpen
と、ok, cancelをする関数を持ってきて、ダイアログのボタンに割り当てます。
見た目などはvue3の新機能「teleport」を使ってモーダルを作成するを参考にしています。
App.vue
App.vue側ではダイアログのコンポーネントを置いておきます。
<template>
<ConfirmDialog message="本当に処理しますか?" />
</template>
App.vue以外からでも、使う時には以下のように関数confirm
を取得して呼び出します。
<script setup lang="ts">
import { useDialog } from "@/composables/dialogs"
async function someProcess() {
const answer = await confirm()
...
}
</script>
answer
にユーザーのダイアログの選択結果が入ります。
後はその選択肢に応じて分岐するなり、煮るなり焼くなりしてください。
旧バージョン(defineExposeを利用)はこちら
実装(旧バージョン)
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
message?: string
}
withDefaults(defineProps<Props>(), {
message: ''
})
const isOpen = ref(false)
// eslint-disable-next-line @typescript-eslint/no-empty-function
const _resolve = ref<(ans: boolean)=> void>(() => {})
async function confirm() {
isOpen.value = true
return new Promise<boolean>((resolve) => {
isOpen.value = true
_resolve.value = resolve
})
}
function close() {
isOpen.value = false
// eslint-disable-next-line @typescript-eslint/no-empty-function
_resolve.value = () => {}
}
function ok() {
_resolve.value(true)
close()
}
function cancel() {
_resolve.value(false)
close()
}
defineExpose({
confirm, isOpen
})
</script>
<template>
<teleport to="body">
<div class="modal" v-if="isOpen" @click.prevent="cancel()">
<div class="modal-content">
<p class="message">{{ message }}</p>
<div class="btn-container">
<button class="btn btn-ok" @click="ok()">OK</button>
<button class="btn btn-cancel" @click="cancel()">Cancel</button>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.modal {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: auto;
z-index: 9999;
}
.modal-content {
text-align: center;
padding: 1rem 2rem 1rem 2rem;
background-color: white;
border-radius: 20px;
box-shadow: 5px 5px 4px rgba(0, 0, 0, 0.8);
}
.btn-container {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
.btn {
border-radius: 20px;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.btn-ok {
width: 6rem;
border: 5px double blue;
}
.btn-cancel {
width: 5rem;
border: 1px solid grey;
}
</style>
使用例
<script setup lang="ts">
import { ref } from 'vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
const userChoice = ref(false)
const ConfirmDialogRef = ref<InstanceType<typeof ConfirmDialog>>()
async function someProcess() {
if (!ConfirmDialogRef.value) {
return
}
const answer = await ConfirmDialogRef.value.confirm()
userChoice.value = answer
}
</script>
<template>
<ConfirmDialog ref="ConfirmDialogRef" message="本当に処理しますか?" />
<button @click.prevent="someProcess">
何かの処理をします
</button>
<p>
{{ userChoice }}
</p>
</template>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
少しだけ解説(旧バージョン)
まずはモーダルのコンポーネントについてです。
今回、確認メッセージはpropsで渡す仕様で実装しています。そのため最初にpropsを定義しています。
※なお、シンタックスハイライトのために<script setup lang="ts"></script>
で囲っています。
<script setup lang="ts">
interface Props {
message?: string
}
withDefaults(defineProps<Props>(), {
message: ''
})
</script>
続いて、本記事の最も核となる実装です。
isOpen
はダイアログが表示されているかを意味する変数です。_resolve
はconfirm
で作られたPromise
のresolve
を保持しておく変数です。
関数confirm
を呼び出すと、isOpen
がtrue
に切り替わり、Promise
を返します。この時に_resolve.value = resolve
とすることで、resolve
を保持しておきます。
その後ユーザーがダイアログのボタンを押すと、それに応じてresolve
が返されるという仕組みです。
なお// eslint-disable-next-line @typescript-eslint/no-empty-function
はESLintのエラー抑制です。本質ではないです。
<script setup lang="ts">
const isOpen = ref(false)
// eslint-disable-next-line @typescript-eslint/no-empty-function
const _resolve = ref<(ans: boolean)=> void>(() => {})
async function confirm() {
isOpen.value = true
return new Promise<boolean>((resolve) => {
isOpen.value = true
_resolve.value = resolve
})
}
function close() {
isOpen.value = false
// eslint-disable-next-line @typescript-eslint/no-empty-function
_resolve.value = () => {}
}
function ok() {
_resolve.value(true)
close()
}
function cancel() {
_resolve.value(false)
close()
}
</script>
しかしコンポーネントの内部の関数を公開することなんて...できます。それがdefineExpose
です。script setup記法ではこれを使うことで関数などを公開することができます。
<script setup lang="ts">
defineExpose({
confirm, isOpen
})
</script>
またHTML/CSS部分については、vue3の新機能「teleport」を使ってモーダルを作成するを参照ください。
呼び出す側では、少し工夫が必要です。抜粋した部分が以下です。
☆コンポーネントのrefを設定・取得する にあるように、コンポーネントの関数などを取得するにはその参照を取得する必要があります。
<script setup lang="ts">
import ConfirmDialog from './components/ConfirmDialog.vue';
// ☆コンポーネントのrefを設定・取得する
const ConfirmDialogRef = ref<InstanceType<typeof ConfirmDialog>>()
async function someProcess() {
// ConfirmDialogRef.value は undefinedの可能性もあるので早期returnなどで型推定をさせる
if (!ConfirmDialogRef.value) {
return
}
const answer = await ConfirmDialogRef.value.confirm()
// answerの値で選択しを判定して何かの処理をする...
}
</script>
<template>
<!-- ☆ コンポーネントのrefを設定・取得する -->
<ConfirmDialog ref="ConfirmDialogRef" message="本当に処理しますか?" />
</template>
まとめ
本記事では、ユーザーのタイミングでPromiseをresolve()する、Vue 3のscript setup記法/Typescriptでの実装例を示しました。
なお解説のセクションでも少し触れましたが、Nuxt 3などだと少し実装が変わる場合があります。注意してください。
もう少しスマート実装があればぜひ知りたいです。是非教えてください。
参考
付録
script setup記法の快適環境作成
vue-cliでtypescriptで指定してプロジェクトを作成すると楽です。詳細は割愛しますが、流れは以下です。
- $
vue create projectName
- defaultではなくManuallyみたいなのを選択
- TypeScriptをオンにする
- あとはお好み
デフォルトでオンにしてくれていいんだが...
.eslintrc.jsへの追記
.eslintrc.jsにコンパイラーマクロ(defineProps
など)を追加しないと、新しいscript setup
記法でエラーが出るので注意。
module.exports = {
root: true,
env: {
node: true,
+ 'vue/setup-compiler-macros': true
},
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/typescript/recommended'
],
parserOptions: {
ecmaVersion: 2020,
// JSXを使うなら加筆。
+ "ecmaFeatures": {
+ "jsx": true
+ }
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
}
}