Vueのエラーハンドリングとは、コンポーネントの中で起きた例外を、onErrorCaptured(子孫のエラーを受け取るフック)とapp.config.errorHandler(アプリ全体の受け口)で受け止め、画面の表示と記録を自分で決めるしくみです。 Vueが呼び出している関数(描画、ライフサイクルフック、イベントハンドラ、watch)で起きたエラーは、async関数の中であってもVueが受け取ります。一方、setTimeoutのコールバックや、どこにも渡していないPromiseはVueの外で動くため、ブラウザのerrorイベントとunhandledrejectionイベントで受けます。

この記事では、16種類の発生源を1つずつ実際に起こし、4つの受け口のどこに届いたかを開発サーバーと本番ビルドの両方で記録しました。「どのエラーがどこで捕まるか」を表で引けること、そしてreturn falseでの伝播の止め方、エラー境界コンポーネント、app.config.throwUnhandledErrorInProductionの動きまで、貼って動くコードと出力で確認できることがこの記事の特徴です。

確認環境:Ubuntu 24.04、Node.js 22.22.2、Vue 3.5.43、Vite 8.3.2、@vitejs/plugin-vue 6.0.9。ブラウザはPlaywright 1.56.0から起動したChromium 141です。

Vueのエラーハンドリングの早見表

4つの受け口

受け口 書く場所 受け取るもの 主な用途
onErrorCaptured 親や祖先のコンポーネント 子孫コンポーネントで起きた、Vueが呼び出した関数のエラー 画面の一部を代わりの表示に切り替える
app.config.errorHandler main.js アプリ内のどこかで起き、途中で止められなかったエラー 記録・送信をまとめる
window の error イベント main.js Vueの外で起きた同期エラー setTimeoutなどの受け口
window の unhandledrejection イベント main.js だれもcatchしなかったPromise 非同期処理の受け口

発生源ごとの届き先(errorHandlerを設定した場合・Vue 3.5.43で実行)

○は呼ばれた、―は呼ばれなかったことを表します。開発サーバーと本番ビルドで同じ結果でした。

発生源 onErrorCaptured(親) app.config.errorHandler window error unhandledrejection
テンプレートの描画中 ○ ○ ― ―
computedの中(描画で読まれたとき) ○ ○ ― ―
setupの中 ○ ○ ― ―
async setupのawaitの後(Suspenseの中) ○ ○ ― ―
onMounted ○ ○ ― ―
asyncのonMountedのawaitの後 ○ ○ ― ―
カスタムディレクティブのフック ○ ○ ― ―
@click のハンドラ ○ ○ ― ―
asyncの @click ハンドラのawaitの後 ○ ○ ― ―
watchのコールバック ○ ○ ― ―
asyncのwatchコールバックのawaitの後 ○ ○ ― ―
watchEffect ○ ○ ― ―
ハンドラ内で返していないthenの中 ― ― ― ○
setTimeoutのコールバック ― ― ○ ―
どこにも渡していないPromiseのreject ― ― ― ○
addEventListenerで付けたリスナー ― ― ○ ―

覚え方は1つです。「Vueが呼び出した関数」と「その関数が返したPromise」はVueが受け取り、それ以外はブラウザのイベントに届きます。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vueのエラーハンドリング|onErrorCapturedと非同期エラー」

Vueのエラーが届く順番

エラーは、起きたコンポーネントの親から順に上へ伝わります。公式ドキュメントのonErrorCapturedの項には、複数のフックがある場合は下から上の順ですべて呼ばれ、最後にapp.config.errorHandlerへ届くこと、フックがfalseを返すとそこで伝播が止まることが書かれています。

flowchart TD
  A[子コンポーネントでエラー] --> B{Vueが呼び出した関数か}
  B -- はい --> C[親の onErrorCaptured]
  C -- false を返す --> S[ここで終了]
  C -- 何も返さない --> D[さらに上の onErrorCaptured]
  D --> E{app.config.errorHandler があるか}
  E -- ある --> F[errorHandler が受け取る]
  E -- ない --> G[開発時は再スロー / 本番はコンソールに出力]
  B -- いいえ --> H[window の error / unhandledrejection]

受け取れる発生源として公式ドキュメントが挙げているのは、コンポーネントのレンダリング、イベントハンドラー、ライフサイクルフック、setup関数、ウォッチャー、カスタムディレクティブフック、トランジションフックです。

