shiodaifuku.io

【Vue 3.0】 Composition APIで分離しにくいページ固有のロジックについて考える

この記事はしおだいふく Advent Calendar 2020 7日目の記事です。 やってみた系記事が嫌いというのは先日話したとおりなので、今日は新ジャンルのやってみていない話を書きます。

Vue 3.0のComposition APIに関する課題を考えるだけのメモです。

Composition APIの利用パターン

Vue 3.0で導入されたComposition APIは、Vueコンポーネントからロジックを分離して全体に疎結合を保つ手段として非常に効果的であることがすでに知られています。 ここではいくつかの実装パターンを見ていきましょう。

例その1: 読み取るだけのデータ取得

次のような、ユーザ名を表示するコンポーネントを見てみましょう。

~/components/user.vue
<template>
  <div v-if="user !== null">
    <h2>{{ user.displayName }}</h2>
    <p>{{ user.email }}</p>
  </div>
</template>

<script lang="ts">
import ...

export default defineComponent({
  setup: () => {
    const { user } = useUser()
    return { user }
  }
})
</script>

ここで、useUserは何らかの手段を用いてユーザ情報を利用できるようにする関数です。

~/composables/user.ts
export const useUser = () => {
  const user = ref<User | null>(null)
  onMounted(async () => {
    // APIを叩いてユーザ情報を取得するような処理
    user.value = ...
  })
  // 外部で勝手に変更できないようにreadonlyを返却
  return { user: readonly(user) }
}

このように、ロジックを別ファイルに切り出すことができます。 コンポーネントは関数が返却するデータだけに依存する設計とし、関数はデータを適切なタイミングで利用可能にすることだけを契約にするします。 将来的にユーザ情報のデータソースが変わったとしても、コンポーネント側の実装を修正する必要はありません。

例その2: 更新されるデータを扱う

次にユーザアクションによって更新されるデータをComposition APIで扱う場合を考えましょう。 一般的には、掲示板とかチャット機能のようなデータの表示と更新用のフォームが同じページにあるケースがこのパターンに該当します。 このパターンでは、Composition関数から更新用の関数をセットで返却するのが良いのではないでしょうか。

~/composables/user.ts
export const useUser = () => {
  const user = ref<User | null>(null)
  onMounted(async () => {
    // APIを叩いてユーザ情報を取得するような処理
    user.value = ...
  })
  const updateUser = async (input: User) => {
    // APIを叩いてユーザ情報を更新するような処理
    await fetch(......)
    // ユーザ情報にも反映
    user.value = input
  }
  return { 
    user: readonly(user),
    updateUser,
  }
}

コンポーネント側は次のようになるでしょう。 後の説明の都合、更新に成功したらページ遷移をすることにします。

~/components/user.vue
<template>
  <div v-if="user !== null">
    <h2>{{ user.displayName }}</h2>
    <p>{{ user.email }}</p>
    <!-- 雑なフォーム追加 -->
    <form @submit="updateUser">
      <input type="text" v-model="displayName">
      <button type="submit">更新</button>
    </form>
  </div>
</template>

<script lang="ts">
import ...

export default defineComponent({
  setup: () => {
    const { user, updateUser } = useUser()
    const input = ref<string>('')
    const router = useRouter()
    const submit = async () => {
      await updateUser({
        ...user.value,
        displayName: input.value,
      })
      router.push({ name: 'success' })
    }
    return { 
      input,
      user,
      submit,
    }
  }
})
</script>

難題: エラーハンドリングをどうするか

さて、先述の例2ではupdateUserでしれっとPromiseを返却しています。 すなわちここはなんらかのエラーで処理が失敗する可能性があるポイントなので、真面目なアプリケーションではエラーハンドリングが要求されます。

コンポーネント内で扱う

コンポーネント内でエラーを扱う場合はこんなかんじでしょうか。

~/components/user.vue
<script lang="ts">
import ...

