[[📒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]]をバージョンアップすることで見極めていきたいと思っています。