ワイ「VueでもユーザーのタイミングでPromiseをresolve()できんへんか？」

# Abstract

[ユーザーのタイミングでPromiseをresolveする記事](https://qiita.com/Yametaro/items/b6e035fe06530a9f47bc)が先日投稿されました。その記事ではReactによる実装が行われていますが、Vueを愛用している筆者にとってはVueの実装が欲しくなります。
そこで本記事では、ユーザーのタイミングでPromiseをresolveするVue 3の実装を紹介します。

# Introduction

最近こちらの記事: [6歳娘「パパ、ユーザーのタイミングでPromiseをresolve()できないの？」](https://qiita.com/Yametaro/items/b6e035fe06530a9f47bc)を読みました。こちらの記事ではReactによる実装が紹介されていますが、Vue3愛好家の筆者的には「これVue.jsでも似たようなことしたいよね」と思いました。ネットの海を漂うと、似たような記事はありますが、最新の記法(script setup)でかつTypeScriptで書かれている記事が見当たりませんでした。そこで本記事ではVue 3のscript setup記法で、ユーザーのタイミングでresolveする実装をしてみました。

出来上がるものは次の図。
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/483769/5a5939cd-c796-a62f-cea1-99ee755ec4d7.gif)

# 前提など

- TypeScriptの文法の解説はしません
- 基本アイデアは[6歳娘「パパ、ユーザーのタイミングでPromiseをresolve()できないの？」](https://qiita.com/Yametaro/items/b6e035fe06530a9f47bc)をお読みください。
- script setupの快適環境作成は付録にて。

# 実装

まずは実装を。

アイデアは以下。
- composablesを使ってダイアログの状態を管理
- App.vueにダイアログを1個だけ置いておいて、teleportさせることで実現

$`mkdir src/composables`とかでcomposablesフォルダを作成してください。

```composables/dialog.ts
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
    }
}
```

```components/dialogs/ConfirmDialog.vue
<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>

```

```App.vue
<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`を保持

:::note warn
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」を使ってモーダルを作成する](https://zenn.dev/naoki_oshiumi/articles/2207c38b5e1edc)を参考にしています。

### App.vue

App.vue側ではダイアログのコンポーネントを置いておきます。

```App.vue
<template>
<ConfirmDialog message="本当に処理しますか？" />
</template>
```

App.vue以外からでも、使う時には以下のように関数`confirm`を取得して呼び出します。

```hoge.vue
<script setup lang="ts">
import { useDialog } from "@/composables/dialogs"

async function someProcess() {
  const answer = await confirm()
  ...
}
</script>
```

`answer`にユーザーのダイアログの選択結果が入ります。
後はその選択肢に応じて分岐するなり、煮るなり焼くなりしてください。

<details><summary>旧バージョン(defineExposeを利用)はこちら</summary><div>

# 実装(旧バージョン)

```components/ConfirmDialog.vue
<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>
```

## 使用例

```App.vue
<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>`で囲っています。

```components/ConfirmDialog.vue
<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のエラー抑制です。本質ではないです。

```components/ConfirmDialog.vue
<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記法ではこれを使うことで関数などを公開することができます。

```components/ConfirmDialog.vue
<script setup lang="ts">
defineExpose({
    confirm, isOpen
})
</script>
```

またHTML/CSS部分については、[vue3の新機能「teleport」を使ってモーダルを作成する](https://zenn.dev/naoki_oshiumi/articles/2207c38b5e1edc)を参照ください。

呼び出す側では、少し工夫が必要です。抜粋した部分が以下です。
☆コンポーネントのrefを設定・取得する にあるように、コンポーネントの関数などを取得するにはその参照を取得する必要があります。


```App.vue
<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>
```

</div></details>

# まとめ

本記事では、ユーザーのタイミングでPromiseをresolve()する、Vue 3のscript setup記法/Typescriptでの実装例を示しました。
なお解説のセクションでも少し触れましたが、Nuxt 3などだと少し実装が変わる場合があります。注意してください。
もう少しスマート実装があればぜひ知りたいです。是非教えてください。

# 参考

- [6歳娘「パパ、ユーザーのタイミングでPromiseをresolve()できないの？」](https://qiita.com/Yametaro/items/b6e035fe06530a9f47bc)
- [vue3の新機能「teleport」を使ってモーダルを作成する](https://zenn.dev/naoki_oshiumi/articles/2207c38b5e1edc)

# 付録

## script setup記法の快適環境作成

vue-cliでtypescriptで指定してプロジェクトを作成すると楽です。詳細は割愛しますが、流れは以下です。

1. $`vue create projectName`
1. defaultではなく**Manuallyみたいなのを選択**
1. TypeScriptをオンにする
1. あとはお好み

~~デフォルトでオンにしてくれていいんだが...~~

### .eslintrc.jsへの追記

.eslintrc.jsにコンパイラーマクロ(`defineProps`など)を追加しないと、新しい`script setup`記法でエラーが出るので注意。

```diff:.eslintrc.js
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'
  }
}
```