確かめるためのコード(そのまま動く一式)

Viteの最小構成(index.htmlとvite.config.js)を用意し、srcフォルダーに次の4ファイルを置きます。URLのクエリでエラーの発生源を切り替えられるようにしてあります。

mkdir vue-error-demo && cd vue-error-demo
npm init -y
npm install vue
npm install -D vite @vitejs/plugin-vue
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({ plugins: [vue()] })
<!doctype html>
<html lang="ja">
  <head><meta charset="utf-8"><title>vue error</title></head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

開発サーバーは npx vite、本番ビルドは npx vite build のあと npx vite preview で確認しました。

main.js:4つの受け口を用意する

import { createApp } from 'vue'
import App from './App.vue'

const q = new URLSearchParams(location.search)
window.__log = []
const log = (where, err, info) =>
  window.__log.push(`${where}: ${err && err.message ? err.message : err}${info !== undefined ? ` [info=${info}]` : ''}`)
window.__add = log

window.addEventListener('error', (e) => log('window.error', e.error ?? e.message))
window.addEventListener('unhandledrejection', (e) => log('unhandledrejection', e.reason))

const app = createApp(App)
if (q.get('eh') === '1') {
  app.config.errorHandler = (err, instance, info) => log('app.config.errorHandler', err, info)
}
if (q.get('throw') === '1') {
  app.config.throwUnhandledErrorInProduction = true
}
app.mount('#app')

App.vue:親でonErrorCapturedを書く

<script setup>
import { onErrorCaptured } from 'vue'
import Child from './Child.vue'
import AsyncChild from './AsyncChild.vue'

const q = new URLSearchParams(location.search)
const kind = q.get('case')
const stop = q.get('stop') === '1'

onErrorCaptured((err, instance, info) => {
  window.__add('onErrorCaptured(親)', err, info)
  if (stop) return false
})
</script>

<template>
  <h1>case: {{ kind }}</h1>
  <Suspense v-if="kind === 'asyncSetup'">
    <AsyncChild />
    <template #fallback>loading</template>
  </Suspense>
  <Child v-else :kind="kind" />
</template>

Child.vue:エラーを起こす側

<script setup>
import { ref, computed, watch, watchEffect, onMounted, onErrorCaptured } from 'vue'

const props = defineProps({ kind: String })
const k = props.kind
const n = ref(0)
const boom = (label) => { throw new Error(label) }
const wait = (ms) => new Promise((r) => setTimeout(r, ms))

// 自分自身のエラーは自分の onErrorCaptured には届かない(確認用)
onErrorCaptured((err) => { window.__add('onErrorCaptured(自分)', err) })

if (k === 'setup') boom('setup で throw')

const doubled = computed(() => (k === 'computed' ? boom('computed で throw') : n.value * 2))

if (k === 'watch') watch(n, () => boom('watch コールバックで throw'))
if (k === 'watchAsync') watch(n, async () => { await wait(10); boom('async の watch で await 後に throw') })
if (k === 'watchEffect') watchEffect(() => { if (n.value > 0) boom('watchEffect で throw') })

onMounted(() => {
  if (k === 'mounted') boom('onMounted で throw')
  if (k === 'timeout') setTimeout(() => boom('setTimeout の中で throw'), 10)
  if (k === 'native') document.getElementById('native').addEventListener('click', () => boom('addEventListener のリスナーで throw'))
  if (k === 'promise') Promise.reject(new Error('どこにも渡していない Promise が reject'))
})
if (k === 'mountedAsync') onMounted(async () => { await wait(10); boom('async の onMounted で await 後に throw') })

const onClick = () => {
  if (k === 'click') boom('クリックハンドラで throw')
  if (k === 'clickThen') { wait(10).then(() => boom('ハンドラ内の then で throw(return なし)')) }
  n.value++
}
const onClickAsync = async () => { await wait(10); boom('async のクリックハンドラで await 後に throw') }
const vBoom = { mounted() { boom('カスタムディレクティブの mounted で throw') } }
</script>

<template>
  <p v-if="k === 'render'">{{ boom('描画中に throw') }}</p>
  <p v-if="k === 'computed'">{{ doubled }}</p>
  <p v-if="k === 'directive'" v-boom>directive</p>
  <button id="btn" @click="onClick">count {{ n }}</button>
  <button id="btnAsync" @click="onClickAsync">async</button>
  <button id="native">native</button>
