[[📒Articles]] > [[📒2021 Articles]]
![[2021-09-05.jpg|cover-picture]]
非同期APIを使って取得したデータを[[Vue]]で扱う方法について、私が開発している[[🦉Owlelia]]を使ったソリューションをまとめてみました。
## はじめに
本記事のサンプルコードは[[TypeScript]]と[[Vue]]を対象にしていますが、[[React]]など[[Vue]]以外のライブラリでも応用できる可能性があります。また、すべてのサンプルコードで[[🦉Owlelia]]のモジュールを使っています。
[[🦉Owlelia]]は[[DDD]]に基づく開発を進めるとき、いつも利用するモノを凝縮した[[TypeScript]]向けの[[npm]]パッケージです。公私を問わず、私が開発している[[TypeScript]]プロジェクトのほぼ全てで利用しています。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://github.githubassets.com/favicons/favicon.svg" />
<span class="link-card-v2-site-name">GitHub</span>
</div>
<div class="link-card-v2-title">
GitHub - tadashi-aikawa/owlelia: Utility for DDD 🦉
</div>
<div class="link-card-v2-content">
Utility for DDD 🦉. Contribute to tadashi-aikawa/owlelia development by creating an account on GitHub.
</div>
<img class="link-card-v2-image" src="https://opengraph.githubassets.com/8aa804ec904d2d73d490ff94348c86f75bcaecbb07a2d2edacb45cf46e5efa07/tadashi-aikawa/owlelia" />
<a href="https://github.com/tadashi-aikawa/owlelia"></a>
</div>
本記事のサンプルコードでは、今回紹介する内容の前に[[🦉Owlelia]]の`Result`[^1]を利用しています。[[Either]]や[[Result]]に馴染みのある方は同等のものと思ってください。ただ、取得した結果の例外処理には[[Go]]のような`if (xxx.isErr())`を使っています。これは関数型の書き方にハードルを感じる方に対する配慮の結果です。
[^1]: [[std.result.Result]]とほぼ同じインターフェースになるよう設計しています
## 非同期データのコーディング疲れ
### 非同期データ取得のよくあるコード
[[Vue]]の実装をしていると、非同期でデータを取得して表示するコードを書く機会が多くなると思います。たとえば、[[GitHub]]からリポジトリ一覧を検索する機能を想像してみてください。
![[2021-09-05.mp4]]
Storeを使わない場合、きっとこんなコードを書くことになるのではないでしょうか。
```html:main.vue
<template>
<v-container>
<v-row class="my-3" justify="center">
<v-btn :loading="state.areRepositoriesLoading"
@click="handleClick(false)"
class="ma-3">
GitHub APIでリポジトリを検索する
</v-btn>
<v-btn :loading="state.areRepositoriesLoading"
@click="handleClick(true)"
class="ma-3">
GitHub APIでエラーを発生させる
</v-btn>
</v-row>
<v-row class="my-3" justify="center">
<v-card min-width="480" min-height="240" tile>
<template v-if="state.areRepositoriesLoading">
<v-overlay absolute>
<v-progress-circular :size="64" indeterminate></v-progress-circular>
</v-overlay>
</template>
<template v-if="state.repositoriesError">
<v-alert v-text="state.repositoriesError.message"
type="error">
</v-alert>
</template>
<template v-else>
<v-list-item v-for="repo in state.repositories" :key="repo.id">
<v-list-item-content>
<v-list-item-title>
{{ repo.id }}: {{ repo.name }}
</v-list-item-title>
<v-list-item-subtitle v-text="repo.owner.login">
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-card>
</v-row>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api'
import { reactive } from '@nuxtjs/composition-api'
import * as github from '~/clients/github'
import { Repository } from '~/clients/github'
interface State {
repositories: Repository[]
areRepositoriesLoading: boolean
repositoriesError: Error | null
}
export default defineComponent({
setup() {
const state = reactive<State>({
repositories: [],
areRepositoriesLoading: false,
repositoriesError: null,
})
const handleClick = async (forceError: boolean) => {
state.areRepositoriesLoading = true
const repositoriesOrErr = await github.search.repositories
.get({ q: 'minerva', per_page: '3' }, forceError)
.then((x) => x.map((r) => r.items))
state.areRepositoriesLoading = false
if (repositoriesOrErr.isErr()) {
state.repositoriesError = repositoriesOrErr.error
return
}
state.repositoriesError = null
state.repositories = repositoriesOrErr.value
}
return { state, handleClick }
},
})
</script>
```
```ts:clients/github.ts
import { AsyncResult, err, ok } from 'owlelia'
const BASE_URL = 'https://api.github.com'
const asyncSleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(() => resolve(), ms))
export interface Repository {
id: number
name: string
owner: { login: string }
}
export namespace search {
export namespace repositories {
const path = '/search/repositories'
type Query = {
q: string
per_page?: string
}
interface Response {
items: Repository[]
}
/**
* GitHubからリポジトリ一覧を取得します
* @param query クエリ
* @param forceError trueにすると強制的にエラーを返却します
*/
export async function get(
query: Query,
forceError?: boolean
): AsyncResult<Response, Error> {
// 一瞬で返却されると分からないので強制的に1秒待つ
await asyncSleep(1000)
// 動作確認用で強制的にエラーを返す
if (forceError) {
return err(new Error(`Error: /search/repositories | q=${query.q}`))
}
try {
const res = await fetch(
`${BASE_URL}${path}?${new URLSearchParams(query)}`
)
return ok(await res.json())
} catch (e) {
return err(e)
}
}
}
}
```
### 本当に欲しい情報が霞む
ここから先は`main.vue`の[[TypeScript]]コード部分をメインに話をします。
```ts:main.vueの<script>内
import { defineComponent } from '@vue/composition-api'
import { reactive } from '@nuxtjs/composition-api'
import * as github from '~/clients/github'
import { Repository } from '~/clients/github'
interface State {
repositories: Repository[]
areRepositoriesLoading: boolean
repositoriesError: Error | null
}
export default defineComponent({
setup() {
const state = reactive<State>({
repositories: [],
areRepositoriesLoading: false,
repositoriesError: null,
})
const handleClick = async (forceError: boolean) => {
state.areRepositoriesLoading = true
const repositoriesOrErr = await github.search.repositories
.get({ q: 'minerva', per_page: '3' }, forceError)
.then((x) => x.map((r) => r.items))
state.areRepositoriesLoading = false
if (repositoriesOrErr.isErr()) {
state.repositoriesError = repositoriesOrErr.error
return
}
state.repositoriesError = null
state.repositories = repositoriesOrErr.value
}
return { state, handleClick }
},
})
```
この中で**本質的に意味のあるデータは`repositories`だけ**です。可能なら『`repositories`を取得して表示』のように1行で表現したい気分です。それなのに、ローディングや例外処理を真面目に書くと本質が霞むコード量になってしまいます。
それでも上記コードはまだマシです。扱うデータがリポジトリだけなのですから。これが2つ、3つ、4つ..と増えていくと地獄です。`State`の中は見るに堪えない状態になります。ローディング処理や例外処理の実装を忘れたり、別のデータに対して代入してしまうことも日常茶飯事になります。
[[アクション (フロントエンド設計)|アクション]]に委譲すれば`vue`ファイルの実装はシンプルになるかもしれません。ただそれは混沌さを[[アクション (フロントエンド設計)|アクション]]に隠蔽しただけです。その場合は`Store`のコードが霞みます。
この疲れを解消するため、[[🦉Owlelia]]に新しいクラスを追加してみました。
## [[🦉Owlelia]]で優しく扱う
本日リリースした[[🦉Owlelia]]のv0.34.1で機能追加しています。
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://static-production.npmjs.com/b0f1a8318363185cc2ea6a40ac23eeb2.png" />
<span class="link-card-v2-site-name">npm</span>
</div>
<div class="link-card-v2-title">
owlelia
</div>
<div class="link-card-v2-content">
TODO. Latest version: 0.49.0, last published: 2 months ago. Start using owlelia in your project by running `npm ...
</div>
<img class="link-card-v2-image" src="https://static-production.npmjs.com/338e4905a2684ca96e08c7780fc68412.png" />
<a href="https://www.npmjs.com/package/owlelia/v/0.34.1"></a>
</div>
そのため、それ以上のバージョンをインストールする必要があります。
```console
npm i owlelia
```
### LiquidValueクラス
[[🦉Owlelia]]に新しく追加された`LiquidValue`クラスを使います。同じく[[🦉Owlelia]]で提供している`AsyncResult`や`BaseError`と連携することにより、無駄を省いています。
```ts:Owleliaのvalues.ts
import { AsyncResult, Nullable } from "../result";
import { BaseError } from "../error";
interface LiquidValueLoadOption {
silent?: boolean;
clearValueBeforeLoading?: boolean;
clearErrorBeforeLoading?: boolean;
keepValueIfError?: boolean;
}
// noinspection AssignmentToFunctionParameterJS
export class LiquidValue<T, E = BaseError> {
initialValue: Nullable<T>;
constructor(
public value: Nullable<T>,
public loading: boolean = false,
public error: Nullable<E> = null
) {
this.initialValue = value;
}
async load(
asyncFunc: () => AsyncResult<T, E>,
option?: LiquidValueLoadOption
): Promise<void> {
if (option?.clearValueBeforeLoading) {
this.value = this.initialValue;
}
if (option?.clearErrorBeforeLoading) {
this.error = null;
}
if (!option?.silent) {
this.loading = true;
}
const ret = await asyncFunc();
if (ret.isErr()) {
if (!option?.keepValueIfError) {
this.value = this.initialValue;
}
this.error = ret.error;
} else {
this.error = null;
this.value = ret.value;
}
if (!option?.silent) {
this.loading = false;
}
}
}
```
値と読みこみ状況、そしてエラーの3つをワンセットにしています。利用しているフレームワークやライブラリによって、どのようにUIを制御すべきかは変わりますが、本質的なモデルは一緒になるはずです。
### LiquidValueクラスで置き換える
それでは`LiquidValue`クラスを使って[[TypeScript]]コードを置き換えてみましょう。
```ts:main.vueの<script>内
import { defineComponent } from '@vue/composition-api'
import { reactive } from '@nuxtjs/composition-api'
import { LiquidValue } from 'owlelia'
import * as github from '~/clients/github'
import { Repository } from '~/clients/github'
interface State {
repositories: LiquidValue<Repository[], Error>
}
export default defineComponent({
setup() {
const state = reactive<State>({
repositories: new LiquidValue([]),
})
const handleClick = (forceError: boolean) => {
state.repositories.load(() =>
github.search.repositories
.get({ q: 'minerva', per_page: '3' }, forceError)
.then((x) => x.map((r) => r.items))
)
}
return { state, handleClick }
},
})
```
差は歴然です。
| `LiquidValue`クラス | Stateのプロパティ数 | `handleClick`のステートメント数 |
| ------------------- | ------------------- | ------------------------------- |
| なし | 3 | 8 |
| あり | 1 | **1** |
プロパティやステートメント数も然る事ながら、**==代入間違い==などの実装ミスがなくなるのは非常に大きなメリット**です。
1階層wrapした影響で`<template>`の記述量は若干増えるかもしれませんが、文字数という面ではほとんど変わりません。
動作確認結果は先ほどと同じです。(そのため動画ファイルも同じです)
![[2021-09-05.mp4]]
### loadのオプション
`LiquidValue.load`の第2引数にはオプションを渡せます。デフォルトはすべて`false`になっていますが、GUIの仕様によって同じモデルの読みこみ方法が変わるケースもあり得るため追加しました。
```ts
interface LiquidValueLoadOption {
// trueにするとloading状態が常にfalseになる
// - バックグラウンドロードなどに
silent?: boolean;
// trueにするとload前にvalueをクリア(初期化)する
// - 再取得開始後に直前の結果を表示したくないときに
clearValueBeforeLoading?: boolean;
// trueにするとload前にエラーをクリア(初期化)する
// - 再取得開始後に直前のエラーを表示したくないときに
clearErrorBeforeLoading?: boolean;
// trueにするとloadの結果がエラーになったときでも直前のvalueを保持する
// - エラーになっても直前の結果を併せて表示したいときに
keepValueIfError?: boolean;
}
```
たとえば、ロードするときは直前の結果/エラーを表示したくなければ以下のようにします。
```ts
const handleClick = (forceError: boolean) => {
state.repositories.load(
() =>
github.search.repositories
.get({ q: 'minerva', per_page: '3' }, forceError)
.then((x) => x.map((r) => r.items)),
{ clearValueBeforeLoading: true, clearErrorBeforeLoading: true }
)
}
```
動作結果は以下のようになります。
![[2021-09-05-2.mp4]]
## まとめ
[[🦉Owlelia]]を使って、[[Vue]]の非同期API取得に関する処理をシンプルに書く方法を紹介しました。
正直、同じような概念は世の中に沢山ある気がしています。ちゃんと調査していないので[[車輪の再発明]]かもしれません。それでも、[[🦉Owlelia]]と親和性が高いという点は、唯一`LiquidValue`クラスだけが持つ強みでしょう。[^2]
[^2]: [[🦉Owlelia]]と親和することが本当に強みなのか、逆にリスクではないか..というのは置いておいてください😅
まだサンプルコードを書いただけなので、実際のプロダクトに親和するかは分かりません。また、[[Vuex]]の[[アクション (フロントエンド設計)|アクション]]や、[[React]]のような[[Vue]]以外のライブラリにも適応できるかも分かりません。その辺は実践を繰り返しつつ、[[🦉Owlelia]]をバージョンアップすることで見極めていきたいと思っています。