Vue.jsでのDryなエラーハンドリングと排他処理


株式会社SKILLでエンジニアをしている田坂です。
今回は弊社で開発している<a href="https://sbank.jp/">職歴BANK</a>という職歴サービスで
使用しているVuejsの実装の一部を紹介したいと思います。

# 1. 概要
- 既存の例外ハンドリング手法をまとめてProsConsを言及
- 例外ハンドリングをDryかつ、既存コードのロジックの変更をできるだけ抑えて実現した
- ついでにUI操作における排他処理機能を実装した

## 1.1 アプリケーションのシチュエーション


左の様な画面でユーザがフォーム送信などの操作をした時に、エラー画面に遷移させずその画面のままで
何が起きて何をすべきかを例えば右の画面の様にユーザに伝えたい状況について考えていきます。

なぜ画面そのままか → 画面が変わってしまうと、フォームの内容が消えるので、UXが悪くなりこれを回避するためです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29037/6ed053e2-e0af-b111-a403-7a3f1ee694e7.png" height="300px"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29037/9f5a061e-dc21-7e60-9224-efb233ef2910.png" height="300px">


コードでいうと以下の様なactionを呼んで、`Store.create(this.form)` で例外が発生するとします。

```ts
export default class Form extends Vue {
  async createSummary() { 
    await Store.create(this.form)
  }
}
```
上の状況では、例えば以下の様な例外は想定できると思います

- バリデーションなどの個別機能に関するエラーが返ってくる
- セッションエラーなどのアプリケーションにおける根幹のステート変更やユーザからの追加アクションが必要になる。その場合もう一度ログインしてもらう必要があります




### Vueコンポーネント側でハンドリングしなかった場合

Vueコンポーネント側でcatchしなかった場合、Vuejsが元々持ってる `handleError` が呼ばれて以下のデフォルトエラー画面が表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29037/ae94d7b3-4c48-67d8-d553-d720a0cac50e.png" height="200px">

コンソールのログ

```
vendors.app.js:35776 error TypeError: "a" is not a constructor
....(省略)
(anonymous) @ vendors.app.js:35776
push../.nuxt/client.js.vue__WEBPACK_IMPORTED_MODULE_17__.default.config.errorHandler @ app.js:563
globalHandleError @ commons.app.js:12305
handleError @ commons.app.js:12274
(anonymous) @ commons.app.js:12291
Promise.catch (async)
invokeWithErrorHandling @ commons.app.js:12291
invoker @ commons.app.js:12614
original._wrapper @ commons.app.js:17337
```


# 2. 解決法

ここで検討する 解決法一覧

1. シンプルにtry-catch
2. then-catchのメソッドチェーン
3. window.onerror
4. errorCaptured
5. Vue.config.errorHandler
6. 私たちが使用している解決法

前提として、ケースバイケースだったりするので一概にこれがいいってのはないです。
自分の環境にマッチしたソリューションを選択してください。

## 2.1 解決法1 シンプルにtry-catch

コード例

```ts

export default class Form extends Vue {
  async createSummary() { 
    try {
      await Store.create(this.form)
    } catch(e) {
      if(e.errorCode === 'session_expired') {
        // storeからセッション情報クリアしてログイン要求したい
      } else if(e.errorCode === 'illegal_argument') {
        // 入力値異常
      } else if(e.errorCode === 'network_error') {
        // 通信環境がいいとこで再度実行してくださいなど表示したい
      }
    }
  }
}
```


### Pros
誰しも理解できて直感的だと思います

### Cons
Vueコンポーネント毎に同じ様なハンドリングを書かないといけなく。結構面倒。
個別機能に関するハンドリングだけならまだいいが、セッション系のハンドリングなどはメンテナンスする必要が出たら全域で改修が必要になってnot good.

例外をハンドリングしようとしているのがこのactionでは `Store.create(this.form)` だけなのでまだこの量に収まっていますが
API呼び出しを複数発行していてかつハンドリング方法が違う場合はもっとハンドリングをふやす必要があります。





## 2.2 解決法2 then-catchのメソッドチェーン

コード例

```ts

// 成功時呼ばれる関数
function onFulfilled(data) {
  console.log(data);
}

function onRejected(err) {
  console.log(err);
}

readFileAsync(module.filename)
  .then(onFulfilled, onRejected);
```