</template>

AsyncChild.vue:async setupでエラーになる部品

<script setup>
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
await wait(10)
throw new Error('async setup で await 後に throw')
</script>

<template>
  <p>読み込み完了</p>
</template>

?case=render&eh=1 のようにURLを変えると、発生源と、errorHandlerの有無を切り替えられます。Child.vueの中にもonErrorCapturedを書いてありますが、16通りすべてで一度も呼ばれませんでした。onErrorCapturedが受け取るのは子孫のエラーで、自分自身のエラーは対象になりません。 自分の処理はtry/catchで、子の処理はonErrorCapturedで、と分けて考えます。

発生源ごとの実行結果(開発サーバー・errorHandlerあり)

開発サーバー(npx vite)で、各ケースを開いて window.__log を表示した結果です。

--- render
  onErrorCaptured(親): 描画中に throw [info=render function]
  app.config.errorHandler: 描画中に throw [info=render function]
--- computed
  onErrorCaptured(親): computed で throw [info=render function]
  app.config.errorHandler: computed で throw [info=render function]
--- setup
  onErrorCaptured(親): setup で throw [info=setup function]
  app.config.errorHandler: setup で throw [info=setup function]
--- asyncSetup
  onErrorCaptured(親): async setup で await 後に throw [info=setup function]
  app.config.errorHandler: async setup で await 後に throw [info=setup function]
--- mounted
  onErrorCaptured(親): onMounted で throw [info=mounted hook]
  app.config.errorHandler: onMounted で throw [info=mounted hook]
--- mountedAsync
  onErrorCaptured(親): async の onMounted で await 後に throw [info=mounted hook]
  app.config.errorHandler: async の onMounted で await 後に throw [info=mounted hook]
--- directive
  onErrorCaptured(親): カスタムディレクティブの mounted で throw [info=directive hook]
  app.config.errorHandler: カスタムディレクティブの mounted で throw [info=directive hook]
--- click
  onErrorCaptured(親): クリックハンドラで throw [info=native event handler]
  app.config.errorHandler: クリックハンドラで throw [info=native event handler]
--- clickAsync
  onErrorCaptured(親): async のクリックハンドラで await 後に throw [info=native event handler]
  app.config.errorHandler: async のクリックハンドラで await 後に throw [info=native event handler]
--- clickThen
  unhandledrejection: ハンドラ内の then で throw(return なし)
--- watch
  onErrorCaptured(親): watch コールバックで throw [info=watcher callback]
  app.config.errorHandler: watch コールバックで throw [info=watcher callback]
--- watchAsync
  onErrorCaptured(親): async の watch で await 後に throw [info=watcher callback]
  app.config.errorHandler: async の watch で await 後に throw [info=watcher callback]
--- watchEffect
  onErrorCaptured(親): watchEffect で throw [info=watcher callback]
  app.config.errorHandler: watchEffect で throw [info=watcher callback]
--- timeout
  window.error: setTimeout の中で throw
--- promise
  unhandledrejection: どこにも渡していない Promise が reject
--- native
  window.error: addEventListener のリスナーで throw

読み取れることを3つにまとめます。

  • computedの中のエラーは「描画のエラー」として届きます。 computedは読まれたときに計算されるので、テンプレートから読まれれば info は render function になります。
  • async関数でも、Vueに渡した関数ならawaitの後のエラーまで届きます。 onMounted、@click、watchに渡したasync関数は、返したPromiseをVueが見ているためです。
  • Promiseを「返さない」と、Vueからは見えなくなります。 clickThenのケースは、ハンドラの中でthenをつないだだけで戻り値にしていないため、unhandledrejectionだけに届きました。

描画のエラーのあと、画面はどうなるか

描画中にエラーになったケースでは、親の見出し(case: render)だけが表示され、Child.vueの領域には何も表示されませんでした。setupでエラーになったケースも、本番ビルドでは同じく何も表示されません(開発サーバーでは、setupの値が入らないままボタンの枠だけが表示されました)。クリックハンドラやwatchでエラーになったケースでは、ボタンなどの表示はそのまま残ります。描画やsetupのエラーは「その部品が正しく表示されない」、イベントやwatchのエラーは「その処理だけが止まる」と整理できます。前者は、後で紹介するエラー境界で代わりの表示を出すと利用者に状況が伝わります。