export default defineComponent({
  setup: () => {
    const { user, updateUser } = useUser()
    const input = ref<string>('')
    const router = useRouter()
    const submit = async () => {
      try {
        await updateUser({
          ...user.value,
          displayName: input.value,
        })
        router.push({ name: 'success' })
      } catch(error) {
        router.push({ name: 'error' })
      }
    }
    return { 
      input,
      user,
      submit,
    }
  }
})
</script>

消したはずのロジックっぽい何かが出現してしまいました。 実際この規模なら大きな問題ではないのですが、心配性のエンジニアは問題を一般化して考えなければなりません。 複数のデータを扱うことでsubmitの中身がもっとややこしくなったり、エラー処理がもっと複雑になったりする可能性があります。

Composition関数内で扱う場合

updateUserの中でエラーハンドリングをすれば、コンポーネントでエラーハンドリングをする必要はなくなります。

~/composables/user.ts
export const useUser = () => {
  const user = ref<User | null>(null)
  onMounted(async () => {
    // APIを叩いてユーザ情報を取得するような処理
    user.value = ...
  })
  const router = useRouter()
  const updateUser = async (input: User) => {
    try {
      // APIを叩いてユーザ情報を更新するような処理
      await fetch(......)
      // ユーザ情報にも反映
      user.value = input
    } catch(error) {
      // ここではerrorをthrowせずに例外を処理しなければいけない
      router.push({ name: 'error' })
    }
  }
  return { 
    user: readonly(user),
    updateUser,
  }
}

ただし、これはこれでエラーハンドリング方式が関数内に固定されてしまうという別の問題が発生します。 例えば、「ページAでupdateUserに失敗した場合はページ遷移せずにエラーメッセージを表示するだけでよいが、ページBではエラー画面に遷移させてほしい」みたいな要望に対応することができません。 関数を疎結合にしたはずなのに、結局コンポーネントの都合から自由になれていません。

この問題はエラーハンドリングだけでなく「フォームのサブミットに成功したら完了画面に行く」みたいなケースでも同様に発生します。

解決策(仮)

これはまだ試していませんが、以下のようにupdateUserが成功時・失敗時の処理を受け取ればいいかもしれません。

~/composables/user.ts
export const useUser = () => {
  const user = ref<User | null>(null)
  onMounted(async () => {
    // APIを叩いてユーザ情報を取得するような処理
    user.value = ...
  })
  // 引数で事後処理を受け取る
  const updateUser = async (input: User, onSuccess: Function, onError: Function) => {
    try {
      // APIを叩いてユーザ情報を更新するような処理
      await fetch(......)
      // ユーザ情報にも反映
      user.value = input
      onSuccess()
    } catch(error) {
      onError(error)
    }
  }
  return { 
    user: readonly(user),
    updateUser,
  }
}

コンポーネント側はこんなかんじ。

~/components/user.vue
<script lang="ts">
import ...

export default defineComponent({
  setup: () => {
    const { user, updateUser } = useUser()
    const input = ref<string>('')
    const router = useRouter()
    const onSuccess = () => router.push({ name: 'success' })
    const onError = () => router.push({ name: 'error' })
    const submit = () => updateUser({
      ...user.value,
      displayName: input.value,
    }, onSuccess, onError)
    return { 
      input,
      user,
      submit,
    }
  }
})
</script>

これで成功時・失敗時の処理を外から渡せるようになりました。 ただの関数なので、複雑になったときは別のCompositionをつかってまたコンポーネント外に処理を吐き出せばよいので、これまで見てきた問題を解決する手段としてはよさそうです。 それはそれとして、今度はpromiseを扱う関数全てでonSuccess, onErrorを引数に取らないといけなくなって面倒ですね。

まとめ

良き解決策がわからん。 似たようなコードをたくさん並べてボリュームごまかしてごめんなさい。

最新記事

  1. クソお世話になりました
  2. Cloud RunでgRPC streamingができるようになったので動かしてみたりした
  3. 2020年の執筆業まとめ
  4. アーキテクチャを設計する仕事とは
  5. 決済システムを設計するときに忘れてはならないたった1つの大切なこと