こんにちは。エンジニアの濱田( @hamakou108 )です。
先日、あるモーダルをサイトの各所で別々の条件によって表示する機能を Nuxt.js + TypeScript で実装する機会がありました。 このときの設計が他の場面でも応用できそうな知見としてまとめられそうだったので、この記事で紹介したいと思います。
話さないこと
背景
M&Aクラウドでは M&A に役立つ資料の一部のダウンロードや買い手企業様とのマッチング機能を会員登録された方向けに提供しています。 サイトのコンテンツが気になった方に少しでもリーチするため、会員登録を促すモーダルを特定のタイミングで表示しています。 勿論モーダルが何度も表示されてしまうようでは UX を損ねるため、どのページでどのような行動を行ったときにモーダルを表示するか様々な条件によって制御されています。
設計
様々な種類の条件を手続き的に実装してしまうと、保守性に乏しいコードが出来上がってしまうのは目に見えています。 そこでまず幾つかの具体的な要件を取り上げ、それらを抽象化して設計を考えてみることにしました。
今回の要件としては例えば以下のようなものがありました。
- ページ A の訪問時、最初に訪問してから24時間経過していたらモーダルを表示する
- パーツ B のクリック時、累計3回目のクリックであればモーダルを表示する
これらの要件を分解してみると「ページ表示やクリックなどのイベント」と「経過時間や回数などの表示条件」の2つの要素の組み合わせという形に抽象化できそうです。 このように分離しておくと、後々「パーツ B のクリック時、最初に訪問してから24時間経過していたらモーダルを表示する」のように組み合わせが異なる要件が追加されても柔軟に対応できそうです。
よって今回はイベントと表示条件という2つのモジュール、そしてそれらを利用して Vue Component にモーダルの表示可否の判定や表示イベントの保存を行う手段を提供する Manager というモジュールの主に3つに分けて設計しました。
実装
Event
イベントに関するモジュール event.ts は以下のようになりました。
// ~/compositions/event.ts export type Event = VisitEvent | ClickEvent export type VisitEvent = { key: 'visit' page: string } export type ClickEvent = { key: 'click' page: string element: string }
型を判定するための key やページやパーツの名前といった型定義のみを持っています。 Vue Component でイベントを定義するときや Web Storage に保存している過去のイベント情報を取得するときにこれらの型情報を参照します。
Condition
表示条件に関するモジュール condition.ts は以下のようになりました。
// ~/compositions/condition.ts import { CustomDate, CustomDuration } from '~/foo/bar/date.ts' // 日付関連の型定義を持つ適当なファイル export type Condition = TimeCondition | CountCondition export type TimeCondition = { key: 'time' durationList: Array<CustomDuration> } export type CountCondition = { key: 'count' countList: Array<number> } type CurrentState = CurrentTimeState | CurrentCountState type CurrentTimeState = { key: 'time' firstEventOccurred: CustomDate | null lastModalShowed: CustomDate | null } type CurrentCountState = { key: 'count' count: number } export function useCondition(condition: Condition) { const canShowUnderTimeCondition = ( currentState: CurrentTimeState ): boolean => { // イベント発生からの経過時間からモーダル表示可能か判定するロジック } const canShowUnderCountCondition = ( currentState: CurrentCountState ): boolean => { // イベントの発生回数からモーダル表示可能か判定するロジック } const canShow = ( currentState: CurrentState ): boolean => { if (currentState.key === 'time') { return canShowUnderTimeCondition(currentState) } else if (currentState.key === 'count') { return canShowUnderCountCondition(currentState) } } return { canShow } }
モーダルの表示条件に関する幾つかの型定義に加え、モーダルの表示可否を判定するロジック useCondition
を持っています。
表示条件を定めて useCondition
の引数として与えておき、 canShow
の引数として現在の状態を与えると、これらの情報に基づいて表示可否を判定します。
モーダル表示可否を判定する具体的なアルゴリズムの実装もこれはこれで面白いのですが、冗長になってしまうので割愛します。
Manager
イベントと表示条件を利用して Vue Component にモーダルの表示可否の判定や表示イベントの保存を行う手段を提供するモジュール manager.ts は以下のようになりました。
// ~/compositions/manager.ts import { useStorage } from '~/foo/bar/storage.ts' // Web Storage 関連の型定義やロジックを持つ適当なファイル import { Event } from '~/compositions/event' import { Condition, useCondition } from '~/compositions/condition' export function useManager(event: Event, condition: Condition) { const storage = useStorage(event, condition) const canShow = (): boolean => { const currentState = storage.getCurrentState() return useCondition(condition).canShow(currentState) } const saveEvent = () => { storage.saveEvent() } return { canShow, saveEvent } }
特筆すべき点はないですが、補足として useStorage
はイベントの発生回数やモーダルの表示時刻などの情報を Web Storage から取得または保存する composition です。
メソッドの引数として渡された condition に応じて必要な情報を取得・保存する役割を持ちます。
詳細な説明は本筋から逸れるので割愛します。
Vue Component
モーダルを表示するページの Vue Component は以下のようになりました。
// pages/some-page.vue <template> <div> <some-modal :can-show="canShow"></some-modal> </div> </template> <script lang="ts"> import { computed, defineComponent, onMounted, reactive, ref } from '@vue/composition-api' import { customDuration } from '~/foo/bar/date.ts' import { VisitEvent } from '~/compositions/event' import { TimeCondition } from '~/compositions/condition' import { useManager } from '~/compositions/manager' import SomeModal from '~/components/some-modal.vue' export default defineComponent({ components: { SomeModal }, setup() { const manager = ref<ReturnType<typeof useManager> | null>(null) onMounted(() => { // イベント: とあるページの訪問 const event: VisitEvent = { page: 'some', } // 表示条件: 最初のイベント発生から24時間または72時間経過後 const condition = reactive<TimeCondition>({ durationList: [ customDuration(1, 'days'), customDuration(3, 'days') ] }) manager.value = useManager(event, condition) manager.value.saveEvent() // ページ表示イベントの発生情報を保存 }) const canShow = computed(() => { return manager.value !== null ? manager.value.canShow() : false }) return { canShow } } }) </script>
「とあるページの訪問時、最初に訪問してから24時間または72時間経過していたらモーダルを表示する」という条件を表現するため、 useManager
にはそれぞれ対応するイベント、発生条件のオブジェクトを設定しています。
そして computed
を使って manager オブジェクトが生成されたタイミングでモーダルの表示可否を判定し、子コンポーネントのモーダルに props としてその情報を伝えます。
今回のイベントはページの表示なので、ライフサイクルフック onMounted
を使って useManager
を呼び出しました。
クリックイベントの場合は対象のコンポーネントの emit イベントに対してコールバック関数を定義し、その中で useManager
を呼び出すように実装します。
このように useManager
を利用して Vue Component 各所でモーダルの表示可否を制御できるようになりました!
最後に
M&Aクラウドではエンジニアを絶賛募集中ですので、興味がありましたら是非以下からご応募ください! Nuxt.js や TypeScript に知見のある方は特に大歓迎です!!