Vue で createApp() を活用してモーダルを動的に管理する方法
TL;DR
Vue 3 の createApp() を使って 独立した Vue インスタンスごとにモーダルを動的に管理 する方法を紹介。
Pinia を活用してモーダルをスタック構造で管理し、複数のモーダルを順番に開閉できる仕組み を実装。
パフォーマンス面での注意点もあるが、複数のモーダルを同時に扱うシナリオには有効なアプローチ。
こんなかんじに動作します
はじめに
Vue でモーダルを実装する際、通常は v-if や v-show を使って表示・非表示を切り替えるのが一般的かと思われます。あるいはModalを出し分けるProviderとしてのVue Elementを使ったりすることもあると思います。しかし、複数のモーダルを重ねて表示する場合、管理が複雑になりがちです。
そこで、Vue の createApp() を利用し、新しい Vue インスタンスを作成してモーダルをスタックしていく方法を紹介します。この方法なら、各モーダルが独立した Vue インスタンスとして動作し、管理がシンプルになります。
今回記載する例は下記を満たすものとします
- モーダルの上に別のモーダルを重ねて表示できる
- モーダルは一つずつ消すことができる
- モーダルはまとめて消すこともできる
- モーダルがいくつ重なってもBackdropは1つしか表示しない
環境
本記事では、以下の環境を使用しています。
- Vue 3 (createApp() を使用) script setup syntaxを利用
- TypeScript
- Pinia(状態管理)
- Vite(ビルドツール)
Pinia は、モーダルのスタック管理や状態共有をシンプルに行うために導入しています。
作成するファイル
- App.vue
- BaseModal.vue
- AlertModal.vue
- ConfirmModal.vue
- modalManager.ts
App.vue
一旦空のモーダルを表示するためのボタンを置いたものを用意します。
<script setup lang="ts">
import { useModalStore } from '@/stores/modalManager'
defineProps<{
msg: string
}>()
const modalStore = useModalStore()
const newModal = () => {
modalStore.openModal('confirm')
}
</script>
<template>
<div class="wrapper">
<button @click="newModal">Open Modal</button>
</div>
</template>
modalManager.ts
import { createApp, ref } from 'vue'
import type { App } from 'vue'
import { defineStore } from 'pinia'
import AlertModal from '@/components/AlertModal.vue'
import ConfirmModal from '@/components/ConfirmModal.vue'
interface AppListItem {
app: App<Element>
modalContainer: HTMLElement
}
type ModalTypes = 'alert' | 'confirm'
function getModalComponent(type: ModalTypes) {
switch (type) {
case 'alert':
return AlertModal
case 'confirm':
return ConfirmModal
default:
return AlertModal
}
}
export const useModalStore = defineStore('modal', () => {
const modalStack = ref<AppListItem[]>([])
function openModal(type: ModalTypes) {
const modalContainer = document.createElement('div')
document.body.appendChild(modalContainer)
const ModalComponent = getModalComponent(type)
const app = createApp(ModalComponent, {
modalNumber: modalStack.value.length,
})
app.config.globalProperties.$rootProps = app._component.props
app.mount(modalContainer)
modalStack.value.push({
app,
modalContainer,
})
}
function closeModal() {
const appListItem = modalStack.value.pop()
if (appListItem?.app) {
appListItem.app.unmount()
document.body.removeChild(appListItem.modalContainer)
}
}
function closeAll() {
while (modalStack.value.length) {
closeModal()
}
}
return { openModal, closeModal, closeAll }
})
モーダルの種類に応じて適切なコンポーネントを取得し、動的に createApp() を使ってマウント・アンマウントする仕組みを作ります。
BaseModal.vue
<script setup lang="ts">
import { computed, getCurrentInstance, onMounted } from 'vue'
const instance = getCurrentInstance()
// Vue インスタンスのrootPropsとして定義されたmodalの数を取得
const propNumber = instance?.appContext?.app?._props?.modalNumber ?? 0
const dialogId = computed(() => `modal-${propNumber}`)
onMounted(() => {
const dialog = document.getElementById(dialogId.value) as HTMLDialogElement
dialog?.showModal()
})
</script>
<template>
<dialog :id="dialogId" class="modal" :class="{ isBase: propNumber === 0 }">
<slot></slot>
</dialog>
</template>
<style scoped>
.modal {
min-width: 300px;
max-width: 600px;
padding: 16px;
background-color: white;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
color: black;
}
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
justify-items: center;
}
.modal-backdrop.isBase {
background-color: rgba(0, 0, 0, 0.5);
}
dialog {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
background-color: white;
}
dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
justify-items: center;
}
dialog:not(.isBase)::backdrop {
background-color: transparent;
}
</style>
BaseModalはモーダルのバックドロップや左右中央寄せを行うコンポーネントです。
modalのvueインスタンスに付与されたモーダルがいくつ目なのかの数を取得して最初のインスタンスである場合のみバックドロップを半透明の黒にかけています。
modal-backdropにただ半透明の黒をかけてしまうとバックドロップが重なるごとに背景が黒く黒くなっていきます。
AlertModal.vue
<script setup lang="ts">
import ModalBase from './ModalBase.vue'
import { useModalStore } from '@/stores/modalManager'
const modalStore = useModalStore()
function closeModal() {
modalStore.closeAll()
}
</script>
<template>
<ModalBase>
<div class="modal">
<div>
<h2>Alert</h2>
</div>
<div>
<p>Are you sure you want to close all modal?</p>
</div>
<div class="modal-footer">
<input class="alert-button" type="button" value="Close" @click="closeModal" autofocus />
</div>
</div>
</ModalBase>
</template>
<style scoped>
.modal {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.alert-button {
padding: 8px 16px;
background-color: #f00;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.alert-button:focus {
outline: blue auto 5px;
}
AlertModalはコンファームの後に表示され、ボタンの押下時にすべてのモーダルを閉じるpinia actionを呼んでいます。
ConfirmModal.vue
<script setup lang="ts">
import ModalBase from './ModalBase.vue'
import { useModalStore } from '@/stores/modalManager'
const modalStore = useModalStore()
function closeModal() {
modalStore.closeModal()
}
function openModal() {
modalStore.openModal('alert')
}
</script>
<template>
<ModalBase>
<form class="modal" @submit.prevent="openModal">
<div>
<h2>Confirm</h2>
</div>
<div>
<p>Are you sure you want to see new modal?</p>
</div>
<div class="modal-footer">
<input class="cancel button" @click="closeModal" type="button" value="Cancel" />
<input
id="confirmButton"
class="confirm button"
@click="openModal"
type="submit"
autofocus
value="OK"
/>
</div>
</form>
</ModalBase>
</template>
<style scoped>
.modal {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
}
.modal-footer {
display: flex;
gap: 8px;
}
.button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
}
.button:focus {
outline: blue auto 5px;
}
.cancel {
background-color: #888;
color: white;
}
.confirm {
background-color: rgb(0, 201, 0);
color: white;
}
ConfirmModalはキャンセルした場合にはモーダルをひとつ、OKだった場合にはAlertModalを上から開くようになっています。
まとめ
この方法を使うと、Vue の createApp() を利用して新しいインスタンスを作成し、独立したモーダルを動的に管理できます。
また、Pinia を活用することで、開かれているモーダルの数やスタックの状態を簡単に管理できます。
メリット
✅ モーダルごとに独立した Vue インスタンスで動作
✅ 動的に追加・削除が可能
✅ Pinia で状態管理をシンプルに実装可能
デメリット
❌ パフォーマンスに影響する可能性(大量のモーダルを開くと、新しい Vue インスタンスが増えてメモリを消費する)
❌ グローバルな状態共有が難しい(Pinia などの状態管理なしでは、モーダル間のデータ共有が難しくなる)
❌ イベントリスナー管理が必要(モーダルを開いたままvue-routerによるページ遷移すると、不要なインスタンスが残る可能性がある)
この手法は、一時的に開くモーダルが少数の場合に適しており、頻繁に開閉する UI には適さない場合もある ため、使用シーンを考慮して実装することをおすすめします。
こんなかんじに動作します
追記
<div>
を用いたデモになっていたので、よりモダンな<dialog>
タグを用いたものに変更しました
Discussion
今なら dialog 要素使って作った方が focus 周りの管理が楽なような気が
たしかに。。。
dialog + autofocusでちょっと作り直そうかな
ちなみに dialog 要素を使って作ると 中に form を置いて method属性値を dialog とするとことで form の submit と連携して閉じたりすることができるので便利です。