🏇

Nuxt3 modal実装について考える

2023/10/03に公開

この記事は以下の記事を参考にし、modal実装のベストプラクティスを考える旅の続きを勝手に記したものです。nuxtに書き換えています。
https://qiita.com/t-sugimoto/items/9c01477c8998a1072225

実装リポジトリ

https://github.com/koosaka/think-modal

参考記事のおさらい

example1(アンチパターン)

pages/example1.vue
<script setup lang="ts">
import BaseModal from "@/src/modal/example1/BaseModal.vue";

const isErrorModalVisible = ref(false);

const openErrorModal = () => {
  isErrorModalVisible.value = true;
};
</script>

<template>
  <BaseModal v-if="isErrorModalVisible" @close="isErrorModalVisible = false" />
  <button @click="openErrorModal">モーダルを開く</button>
</template>

アンチパターンな理由

  • モーダルの開閉ロジックを呼び出すページで持っているため、ページとモーダルが密結合になっている。
  • 表示するモーダルの種類、数分の状態を定義しなければならない。
  • ページのhtml部分に表示条件を記述するため、htmlの見通しも悪くなる

example2

hookでmodalの開閉ロジックを持つこと、開閉するmodalをwrapしたmodalComponentを作成することでpageとmodalを疎結合にしている。

pages/example2.vue
<script setup lang="ts">
import ErrorModal from "@/src/modal/example2/ErrorModal.vue";
import { useModal } from "@/composable/example2/useModal";
const { openModal } = useModal();
</script>

<template>
  <ErrorModal />
  <button @click="openModal">モーダルを開く</button>
</template>
baseModal.vue(一部抜粋)
<script setup lang="ts">
const emit = defineEmits<{
  close: [];
}>();
</script>

<template>
  <div class="modal">
    <div class="modal-content">
      <h4>モーダルタイトル</h4>
      <p>モーダル本文</p>
      <button @click="emit('close')">閉じる</button>
    </div>
  </div>
</template>
errorModal.vue
<script setup lang="ts">
import { useModal } from "@/composable/example2/useModal";
import BaseModal from "@/src/modal/example2/BaseModal.vue";
const { isVisible, closeModal } = useModal();
</script>

<template>
  <BaseModal v-if="isVisible" @close="closeModal" />
</template>
useModal.ts
export const useModal = () => {
  const isVisible = useState<boolean>("example2-use-modal", () => false);
  const openModal = () => {
    isVisible.value = true;
  };
  const closeModal = () => {
    isVisible.value = false;
  };

  return { isVisible, openModal, closeModal };
};

感想

  • example1と比べるとかなり見通しが良くpageがスッキリした。
  • baseModalをwrapする実装は悪くないが、baseModalで状態持つのはどうなんだろう?という疑問が沸いた

一方で改善するとしたら以下の点が挙げられた。

  • 複数モーダル表示には対応していない。(そもそも一画面で出すモーダルの数は1つで良いという話はあるが、一応課題として挙げておく。)
  • 今の実装だとmodalの種類が増えたときにhookを増やさないといけない。
  • つど新種のmodalを増やすときにhookのメソッドをmodalに書かなければいけない。

modakStackという概念を試す

グローバルなストアに配列を用意し、そこにmodalの情報を追加、削除することでmodalの表示ロジックを管理する方法。ストアは何を使っても良いが、今回はpiniaを使用している。

example3

useModalStack.ts
import { defineStore } from "pinia";

export type ModalType = "base" | "error";

export const useModalStack = defineStore("modal-stack", () => {
  const modalStack = ref<ModalType[]>([]);

  const openModal = (modalType: ModalType) => {
    modalStack.value.push(modalType);
  };

  const closeModal = () => {
    modalStack.value.pop();
  };

  return { modalStack, openModal, closeModal };
});

上記のファイルで、modalStackとそれにpush,popする実装をしている。

page/example3.vue
<script setup lang="ts">
import { useModalStack, ModalType } from "@/composable/example3/useModalStack";
import { DefineComponent } from "vue";
import BaseModal from "@/src/modal/example3/BaseModal.vue";
import ErrorModal from "@/src/modal/example3/ErrorModal.vue";

const modalStackStore = useModalStack();
const modalMapping: Record<ModalType, DefineComponent<{}, {}, any>> = {
  error: ErrorModal,
  base: BaseModal,
};
</script>

<template>
  <div
    v-for="(modalType, index) in modalStackStore.modalStack"
    :style="{ zIndex: index + 1 }"
  >
    <component
      :is="modalMapping[modalType]"
      @close="modalStackStore.closeModal"
    />
  </div>
  <button @click="modalStackStore.openModal('base')">
    ベースモーダルを開く
  </button>
  <button @click="modalStackStore.openModal('error')">
    エラーモーダルを開く
  </button>
</template>

上記のファイルでグローバルストアにあるmodalStackを参照し、modal情報が格納されている場合は、それを表示する。

errorModal.vue
<script setup lang="ts">
const emit = defineEmits<{
  close: [];
}>();
</script>

<template>
  <div class="modal">
    <div class="modal-content">
      <h4>エラーモーダルタイトル</h4>
      <p>エラーモーダル本文</p>
      <button @click="emit('close')">閉じる</button>
    </div>
  </div>
