【Vue.js】ページ遷移時にローディング画面を表示する方法|SPAでのUX向上

【Vue.js】ページ遷移時にローディング画面を表示する方法|SPAでのUX向上 Vue.js

SPAでは、ページ遷移が画面の書き換えで済むため、一般には速く感じられます。ただし、遷移先の画面のコードをはじめて読み込む場合や、データの取得を待つ場合は、一瞬、何も起きない時間ができます。その間、ユーザーに「動いている」と伝えるのが、ローディング表示です。この記事では、Vue Routerのガードを使った、遷移中のローディング表示を、タイマーの管理や例外の扱いまで含めて整理します。ガードの基本はルーティングの動的管理をご覧ください。

スポンサーリンク

よくある書き方の問題点

ルーターのbeforeEachで表示をオンにして、afterEachで少し待ってからオフにする、という書き方をよく見かけます。

Vue:よくある書き方(問題あり)
<!-- App.vue(よくある書き方) -->
<script>
export default {
  data() {
    return { isLoading: false }
  },
  created() {
    this.$router.beforeEach((to, from, next) => {
      this.isLoading = true            // どんな遷移でも、すぐ表示する
      next()
    })
    this.$router.afterEach(() => {
      setTimeout(() => {
        this.isLoading = false         // 500ms 後に消す(タイマーを管理していない)
      }, 500)
    })
  }
}
</script>
  • 速い遷移も、500ms遅くなる:すぐ表示して、遷移の後にさらに500ms残すので、一瞬で終わる遷移でも、画面がちらつき、遅く感じます。
  • 連続した遷移で、早く消える:前の遷移のsetTimeoutが残るため、次の遷移の途中で、ローディングが消えてしまいます。
  • 例外で出たままになる:ガードで例外が起きると、afterEachが呼ばれず、ローディングが消えないことがあります。
  • データの取得を待たない:afterEachは、遷移が確定した後に呼ばれます。遷移先のコンポーネントが、あとでAPIを呼ぶ場合、そのデータを待たずに、ローディングが消えます。
  • next()は非推奨:ガードは、戻り値で制御する書き方が、現在の推奨です。

設計:表示を始める時間と、残す時間を決める

ローディングは、出しすぎても逆効果です。次のルールにします。

  • 遷移が200ms以上かかったときだけ、表示を始める。速い遷移では、出さない。
  • 一度表示したら、最低300msは残す。一瞬だけ出て消える、ちらつきを防ぐ。
  • 遷移が連続したら、前のタイマーを取り消して、状態を引き継ぐ。

この状態を持つ処理を、Composableにまとめます。状態を関数の外に置くことで、ルーターの設定ファイルと、表示するコンポーネントで、同じ状態を共有できます。Composableの基本はComposable関数でロジックを共通化する方法をご覧ください。

JavaScript:composables/useRouteLoading.js
// composables/useRouteLoading.js
import { ref } from 'vue'

const SHOW_DELAY = 200   // これだけ時間がかかったときだけ、表示を始める(ミリ秒)
const MIN_SHOW = 300     // 表示したら、最低これだけは残す(ちらつき防止)

// 関数の外に置き、ルーターとコンポーネントで同じ状態を共有する
const isLoading = ref(false)
let showTimer = null
let hideTimer = null
let shownAt = 0

function start() {
  clearTimeout(hideTimer)                       // 直前の「消す予約」を取り消す
  if (isLoading.value || showTimer !== null) return

  showTimer = setTimeout(() => {
    showTimer = null
    isLoading.value = true
    shownAt = Date.now()
  }, SHOW_DELAY)
}

function finish() {
  // まだ表示前なら、予約を取り消す(速い遷移では、ローディングを出さない)
  if (showTimer !== null) {
    clearTimeout(showTimer)
    showTimer = null
    return
  }
  if (!isLoading.value) return

  // 表示してから MIN_SHOW 経つまで待って、消す
  const wait = Math.max(0, MIN_SHOW - (Date.now() - shownAt))
  hideTimer = setTimeout(() => {
    isLoading.value = false
  }, wait)
}

export function useRouteLoading() {
  return { isLoading, start, finish }
}

ルーターでガードに登録する

ガードは、ルーターの設定ファイルで登録します。コンポーネントのcreatedの中で登録する書き方は、コンポーネントが作り直されるたびに登録され、取り消されないため、避けます。

  • beforeEach:遷移の開始。何も返さなければ、遷移は許可されます。
  • afterEach:遷移の終了。失敗した場合は、第3引数failureに入ります。
  • onError:ガードの中で例外が起きた場合。afterEachが呼ばれないことがあるため、ここでも終了の処理をします。

Vue Routerのナビゲーション失敗には、中止(aborted:ガードがfalseを返した)、打ち切り(cancelled:新しい遷移が始まって、前のものが完了前に打ち切られた)、重複(duplicated:すでにその場所にいる)があります。このうちcancelledのときは、すぐあとに新しい遷移がfinish()を呼ぶので、ここでは消さないようにしています。判定にはisNavigationFailureを使います。

JavaScript:router/index.js
// router/index.js
import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureType } from 'vue-router'
import { useRouteLoading } from '@/composables/useRouteLoading'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    // 遅延読み込み:初回に画面のコードをダウンロードするため、遷移に時間がかかる
    { path: '/', component: () => import('@/views/Home.vue') },
    { path: '/reports', component: () => import('@/views/Reports.vue') }
  ]
})