引用(https://qiita.com/koki_cheese/items/c559da338a3d307c9d88)


この手法だと例外スロー時にonRejectedが呼ばれてそこで好きな処理を書くことができます。


### Pros

Promiseでメソッドチェーンを多用してるプロジェクトには相性が良さそう

### Cons

Prosの逆で、 Promiseをあまり定義してない状況からこの手法で実装すると変更すべき箇所が多く大変そう

##  2.3 解決法3 window.onerror

Browser側で実行されるjsの例外を拾ってくれます。
なのでServer側で実行される例外は拾ってくれません。
Vuejs使ってるのであれば、同等の挙動をする `解決法5 Vue.config.errorHandler` を選択するのがいいかと思います。

##  2.4 解決法4 errorCaptured
コード例

```ts

export default class Form extends Vue {
  async createSummary() { 
    await Store.create(this.form)
  }

  errorCaptured(e, vm, info) {
    if(e.errorCode === 'session_expired') {
      // storeからセッション情報クリアしてログイン要求したい
    } else if(e.errorCode === 'illegal_argument') {
      // 入力値異常
    } else if(e.errorCode === 'network_error') {
      // 通信環境がいいとこで再度実行してくださいなど表示したい
    }
    return false // ここで例外の伝搬をとめる
  }
}
```


`errorCaptured` を使うとVueComponent側で例外をハンドリングを共通処理として定義することができます。
falseを返せば伝搬はとまりますが、返さないと親コンポーネントに例外が伝搬します。

ここで詳しく書かれてます。
[Vue.jsのエラーハンドリングについて調べた件（前編）](https://morimori-kochan.hatenablog.com/entry/2019/08/08/013409)

### Prop

コンポーネント単位やコンポーネントツリー単位でハンドリング設計をしたい場合に有効な手段だと思います。

### Cons
一つ一つtry-catchしてハンドリングするよりかは、ある程度まとまった単位でハンドリングできるので少しDryになりますが
どの機能や種類の例外をどこでハンドリングするかなどの全体の設計がむずかしそうですし、コンポーネントツリー単位でハンドリングする
実装にするとメンテナンスコストが高そう

##  2.5 解決法5 Vue.config.errorHandler

コード例

```ts
Vue.config.errorHandler = function (e, vm, info) {
  if(e.errorCode === 'session_expired') {
    // storeからセッション情報クリアしてログイン要求したい
  } else if(e.errorCode === 'illegal_argument') {
    // 入力値異常
  } else if(e.errorCode === 'network_error') {
    // 通信環境がいいとこで再度実行してくださいなど表示したい
  }
  return false
}
```

これはどのコンポーネントで例外が発生してもここでcatchするというものです。
https://jp.vuejs.org/v2/api/#errorHandler
`false` を返すとここで例外が握り潰され、それ以外の場合は `console.err` でエラーがコンソールに出力されます。

### Pros

個人的に他の手法と比べて一番シンプルに実装しやすいのかなと思います。

### Cons

基本的にこれで事足りるのですが、SSRの時に少し困る場合があります。
SSR側で例外が起きた場合`error`を呼ぶことができないため、エラーが起きた`VueComponent`を表示せずエラー画面を表示するということはできません（間違ってたら教えてください）

##  2.6 解決法6 私たちが使用しているソリューション

結論
decoratorを活用します。
decoratorでactionを囲って汎用的な例外をキャッチしたり汎用エラーエラーメッセージをUIに反映させます。


以下はdecoratorの定義
descriptor(action)をtry-catchで囲んであげます。

descriptorのはじめに、mutex Lockされてるかチェックして
Lockされていたら実行しない。Lockされてなければ、Lockしてaction実行終了後にLockを解除しています。

```ts
export function AsyncRescuable(): (target: any, name: string, descriptor: PropertyDescriptor) => void {
  return function(target: any, name: string, descriptor: PropertyDescriptor) {
    const delegate = descriptor.value

    descriptor.value = async function() {
     // *1 例外をキャッチするためにtry-catchで囲む
     try {
        return await delegate.apply(this, arguments)
      } catch (e) {
        // *2 例外ハンドリングする
        await handleException(target, name, e, arguments)
      }
    }
    return descriptor
  }
}
```

セッション系の例外ハンドリングやブラウザレンダリングの時はエラーモーダルを表示するなどの処理はここでします。

```ts
async function handleException(target, name, e, args) {
  const message = getErrorMessage(e)
  if (hasSessionError(e.graphQLErrors)) {
    await AuthStore.logout()
    if (name === 'asyncData') {
      args[0].error(message)
    } else {
      ErrorModalStore.openErrorModal(message)
    }
  } else if (name === 'asyncData') { // SSRとその他で出し分ける必要がある。
    args[0].error(message)
  } else {
    ErrorModalStore.openErrorModal(message)
  }
}
```
###  使うところ

```ts
export default class Form extends Vue {
  // *1 これをつけることで例外が起きたら任意のハンドリングを挟める
  @AsyncRescuable()
  async createSummary() {
    await Store.create(this.form)
  }
}
```

このdecoratorではあくまでも汎用的な例外処理しかしないので
機能毎で固有の例外処理は別途ハンドリング処理を実装する必要があります。
しかし、上での書いた通り、汎用的なセッションエラーやエラーメッセージをモーダルで表示したりするのはこの実装で対応できます。

# 3. 排他処理

UIからユーザがボタンをクリックするとき二重クリックや一つのアクションをしている時に別のアクションを阻止させたい場合の解決法方の一つです。

上のdecorator実装を応用すると排他処理が割と簡単にかけます。


```ts

interface RescueableOptions {
  mutex: 'none' | 'lock'
  loading: boolean
}

export default function Rescuable(
  options: RescueableOptions = { mutex: 'none', loading: false }
): (target: any, name: string, descriptor: PropertyDescriptor) => void {
  return function(target: any, name: string, descriptor: PropertyDescriptor) {
    const delegate = descriptor.value

    descriptor.value = function() {
      if (options.mutex === 'lock') {
        if (MutexStore.isProcessing) return
        MutexStore.beginProcessing(options.loading)
      }
      try {
        const result = delegate.apply(this, arguments)
        MutexStore.endProcessing(options.loading)
        return result
      } catch (e) {
        MutexStore.endProcessing(true)
        handleException(target, name, e, arguments)
      }
    }

    return descriptor
  }
}

export function AsyncRescuable(
  options: RescueableOptions = { mutex: 'none', loading: false }
): (target: any, name: string, descriptor: PropertyDescriptor) => void {
  return function(target: any, name: string, descriptor: PropertyDescriptor) {
    const delegate = descriptor.value

    descriptor.value = async function() {
      if (options.mutex === 'lock') {
        if (MutexStore.isProcessing) {
          return
        }
        MutexStore.beginProcessing(options.loading)
      }
      try {
        const result = await delegate.apply(this, arguments)
        MutexStore.endProcessing(options.loading)
        return result
      } catch (e) {
        MutexStore.endProcessing(true)
        await handleException(target, name, e, arguments)
      }
    }
    return descriptor
  }
}

```

以下はdecoratorを使用する実装
decoratorの引数にoptionを渡します。


```ts
export default class Form extends Vue {
  // mutex: lockで他の操作を無視
  // loading: true でローディングアイコンだす
  @AsyncRescuable({ mutex: 'lock', loading: true })
  async createSummary() {
    await Store.create(this.form)
  }
}
```

`createSummary`が終わるまでユーザは `@AsyncRescuable({ mutex: 'lock'}) ` がついているactionは呼べない様に
することができます。



# 4. まとめ
今回はいくつかの例外ハンドリングに関して考察し、decoratorでのハンドリングに提案をしました。
プロジェクトごとに適切なパターンは違うと思うのでフィットしそうなソリューションを採用してください。
また、この手法もまだまだ発展途上なのでインプットいただけると嬉しいです！


# 5. おまけ
## エラー画面をカスタマイズ
`layouts/error.vue` を定義している場合デフォルトのエラー画面をカスタマイズすることができます
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/29037/5ce33fcc-49ab-fce8-d940-eb9e964b1902.png" height="200px">

## vuex-decoratorを使う場合

`@Action`に`{ rawError: true }` を渡さないとラップされた例外が返ってくるので注意

```ts
@Action({ rawError: true })
public async fetch() {
  // hogehoge
}
```


# 6. 参考文献
[Vue.jsのエラーハンドリングについて調べた件（前編）](https://morimori-kochan.hatenablog.com/entry/2019/08/08/013409)
[今更だけどPromise入門](https://qiita.com/koki_cheese/items/c559da338a3d307c9d88)
[ユーザのブラウザで起きた JavaScript のエラーを収集する](https://qiita.com/grapswiz/items/71f14e73820e352c1fa3)
[Vue.config.errorHandlerはどこで発生したエラーをキャプチャできるのか
](https://qiita.com/clomie/items/73fa1e9f61e5b88826bc)