Vue — API クライアントの一元化
テクノロジ系 / L11 api — 非同期 UX と API 連携設計
Vue — API クライアントの一元化
api / ch02
このチャプターの到達ゴール
| 翌日 |
|---|
src/lib/apiClient.ts で 単一の axios インスタンスを作り、全コンポーネントが import する |
interceptors.response で 401 / 共通エラーを 1 箇所で処理できる |
baseURL を import.meta.env から読み、URL 直書きを残さない |
error.response?.data?.message ?? error.message の 段階フォールバックが書ける |
> 題材は 読書記録アプリ (books の CRUD)。 一覧・登録・更新・削除がすべて同じ API を叩く。
何が問題か (= 散らばった通信処理)
ch01 では各コンポーネントが直接 fetch('http://localhost:8000/...') を書いていた。
画面が増えると 同じことを毎回コピペすることになる。
| コピペされるもの | 増えると何が起きるか |
|---|---|
baseURL (URL 直書き) | 本番 URL に変えるとき全ファイル grep して直す羽目に |
共通ヘッダ (Content-Type 等) | 1 ファイル付け忘れて挙動が揺れる |
| 401 (認証切れ) の処理 | 画面ごとに対応がバラバラ / 付け忘れた画面は無反応 |
| エラーメッセージの取り出し方 | e.message だけ見て API が返した文言を捨てる |
> 通信の 横断的な関心事 は 1 箇所に集約する。 これが今日の主題。
Before — 各 view が axios を直書き (アンチパターン)
// BookListView.vue
import axios from 'axios'
async function load() {
// baseURL が URL 直書き = env 化されていない
const res = await axios.get('http://localhost:8000/books')
books.value = res.data
}// BookEditView.vue — 別ファイルでも同じ URL をコピペ
import axios from 'axios'
async function save() {
await axios.put(`http://localhost:8000/books/${id}`, payload)
}> ❌ http://localhost:8000 が 2 ファイルに散る。 401 が来ても各画面が個別対応。 直書き URL は本番化のとき爆発する。
After — 単一インスタンスを export する
// src/lib/apiClient.ts
import axios from 'axios'
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE, // ← env から読む (直書きしない)
headers: { 'Content-Type': 'application/json' },
timeout: 10_000,
})# .env.development VITE_API_BASE=http://localhost:8000 # .env.production VITE_API_BASE=https://api.example.test
> axios.create() で 設定済みのインスタンスを 1 つ作り、それを export する。
> 各 view は import { api } from '@/lib/apiClient' するだけ。 URL も設定も二度と書かない。
各 view は api を import するだけ
// BookListView.vue
import { api } from '@/lib/apiClient'
async function load() {
const res = await api.get('/books') // ← 相対パスのみ。 baseURL は付かない
books.value = res.data
}// BookEditView.vue
import { api } from '@/lib/apiClient'
async function save() {
await api.put(`/books/${id}`, payload) // ← 同じ api。 URL もヘッダも共通
}> パスは 相対 (/books) だけ書く。 baseURL は インスタンスが補完する。
> 開発と本番の切り替えは .env を変えるだけ。 ソースは 1 行も触らない。
response interceptor で 401 を 1 箇所処理
// src/lib/apiClient.ts (続き)
import router from '@/router'
api.interceptors.response.use(
(res) => res, // 成功はそのまま通す
(error) => {
if (error.response?.status === 401) {
router.push('/login') // ← 認証切れは全画面共通でログインへ
}
return Promise.reject(error) // ← 必ず reject を返す (これが肝)
},
)> interceptors.response.use(onSuccess, onError) は 全レスポンスを 1 箇所で横取りする。
> 401 を各画面で書かなくても、ここで一括ハンドリングできる。
落とし穴 1 — interceptor が 401 を握り潰す
// ❌ やりがちな失敗
api.interceptors.response.use(
(res) => res,
(error) => {
console.error(error) // ← ログに出すだけ
// return を書いていない = undefined を return
},
)| 何が起きるか |
|---|
エラーが console.error に消えるだけで 呼び出し側の catch に届かない |
view の try/catch が発火せず error state が立たない → 画面が無反応のまま固まる |
| 「ボタンを押したのに何も起きない」 という最悪の UX |
> ⚠️ interceptor の onError は 必ず return Promise.reject(error) で締める。
> ログを出すのは良いが、握り潰してはいけない。 呼び出し側にエラーを返し切る。
落とし穴 2 — baseURL が一時 URL 直書きで残る
// ❌ env 化したつもりで 1 箇所だけ直書きが取り残される
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
})
// …別ファイルで画像 URL だけ直書きが生き残る
const cover = `http://localhost:8000/covers/${book.id}.png` // ← 片手落ち> 「baseURL は env 化したから完了」 と思った裏で、画像 URL や別エンドポイントだけ直書きが残る。
> これは目視では気づきにくい。 後の演習で grep で全検出して回収する習慣をつける。
エラーメッセージの段階フォールバック
API が返す JSON は { "message": "ISBN が重複しています" } のような構造のことが多い。
ネット切れだと error.response 自体が存在しない。 段階的に取り出す。
function toMessage(error: unknown): string {
if (axios.isAxiosError(error)) {
return error.response?.data?.message ?? error.message
// ↑ API が返した文言 ↑ なければ axios の汎用文言
}
return '不明なエラーが発生しました'
}| 取り出す順 | 元 | 例 |
|---|---|---|
① error.response?.data?.message | API が返した業務メッセージ | 「ISBN が重複しています」 |
② error.message | axios / ネットワーク層の文言 | 「Network Error」 |
| ③ 固定文 | 何も取れないとき | 「不明なエラーが発生しました」 |
> ?. (optional chaining) と ?? (nullish 合体) で 段階的に落とす。 一番具体的な文言を優先する。
view 側はこれだけ薄くなる
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/apiClient'
import { toMessage } from '@/lib/apiClient'
const books = ref<Book[]>([])
const loading = ref(false)
const error = ref('')
async function load() {
loading.value = true
error.value = ''
try {
const res = await api.get('/books') // 401 は interceptor が処理済み
books.value = res.data
} catch (e) {
error.value = toMessage(e) // 段階フォールバックで文言化
} finally {
loading.value = false
}
}
onMounted(load)
</script>> ch01 で学んだ 3 状態 (loading/success/error) はそのまま。
> 変わったのは 「通信の共通処理が view から消えた」 こと。
責務の地図
| レイヤ | 持つもの |
|---|---|
apiClient.ts (インスタンス) | baseURL / 共通ヘッダ / timeout |
interceptors.response | 401 リダイレクト / 共通エラー整形 |
toMessage() | エラーの段階フォールバック |
| view | 3 状態 (loading/error) と画面固有の catch |
> 「どこを直せば全画面に効くか」 が 1 箇所に決まっているのが一元化の価値。
> baseURL を変えたいなら .env、401 の挙動を変えたいなら interceptor、それだけを見ればいい。
わざと壊して確認
| 試す | やり方 | 期待動作 |
|---|---|---|
| 401 | API を 401 を返すように細工してアクセス | interceptor が /login へ飛ばす |
| 握り潰し再現 | onError から return を消す | view の catch が発火せず画面が固まる (= 罠を体感) |
| API 文言 | 重複 ISBN で 409 + {message} を返させる | 画面に API の業務メッセージが出る |
| ネット切れ | API を停止 | error.message 由来の汎用文言が出る |
> ⚠️ 「握り潰し」 は わざと再現して固まるのを見る。 これを知らないと本番で原因不明の無反応に悩む。
チャプター完了サイン
- [ ]
src/lib/apiClient.tsに 単一の api インスタンス がある - [ ] 全 view が
import { api }で同じインスタンスを使っている (= 各 view にaxios.createが無い) - [ ]
baseURLがimport.meta.env.VITE_API_BASEを読み、ソースに URL 直書きが 0 件 - [ ] interceptor の onError が
return Promise.reject(error)で締めてある - [ ] エラー文言が
error.response?.data?.message ?? error.messageの順で出る - [ ] 「interceptor が 401 を握り潰すと何が起きるか」 を 1 文で言える
次に学ぶ領域
| 領域 | 名前 |
|---|---|
| 段階的開発 | モック先行 → 本番繋ぎ替え (= 次章 ch03) |
| キャッシュ / 再取得 | TanStack Query |
| トークン更新 | refresh token + interceptor の retry |
| リクエストキャンセル | AbortController |
> 一元化した api インスタンスは モックにも本番にも差し替えやすい。 次章でその段階的開発を扱う。
考えてみよう
既定では正解・不正解の判定はしません。自分のペースで「答えを見る」を。採点してほしい場合だけ「採点してほしい」を押してください。
ch02 — 演習
題材: 読書記録アプリ (books の CRUD)。 一覧 (BookListView) と編集 (BookEditView) が同じ API を叩く。
Lv1: 単一 api インスタンスに集約する
src/lib/apiClient.ts を作り、各 view の axios 直書きを撤去する。
// src/lib/apiClient.ts
import axios from 'axios'
export const api = axios.create({
// TODO 1: baseURL は import.meta.env.VITE_API_BASE から読む (URL 直書きしない)
// TODO 2: headers に Content-Type: application/json
// TODO 3: timeout: 10_000
})// BookListView.vue / BookEditView.vue
// TODO 4: import axios → import { api } from '@/lib/apiClient' に置換
// TODO 5: axios.get('http://localhost:8000/books') → api.get('/books') (相対パスのみ)# .env.development VITE_API_BASE=http://localhost:8000
検証
- 各 view から
axios.createとhttp://localhost:8000が 消えている grep -rn "axios.create" src/のヒットが 1 件 (apiClient.ts のみ)grep -rn "http://localhost:8000" src/のヒットが 0 件
Lv2: response interceptor で 401 と共通エラーを処理
apiClient.ts に interceptor と段階フォールバックを足す。
import router from '@/router'
api.interceptors.response.use(
(res) => res,
(error) => {
// TODO 6: error.response?.status === 401 なら router.push('/login')
// TODO 7: 必ず return Promise.reject(error) で締める ← これを忘れると握り潰し
},
)
export function toMessage(error: unknown): string {
// TODO 8: axios.isAxiosError なら error.response?.data?.message ?? error.message
// そうでなければ '不明なエラーが発生しました'
return ''
}view 側:
import { api, toMessage } from '@/lib/apiClient'
// catch (e) { error.value = toMessage(e) } ← e.message 直参照をやめる検証
| シナリオ | 期待表示 |
|---|---|
| API が 401 を返す | /login に遷移する |
重複 ISBN で 409 + {message} | 画面に API が返した業務メッセージ が出る |
| API 停止 (ネット切れ) | error.message 由来の汎用文言が出る |
> ⚠️ onError から return を一度わざと消し、画面が 無反応で固まるのを観察してから戻すこと。
提出物
「interceptor の onError で return Promise.reject(error) を忘れると何が起きるか」 を 1 文で書け。
チャレンジ
error.response?.data?.message ?? error.messageをtoMessage()に閉じ込め、view からe.message直参照を 0 件にする (grep -rn "\.message" src/views/で確認)- request interceptor を 1 つ足して、全リクエストに
Authorization: Bearer <token>を自動付与する (token はダミー文字列で可) - 画像 URL など baseURL とは別に直書きが残っていないか
grep -rn "http://localhost" src/で全検出して回収する
ヒント
api.get('/books')の 先頭スラッシュ を忘れると baseURL との連結が崩れる- interceptor の onError は
(error) => { … ; return Promise.reject(error) }。 return を書かないとcatchに届かない ??はnull/undefinedのときだけ右に落ちる。 空文字''は落とさないので、API が空メッセージを返すケースは別途考える