SPAでは、ページ遷移が画面の書き換えで済むため、一般には速く感じられます。ただし、遷移先の画面のコードをはじめて読み込む場合や、データの取得を待つ場合は、一瞬、何も起きない時間ができます。その間、ユーザーに「動いている」と伝えるのが、ローディング表示です。この記事では、Vue Routerのガードを使った、遷移中のローディング表示を、タイマーの管理や例外の扱いまで含めて整理します。ガードの基本はルーティングの動的管理をご覧ください。
よくある書き方の問題点
ルーターのbeforeEachで表示をオンにして、afterEachで少し待ってからオフにする、という書き方をよく見かけます。
<!-- 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関数でロジックを共通化する方法をご覧ください。
// 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を使います。
// 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"の領域で、「読み込み中です」と伝えます。動きを減らす設定の人には、アニメーションを止めます。
<!-- 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>
<!-- App.vue --> <script setup> import RouteLoadingBar from '@/components/RouteLoadingBar.vue' </script> <template> <RouteLoadingBar /> <RouterView /> </template>
データの取得も待ちたいとき:beforeResolve
遷移先の画面に必要なデータを、遷移の前に取得したい場合は、router.beforeResolveが使えます。公式も、データを取得する場所として適していると説明しています。遷移が確定する直前に呼ばれ、非同期の処理をawaitすれば、終わるまで遷移が保留されます。その間、ローディングが表示されます。
// 遷移の「前に」データを取得したいルートだけ、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で多階層のルート定義を整理する方法が参考になります。
関連記事
- 表示のアニメーションはtransitionコンポーネントを使ってアニメーションを実装する方法
- 取得の失敗の通知はトースト通知を作る方法
よくある質問(FAQ)
afterEachが呼ばれていない可能性があります。router.onErrorでも、終了の処理を呼んでください。cancelled)では、消す処理をしないようにしてください。afterEachは、データの取得を待ちません。遷移の前に取得するなら、router.beforeResolveでawaitします。画面を先に切り替えるなら、画面の中の読み込み状態で表示してください。まとめ
遷移中のローディングは、ルーターのガードで開始と終了を扱い、状態をComposableで共有して作ります。200ms以上かかったときだけ表示し、表示したら最低300ms残す。遷移の連続や打ち切り、例外のときも、確実に消す。データを待つならbeforeResolveを使う。読み上げと、動きを減らす設定にも配慮して、邪魔にならない表示にしてください。