const { start, finish } = useRouteLoading()

// 遷移の開始。何も返さない(undefined)ので、遷移は許可される
router.beforeEach(() => {
  start()
})

// 遷移の終了。失敗した場合は、第3引数 failure に入る
router.afterEach((to, from, failure) => {
  // cancelled:新しい遷移が始まって、前の遷移が打ち切られた。
  // 新しい遷移がこのあと finish() を呼ぶので、ここでは消さない
  if (isNavigationFailure(failure, NavigationFailureType.cancelled)) return
  finish()
})

// ガードの中で例外が起きた場合。afterEach は呼ばれないので、ここで戻す
router.onError(() => {
  finish()
})

表示するコンポーネント:上部のバー

全画面を白いオーバーレイで覆う方法は、遷移のたびに使うには、重い表示です。画面の上部に細いバーを出す方法なら、邪魔にならず、操作も止めません。読み上げソフトの利用者には、常にDOMにあるrole="status"の領域で、「読み込み中です」と伝えます。動きを減らす設定の人には、アニメーションを止めます。

Vue:RouteLoadingBar.vue
<!-- components/RouteLoadingBar.vue -->
<script setup>
import { useRouteLoading } from '@/composables/useRouteLoading'

const { isLoading } = useRouteLoading()
</script>

<template>
  <!-- 画面の上部に出る、細いバー(見た目だけ。読み上げからは外す) -->
  <Transition name="fade">
    <div v-if="isLoading" class="loading-bar" aria-hidden="true"></div>
  </Transition>

  <!-- 読み上げ用。この要素は常に DOM にあり、文言だけが変わる -->
  <p class="visually-hidden" role="status">{{ isLoading ? '読み込み中です' : '' }}</p>
</template>

<style scoped>
.loading-bar {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  width: 100%;
  height: 3px;
  background: #42b983;
  transform-origin: left;
  animation: loading 1.2s ease-in-out infinite;
}
@keyframes loading {
  0% { transform: scaleX(0); }
  70% { transform: scaleX(0.8); }
  100% { transform: scaleX(1); opacity: 0.3; }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }

/* 画面には見せず、読み上げソフトにだけ伝える */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

/* 「動きを減らす」設定の人には、アニメーションを止める */
@media (prefers-reduced-motion: reduce) {
  .loading-bar { animation: none; }
}
</style>
Vue:App.vue
<!-- App.vue -->
<script setup>
import RouteLoadingBar from '@/components/RouteLoadingBar.vue'
</script>

<template>
  <RouteLoadingBar />
  <RouterView />
</template>

データの取得も待ちたいとき:beforeResolve

遷移先の画面に必要なデータを、遷移の前に取得したい場合は、router.beforeResolveが使えます。公式も、データを取得する場所として適していると説明しています。遷移が確定する直前に呼ばれ、非同期の処理をawaitすれば、終わるまで遷移が保留されます。その間、ローディングが表示されます。

JavaScript:beforeResolveでデータを取得する
// 遷移の「前に」データを取得したいルートだけ、meta に取得関数を持たせる
{
  path: '/reports',
  component: () => import('@/views/Reports.vue'),
  meta: { preload: () => fetch('/api/reports').then((r) => r.json()) }
}

// beforeResolve:遷移が確定する直前に、実行される
router.beforeResolve(async (to) => {
  if (to.meta.preload) {
    await to.meta.preload()        // 終わるまで、遷移は保留される(その間、ローディングが出る)
  }
})

この方法は、データが届くまで、前の画面に留まります。画面は先に切り替えて、画面の中で、読み込み中の表示(スケルトンなど)を出す設計もあります。使い分けは、遷移の感覚で選んでください。画面の中の状態の持ち方は非同期APIの状態管理パターン、遅延読み込みと遷移のルート設計はVue Routerで多階層のルート定義を整理する方法が参考になります。

関連記事

よくある質問(FAQ)

Qローディングが、画面を開いたのに消えません。
Aガードの中で例外が起きて、afterEachが呼ばれていない可能性があります。router.onErrorでも、終了の処理を呼んでください。
Q遷移を連続して行うと、ローディングが途中で消えます。
A前の遷移のタイマーが残っているのが原因です。開始のたびに、前の「消す予約」を取り消し、打ち切られた遷移(cancelled)では、消す処理をしないようにしてください。
Qローディングを、データの取得が終わるまで出すには?
AafterEachは、データの取得を待ちません。遷移の前に取得するなら、router.beforeResolveでawaitします。画面を先に切り替えるなら、画面の中の読み込み状態で表示してください。
Qローディングを、すぐに表示してはいけませんか?
A速い遷移でも出てしまい、かえって遅く、ちらついて見えます。一定時間(200ms程度)かかったときだけ表示し、表示したら、最低限の時間(300ms程度)は残す設計がおすすめです。

まとめ

遷移中のローディングは、ルーターのガードで開始と終了を扱い、状態をComposableで共有して作ります。200ms以上かかったときだけ表示し、表示したら最低300ms残す。遷移の連続や打ち切り、例外のときも、確実に消す。データを待つならbeforeResolveを使う。読み上げと、動きを減らす設定にも配慮して、邪魔にならない表示にしてください。