</template>

感想

  • modalのcomponentでv-ifと開閉ロジックを持っていないのが良い
  • componentタグを使用しており、modalが増えてもロジック部分のみの修正でtemplate部分をいじること頻度が減るのが良さそう
    一方、各modalに対してのpropsにまだ対応できていないため、それを実装したのが以下になる。

example4

modalをerrorModalとalertModalを用意しpropsの定義を以下とする。

types/modal.ts
export type ErrorModalProps = {
  modalType: "error";
  errorCode: "404" | "500" | "other";
  closeFunc?: (() => void) | undefined;
};

export type AlertModalProps = {
  modalType: "alert";
  closeFunc?: (() => void) | undefined;
  logout: () => void;
};

export type ModalProps = ErrorModalProps | AlertModalProps;
useModalStack.ts
import { defineStore } from "pinia";
import { ModalProps } from "@/types/modal";

export const useModalStack = defineStore("modal-stack", () => {
  const modalStack = ref<ModalProps[]>([]);

  const openModal = (modalProps: ModalProps) => {
    modalStack.value.push(modalProps);
  };

  const closeModal = () => {
    modalStack.value.pop();
  };

  return { modalStack, openModal, closeModal };
});
pages/example4.vue
<script setup lang="ts">
import { ModalProps } from "@/types/modal";
import AlertModal from "@/src/modal/example4/AlertModal.vue";
import ErrorModal from "@/src/modal/example4/ErrorModal.vue";
import { useModalStack } from "@/composable/example4/useModalStack";

const modalStackStore = useModalStack();
const modalMapping: Record<ModalProps["modalType"], any> = {
  error: ErrorModal,
  alert: AlertModal,
};

const openErrorModal = () => {
  modalStackStore.openModal({
    modalType: "error",
    errorCode: "404",
    closeFunc: modalStackStore.closeModal,
  });
};

const openAlertModal = () => {
  modalStackStore.openModal({
    modalType: "alert",
    closeFunc: modalStackStore.closeModal,
    logout: () => console.log("ログアウトされました。"),
  });
};
</script>

<template>
  <button @click="openErrorModal">エラーモーダルボタン</button>
  <button @click="openAlertModal">アラートモーダルボタン</button>
  <div
    v-for="(modalProps, index) in modalStackStore.modalStack"
    :style="{ zIndex: index + 1 }"
  >
    <component :is="modalMapping[modalProps.modalType]" v-bind="modalProps" />
  </div>
</template>

上記のようにしておけば、openModalをする際にmodalTypeを選択すると自ずとそのmodalTypeの型の補完が効くようになる。

errorModalの場合は、propsとしてerrorCodeを必要としているが型がちゃんと効いている。

errorModal.vue(一部抜粋)
<script setup lang="ts">
import { ErrorModalProps } from "@/types/modal";
import { useErrorModal } from "@/composable/example4/useErrorModal";
const props = defineProps<ErrorModalProps>();
const errorModal = useErrorModal();

const modalText = computed(() => {
  return errorModal.getErrorModalText(props.errorCode);
});
</script>

<template>
  <div class="modal">
    <div class="modal-content">
      <h4>{{ modalText.title }}</h4>
      <p>{{ modalText.body }}</p>
      <button @click="props.closeFunc">閉じる</button>
    </div>
  </div>
</template>
useErrorModal
import { ErrorModalProps } from "@/types/modal";

type ExportProps = {
  getErrorModalText: (errorCode: ErrorModalProps["errorCode"]) => {
    title: string;
    body: string;
  };
};

const errorCodeMessageMapping: Record<
  ErrorModalProps["errorCode"],
  { title: string; body: string }
> = {
  "404": {
    title: "404 Not Found",
    body: "ページが見つかりませんでした。",
  },
  "500": {
    title: "500 Internal Server Error",
    body: "サーバーでエラーが発生しました。",
  },
  other: {
    title: "エラー",
    body: "エラーが発生しました。",
  },
};

export const useErrorModal = (): ExportProps => {
  const getErrorModalText = (errorCode: ErrorModalProps["errorCode"]) => {
    return {
      title: errorCodeMessageMapping[errorCode].title,
      body: errorCodeMessageMapping[errorCode].body,
    };
  };

  return { getErrorModalText };
};

感想

  • 各モーダルに対してのprops制御ができてよかった。
  • 型の書き方についてはもう少し良い感じに書けそうな気はする。
  • 新種のmodalが増えたり、modalで追加で呼びたい関数が増えても楽に対応できそうな予感がした。

終わりに

実際に運用してみないと良いのかわからないのが正直なところだが、
modalStackを用いることで綺麗にmodalの管理ができそうな可能性を感じた。
局所的にしか表示する必要のないmodalはそこに閉じ込めて、共通利用するmodalを管理対象に入れるのが良いのかな、と妄想したところで今回の旅は終わりにしようと思う。
正直なところ、モーダル実装のベストプラクティスがわかっていないため
知見のある方に是非、この旅の続きを歩んでもらいたい。あとは頼んだぞ、、!、、ぐは、、

Discussion