非同期エラーが届く書き方・届かない書き方

非同期処理で大切なのは、Promiseを切らさずにVueへ返すことです。同じ処理を3通りの書き方で比べます。

書き方 コード 届き先(実行結果)
async関数を渡す const onClickAsync = async () => { await wait(10); boom() } onErrorCaptured → errorHandler
thenをつないで返さない wait(10).then(() => boom()) unhandledrejection のみ
setTimeoutに包む setTimeout(() => boom(), 10) window error のみ

2行目の形は、先頭に return を付けるか、async/awaitに書き直すとVueに届くようになります。表の1行目がその形です。

setTimeoutやaddEventListenerに渡す関数は、Vueではなくブラウザが呼び出します。この中でエラーを画面に反映したいときは、関数の中でtry/catchを書き、エラーをrefに入れます。

予想できるエラーはtry/catchで状態にする

通信のように、うまくいかないことが最初から想定できる処理は、グローバルの受け口に任せず、その場で受け取って画面の状態にします。次のTaskList.vueは、1回目の取得だけエラーになり、再読み込みで表示できる例です。

<script setup>
import { ref, onMounted } from 'vue'

const tasks = ref([])
const loading = ref(false)
const error = ref(null)
let calls = 0

// 通信のかわり:1回目だけ reject する
function fetchTasks() {
  calls++
  return new Promise((resolve, reject) =>
    setTimeout(() => (calls === 1 ? reject(new Error('503 Service Unavailable')) : resolve(['見積もり', '発注'])), 20))
}

async function load() {
  loading.value = true
  error.value = null
  try {
    tasks.value = await fetchTasks()
  } catch (e) {
    error.value = e            // 予想できるエラーは画面の状態にする
  } finally {
    loading.value = false
  }
}
onMounted(load)
</script>

<template>
  <p v-if="loading" id="state">読み込み中…</p>
  <p v-else-if="error" id="state">取得できませんでした({{ error.message }})<button id="reload" @click="load">再読み込み</button></p>
  <ul v-else id="state"><li v-for="t in tasks" :key="t">{{ t }}</li></ul>
</template>

読み込み中・エラー・成功の3つの状態を、loadingとerrorの2つのrefで表しています。finallyでloadingを戻しておくと、エラーのときも「読み込み中」の表示が残りません。実行結果は、次のエラー境界の項でまとめて示します。

エラーの種類 例 受け取り方
予想できるエラー 通信が通らない、入力値が合わない、権限がない その場でtry/catchし、refに入れて画面に出す
予想していないエラー undefinedのプロパティを読んだ、想定外のデータ形式 onErrorCapturedとerrorHandlerに任せ、記録する

return falseで伝播を止める

onErrorCapturedのコールバックがfalseを返すと、それより上のonErrorCapturedにもapp.config.errorHandlerにも届かなくなります。App.vueの if (stop) return false を有効にして(&stop=1)、errorHandlerを設定したまま実行しました。

[stop=1 render]     ["onErrorCaptured(親): 描画中に throw [info=render function]"]
[stop=1 click]      ["onErrorCaptured(親): クリックハンドラで throw [info=native event handler]"]
[stop=1 clickAsync] ["onErrorCaptured(親): async のクリックハンドラで await 後に throw [info=native event handler]"]

3ケースとも、app.config.errorHandlerの行が出なくなりました。コンソールへのエラー出力も0件です。return falseは「このエラーはここで対応し終えた」という合図です。止めた場所で記録もしておかないと、どこにも残らなくなる点だけ気を付けます。

戻り値 上のonErrorCaptured app.config.errorHandler 向いている場面
何も返さない 呼ばれる 呼ばれる 表示だけ切り替えて、記録は全体の受け口に任せる
false 呼ばれない 呼ばれない その場で表示も記録も済ませる

エラー境界コンポーネントを作る

エラー境界とは、スロットの中で起きたエラーを受け取り、代わりの表示に切り替える部品のことです。onErrorCapturedとスロットを組み合わせると、30行ほどで作れます。

ErrorBoundary.vue

<script setup>
import { ref, onErrorCaptured } from 'vue'

const emit = defineEmits(['error'])
const error = ref(null)
const tryCount = ref(0)

onErrorCaptured((err, instance, info) => {
  error.value = err
  emit('error', { err, info })   // ログ送信などは親に任せる
  return false                   // ここで伝播を止める
})

function retry() {
  error.value = null
  tryCount.value++               // key を変えて中身を作り直す
}
</script>

<template>
  <div v-if="error" class="error-box" role="alert">
    <p>表示中に問題が起きました:{{ error.message }}</p>
    <button id="retry" @click="retry">もう一度試す</button>
  </div>
  <slot v-else :key="tryCount" />
</template>

ポイントは3つです。

  • エラーをrefに入れ、v-ifで代わりの表示に切り替える。 公式ドキュメントには、エラーを起こした内容をそのまま描画し直すと無限ループになるという注意があります。エラーの間はスロットを描画しない作りにしておくと、この注意を自然に満たせます。
  • emitでエラーを親に知らせる。 記録の方法を部品の外で決められます。
  • 再試行ではkeyを変える。 keyが変わるとスロットの中身が新しく作り直され、setupからやり直されます。

使う側:1回目だけエラーになる部品を包む

<script setup>
// 1回目の描画だけエラーになる部品(再試行の確認用)
window.__tries = (window.__tries ?? 0) + 1
const tries = window.__tries
if (tries === 1) throw new Error('1回目は読み込めませんでした')
</script>

<template>
  <p id="ok">{{ tries }}回目で表示できました</p>
</template>
<script setup>
import ErrorBoundary from './ErrorBoundary.vue'
import Flaky from './Flaky.vue'
import TaskList from './TaskList.vue'

const report = ({ err, info }) => window.__add('ErrorBoundary', err, info)
</script>

<template>
  <h1>ダッシュボード</h1>
  <ErrorBoundary @error="report">
    <Flaky />
  </ErrorBoundary>
  <TaskList />
  <p id="other">ほかの領域はそのまま表示されます</p>
</template>

画面の文字を順に取り出した結果です(本番ビルドで実行。開発サーバーでも同じ表示でした)。

[0ms]    ダッシュボード / 表示中に問題が起きました:1回目は読み込めませんでした / もう一度試す / 読み込み中… / ほかの領域はそのまま表示されます
[100ms]  ダッシュボード / 表示中に問題が起きました:1回目は読み込めませんでした / もう一度試す / 取得できませんでした(503 Service Unavailable)再読み込み / ほかの領域はそのまま表示されます
[「もう一度試す」と「再読み込み」を押した後]
         ダッシュボード / 2回目で表示できました / 見積もり / 発注 / ほかの領域はそのまま表示されます

Flakyのエラーは境界の中だけで代わりの表示になり、見出しやほかの領域は表示されたままです。TaskListはtry/catchで自分の状態にしているため、境界を使わずに案内と再読み込みボタンを出せています。errorHandlerを設定していましたが、境界がfalseを返したので呼ばれず、記録は境界のemit経由の1件だけでした。

エラー境界を置く単位

置く場所 代わりの表示になる範囲 向いている場面
ルートの直下に1つ 画面全体 まず最低限の受け皿を用意する
ページ(ルーティングの単位)ごと そのページ ヘッダーやメニューは残したい
ウィジェットごと そのカードだけ 一覧やダッシュボードで、1つの部品の不調をほかに広げない

イベントハンドラやwatchのエラーも境界に届く点には注意が要ります。ボタンを押したときのエラーで境界ごと代わりの表示に切り替わると、表示できていた内容まで隠れてしまいます。境界は描画とsetupの受け皿、ボタン操作や通信はその場のtry/catchと役割を分けておくと、表示が安定します。

errorHandlerを設定しないとどうなるか(開発と本番の違い)

app.config.errorHandlerを設定しない場合の動きは、開発と本番で変わります。公式ドキュメントには、既定では開発時に再スローし、本番ではコンソールへの出力だけにすること、app.config.throwUnhandledErrorInProductionで本番でも投げるように変えられることが書かれています。実際に &eh=0 で実行した結果を表にまとめます。

発生源 開発サーバー 本番ビルド(既定) 本番ビルド+throwUnhandledErrorInProduction
描画・setup・onMounted・ディレクティブ・@click(同期) window error に届く console.error のみ window error に届く
async setup・asyncのonMounted・asyncの @click・asyncのwatch unhandledrejection に届く console.error のみ unhandledrejection に届く
watch・watchEffect(値の変更をきっかけに実行) unhandledrejection に届く console.error のみ unhandledrejection に届く

どの場合も、親のonErrorCapturedは先に呼ばれています。開発サーバーで描画中にエラーを起こしたときの記録は次のとおりです。

--- render(開発・errorHandlerなし)
  onErrorCaptured(親): 描画中に throw [info=render function]
  window.error: 描画中に throw
  (console) warning: [Vue warn]: Unhandled error during execution of render function
--- render(本番・errorHandlerなし)
  onErrorCaptured(親): 描画中に throw [info=https://vuejs.org/error-reference/#runtime-1]
  (console) error: Error: 描画中に throw

watchのエラーで画面の更新が止まる例

開発サーバーでerrorHandlerを設定せずに、同期のwatchコールバックでエラーを起こすと、次のようになりました。

--- watch(開発・errorHandlerなし)
  onErrorCaptured(親): watch コールバックで throw [info=watcher callback]
  onErrorCaptured(親): watch コールバックで throw [info=component update]
  unhandledrejection: watch コールバックで throw
  画面: count 0 のまま

ボタンを押してnを1に増やしたのに、画面は「count 0」のままです。watchのコールバックは画面の更新と同じ流れ(スケジューラー)の中で実行されるため、再スローされたエラーがその回の更新を止めました。errorHandlerを設定した場合と、本番ビルドの既定では「count 1」に更新されています。errorHandlerを設定しておくことには、記録だけでなく、後続の画面更新を続けられるという利点もあります。

app.config.throwUnhandledErrorInProduction

本番ビルドの既定では、受け口のないエラーはconsole.errorに出るだけで、windowのerrorイベントには届きません。エラー監視のサービスがwindowのイベントだけを見ている構成では、Vue内のエラーが記録に残らないことになります。その場合に使うのがこの設定です。

const app = createApp(App)
app.config.throwUnhandledErrorInProduction = true
app.mount('#app')

本番ビルドで &eh=0&throw=1 を付けて実行した結果です。

[throw=1 render]     onErrorCaptured(親) → window.error: 描画中に throw
[throw=1 mounted]    onErrorCaptured(親) → window.error: onMounted で throw
[throw=1 click]      onErrorCaptured(親) → window.error: クリックハンドラで throw
[throw=1 clickAsync] onErrorCaptured(親) → unhandledrejection: async のクリックハンドラで await 後に throw
[throw=1 watch]      onErrorCaptured(親) ×2 → unhandledrejection: watch コールバックで throw(画面は count 0 のまま)

本番ビルドでも開発サーバーと同じ届き先になりました。watchのケースで画面の更新が止まる点も同じです。

構成 おすすめの設定
自前で記録・送信する app.config.errorHandlerを設定する(この設定は不要)
windowのイベントだけを見る監視の仕組みを使う throwUnhandledErrorInProductionをtrueにする、またはerrorHandlerから監視側へ渡す
どちらも用意していない まずerrorHandlerを設定する

本番ビルドのinfoは短いコードになる

第3引数のinfoは、開発時は「render function」のような文字列ですが、本番ビルドではURLの形をした短いコードになります。実行結果に出てきたものと、Vue 3.5.43の開発ビルドが持っている対応(ErrorTypeStrings)を照らし合わせた表です。

本番のinfoの末尾 開発時のinfo 発生源
#runtime-0 setup function setup、async setup
#runtime-1 render function テンプレートの描画、描画中に読まれたcomputed
#runtime-2 watcher getter watchの第1引数の関数
#runtime-3 watcher callback watchのコールバック、watchEffect
#runtime-4 watcher cleanup function watchの後始末の関数
#runtime-5 native event handler @click などDOMイベントのハンドラ
#runtime-6 component event handler emitで呼ばれるハンドラ
#runtime-8 directive hook カスタムディレクティブのフック
#runtime-9 transition hook トランジションのフック
#runtime-13 async component loader 非同期コンポーネントの読み込み
#runtime-15 component update コンポーネントの更新
#runtime-m mounted hook onMounted
#runtime-bm / #runtime-u / #runtime-um beforeMount hook / updated / unmounted hook 各ライフサイクルフック

infoの文字列で処理を分けるコードは、開発と本番で値が変わるため、本番のコードも合わせて判定する必要があります。分岐に使うよりも、記録にそのまま残して調査の手がかりにする使い方が向いています。

記録を1か所に集めるmain.jsのひな形

3つの受け口から同じ関数へ流すと、送信先や形式を1か所で管理できます。ローカルに受信用のサーバーを立て、navigator.sendBeaconで送る形で動かしました。

import { createApp } from 'vue'
import App from './App.vue'

const ENDPOINT = 'http://localhost:8942/errors'

// 送信先は1か所にまとめる
function report(source, err, extra = {}) {
  const body = JSON.stringify({
    source,
    message: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack?.split('\n')[1]?.trim() : undefined,
    url: location.pathname + location.search,
    ...extra,
  })
  navigator.sendBeacon(ENDPOINT, body)
}

const app = createApp(App)

// 1. Vue が管理している場所のエラー
app.config.errorHandler = (err, instance, info) => {
  report('vue', err, { info, component: instance?.$options.__name })
}
// 2. Vue の外で起きた同期エラー(setTimeout、addEventListener など)
window.addEventListener('error', (e) => report('window.error', e.error ?? e.message))
// 3. だれも catch しなかった Promise
window.addEventListener('unhandledrejection', (e) => report('unhandledrejection', e.reason))

app.mount('#app')

受信側は、届いた本文を表示するだけのNode.jsのサーバーです。

import http from 'node:http'
http.createServer((req, res) => {
  let b = ''
  req.on('data', (c) => (b += c))
  req.on('end', () => { if (b) console.log(b); res.writeHead(204, { 'Access-Control-Allow-Origin': '*' }); res.end() })
}).listen(8942)

render、clickAsync、timeout、promiseの4ケースを開いたときに、サーバーが受け取った内容です(開発サーバーで実行)。

{"source":"vue","message":"描画中に throw","stack":"at Proxy.boom (http://localhost:8940/src/Child.vue?t=1791038474176:13:33)","url":"/report.html?case=render","info":"render function","component":"Child"}
{"source":"vue","message":"async のクリックハンドラで await 後に throw","stack":"at boom (http://localhost:8940/src/Child.vue?t=1791038474176:13:33)","url":"/report.html?case=clickAsync","info":"native event handler","component":"Child"}
{"source":"window.error","message":"setTimeout の中で throw","stack":"at boom (http://localhost:8940/src/Child.vue?t=1791038474176:13:33)","url":"/report.html?case=timeout"}
{"source":"unhandledrejection","message":"どこにも渡していない Promise が reject","stack":"at http://localhost:8940/src/Child.vue?t=1791038474176:31:39","url":"/report.html?case=promise"}

Vue経由のエラーには、infoと、エラーが起きたコンポーネントの名前(Child)が付きました。windowの2つのイベントにはコンポーネントの情報がないため、スタックの1行目とURLが手がかりになります。

記録に入れる項目 取り方 役立つ場面
受け口の種類 自分で付ける(vue / window.error / unhandledrejection) Vueの中か外かの切り分け
メッセージ・スタック err.message、err.stack 原因の行の特定
info errorHandlerの第3引数 描画・イベント・watchなどの区別
コンポーネント名 instance?.$options.__name どの部品かの特定
画面のURL location.pathname + location.search 再現手順の把握

unhandledrejectionのreasonは、Errorとは限りません。Promise.reject('文字列') のように値がそのまま渡されることもあるため、ひな形ではinstanceofで確かめてから取り出しています。

よくあるつまずきと直し方

症状 原因 直し方
自分のコンポーネントのエラーがonErrorCapturedに来ない 対象は子孫だけ 自分の処理はtry/catch、または1つ上の親にフックを書く
errorHandlerにエラーが来ない 途中のonErrorCapturedがfalseを返している 止めた場所で記録する、または何も返さない
setTimeoutの中のエラーが来ない Vueの外で呼ばれている 中でtry/catchする。全体はwindowのerrorで受ける
thenの中のエラーが来ない Promiseを返していない returnを付ける、またはasync/awaitにする
本番でwindowのerrorに届かない 既定はconsole.errorだけ errorHandlerを設定する、またはthrowUnhandledErrorInProductionをtrueにする
エラー後に「読み込み中」が消えない loadingを戻していない finallyでloadingをfalseにする
エラー境界が何度もエラーになる エラーの内容をそのまま描画し直している v-ifで代わりの表示に切り替え、再試行はkeyを変える
async setupの部品が表示されない Suspenseで包んでいない 親でSuspenseの中に置く

最後の行は、今回の確認中にも出会いました。script setupのトップレベルでawaitを書くとsetupがasyncになり、Suspenseの外で使うと次の警告が出て、その部品は表示されません。

[Vue warn]: Component <Child>: setup function returned a promise, but no <Suspense> boundary was found in the parent component tree.

どこで受けるかの判断表

やりたいこと 使うもの
通信のエラーを画面に案内として出す その場のtry/catch+ref
部品が描画できないとき、そこだけ代わりの表示にする エラー境界(onErrorCaptured+return false)
アプリ内のエラーをすべて記録する app.config.errorHandler
タイマーや外部ライブラリのコールバックのエラーを記録する windowのerror
catchし忘れたPromiseを記録する windowのunhandledrejection
監視の仕組みがwindowだけを見ている throwUnhandledErrorInProduction

Options APIで書く場合

Options APIでは、onErrorCapturedのかわりにerrorCapturedオプションを使います。引数と、falseを返すと伝播が止まる動きは同じです。

<script>
import Child from './Child.vue'

export default {
  components: { Child },
  errorCaptured(err, instance, info) {
    console.log('errorCaptured:', err.message, info)
    return false
  },
}
</script>

<template>
  <Child kind="render" />
</template>

これをルートにしてerrorHandlerを設定したまま開くと、コンソールに「errorCaptured: 描画中に throw render function」と出力され、errorHandlerは呼ばれませんでした。

よくある質問

Q. onErrorCapturedとapp.config.errorHandlerは、どちらを使えばよいですか? A. 役割が違うので両方使います。onErrorCapturedは画面の一部を代わりの表示に切り替えるため、app.config.errorHandlerはアプリ全体の記録をまとめるためのものです。まずerrorHandlerを1つ設定し、表示を守りたい領域にエラー境界を足していく順番が取り組みやすいです。

Q. async関数の中のエラーはVueで受け取れますか? A. Vueに渡した関数であれば受け取れます。今回の実行では、asyncのonMounted、@click、watch、async setupのいずれも、awaitの後のエラーがonErrorCapturedとerrorHandlerに届きました。ハンドラの中でthenをつないだだけで返していないPromiseと、setTimeoutのコールバックはVueには届かず、windowのイベントで受け取りました。

Q. return falseを書くと何が変わりますか? A. そのエラーが上のonErrorCapturedとapp.config.errorHandlerに届かなくなります。実行結果でも、falseを返したケースではerrorHandlerの行とコンソールのエラー出力がなくなりました。止めた場所で記録も行うようにしておくと安心です。

Q. 本番でエラーがコンソールにしか出ません。 A. 本番ビルドの既定の動きです。app.config.errorHandlerを設定すればそこに届きます。windowのerrorやunhandledrejectionで受けたい場合は、app.config.throwUnhandledErrorInProductionをtrueにします。

まとめ

  • Vueが呼び出した関数(描画・setup・ライフサイクル・イベント・watch・ディレクティブ)のエラーは、onErrorCapturedとapp.config.errorHandlerに届く
  • async関数でも、Vueに渡した関数ならawaitの後のエラーまで届く。 Promiseを返さないthenと、setTimeout・addEventListenerはwindowのイベントで受ける
  • onErrorCapturedが受けるのは子孫のエラー。 自分の処理はtry/catchで状態にする
  • return falseで伝播が止まる。 エラー境界は「refに入れる・v-ifで切り替える・keyで作り直す」の3点で作れる
  • errorHandlerがないと、開発では再スロー、本番ではconsole.errorだけ。 throwUnhandledErrorInProductionで本番でもwindowに届く
  • 本番ビルドのinfoは短いコード。 分岐に使うより、記録に残して調査に使う
  • 記録は1つの関数にまとめ、Vue・window error・unhandledrejectionの3つから流す

参考・出典

  • Vue.js 公式ドキュメント「コンポジション API: ライフサイクルフック」onErrorCaptured(https://ja.vuejs.org/api/composition-api-lifecycle)
  • Vue.js 公式ドキュメント「アプリケーション API」app.config.errorHandler、app.config.throwUnhandledErrorInProduction(https://ja.vuejs.org/api/application 、https://vuejs.org/api/application.html)
  • Vue.js 公式ドキュメント「Suspense」(https://ja.vuejs.org/guide/built-ins/suspense)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Vueのエラーハンドリング|onErrorCapturedと非同期エラー」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?