Skip to main content

HTTP 通信とデータ取得 — 取得処理の置き場所を決める

8 章でデータ取得を Composable にまとめ、9 章でそれをサーバー描画に置くと読み込み中の HTML が返ることを見ました。この章は 2 つの宿題を回収します。中断が届かない経路の扱いと、サーバー側でデータを埋める仕組みです。

あわせて接続先の切り替えを扱います。fetch の書き方そのものより、URL と鍵をどこに置くかで事故が起きやすい部分です。

この章で学ぶこと

  • abort() が通信を止められる条件と、signal.aborted のガードがそれと独立に効く理由
  • fetch のオプションを合成するときの落とし穴。展開の順序と、Headers を spread すると空になること
  • Vite の import.meta.env — 露出する範囲、.env の優先順位、型に空いている穴
  • process.env がクライアント向けビルドで {} に置き換わることと、VITE_* に秘密を入れてはいけない理由
  • Nuxt の $fetch / useFetch / useAsyncData の使い分けと、SSR で cookie が飛ばない条件
  • Nuxt の設定値を useRuntimeConfig() で読む形

取り消しが効く条件

8 章の Composable は watchEffect の後片付けで controller.abort() を呼び、書き込みの前に controller.signal.aborted を見ていました。この 2 つは役割が違います。

abort() が実際に通信を止められるのは、取得側が signal を尊重している経路だけです。自前のラッパーに差し替えて signal を渡し忘れると、通信は止まりません。

// signal を受け取っているが渡し忘れている
const request = (url: string, signal: AbortSignal) => fetch(url)

それでも古い結果は書き込まれませんabort()signal.abortedその場で true にするので、後から返ってきた実行がガードに引っかかります。通信が止まるかどうかとは独立に効くのがこのガードの利点です。

実行ごとに AbortController を作らないと弾けない

弾けるのは、その実行専用の AbortController がクロージャに入っているからです。watchEffect は再実行のたびに後片付けを呼ぶので、この形が自然に成り立ちます。

命令的に呼ぶ関数ではそうなりません。ボタンを 2 回押して 1 回目の応答が後から返ると、古い結果が新しい結果を上書きします

const data = shallowRef<string | null>(null)
let current: AbortController | null = null

const load = (path: string) => {
current?.abort() // 前回を取り消す
const controller = new AbortController()
current = controller
return request(path, controller.signal).then((v) => {
if (controller.signal.aborted) return
data.value = v
})
}

前回の controller を持って abort() してから新しいものを作れば、watchEffect と同じ形になります。

世代カウンタへ置き換えない

「何回目の実行か」を数えて比べる書き方もありますが、signal.aborted の代わりにすると壊れます

// 置き換えると後片付けを弾けない
if (mine !== generation) return

コンポーネントがアンマウントされたときも後片付けは走り、abort()fetchAbortError を投げます。しかし世代は進んでいないので判定を通り抜け、errorAbortError が入ります。もう画面に無いコンポーネントがエラー状態で終わるわけです。signal.aborted を見ていればここも弾けます。

オプションを合成する順序

呼び出しごとのオプションと共通のヘッダーを混ぜるとき、展開の順序を間違えるとヘッダーが丸ごと消えます

// 壊れている — options.headers があると Content-Type が消える
fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
})

後ろの ...optionsheaders プロパティごと上書きするので、optionsheaders があると組み立てた結果が捨てられます。{ Authorization: 'Bearer x' } を渡すと、実際に飛ぶヘッダーは Authorization だけになります。

options.headers が無いときは症状が出ません。 ヘッダーを渡す呼び出しを書いた日に初めて壊れるので、原因を追いにくい種類の間違いです。

順序を直しても spread では足りない

options を先に展開すれば上書きは避けられますが、それだけでは中身が消える経路が残りますRequestInitheaders は素のオブジェクトのほかに Headers[key, value][] も受け取ります。Headers を spread すると空になります — 中身がプロパティとして並んでいないからです。型検査は通るので、渡した日に黙って消えます。

Headers に集め直せば 3 つの形すべてで残ります。8 章の useFetch から作る useApi は、第 2 引数に options?: RequestInit を取り、urlpath に改名して apiBaseUrl を import します。そのうえで下の 2 か所を差し替えます。

src/composables/useApi.ts
function mergeHeaders(init?: HeadersInit) {
// オブジェクトを spread すると Headers の中身が消えるので、Headers に集め直す
const headers = new Headers(init)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return headers
}

has を見てから入れているのは、呼び出し側が指定した Content-Type を尊重するためです。FormData を送るときは Content-Type を付けないのが正しいので、その場合は呼び出し側で空文字を入れるなど別の扱いが要ります。fetch へは次の形で渡します。

fetch(`${apiBaseUrl}${toValue(path)}`, {
// options を先に展開する。後にすると headers ごと上書きされる
...options,
signal: controller.signal,
headers: mergeHeaders(options?.headers),
})

apiBaseUrl を前に付けているのは、接続先を 1 か所に寄せるためです。次の節で扱います。

接続先を環境変数で切り替える

Vite では import.meta.env から読みます。process.env を書くと開発中と本番で違う壊れ方をします。

src/config/api.ts
// ベース URL は 1 か所で読む。呼び出し側に import.meta.env を散らさない
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'
# .env
VITE_API_BASE_URL=https://api.example.com

process.env{} へ置き換わるのは、クライアント向けのアプリを本番ビルドしたときだけです。開発サーバー、SSR 向けのビルド、ライブラリビルド (build.lib) では置き換わりません (keepProcessEnv を書けば明示的に変えられます)。

つまり process.env.API_BASE_URL は場面ごとに壊れ方が変わります。開発中は process の無いブラウザでその行が止まり、本番ビルドでは {}.API_BASE_URL になるのでエラーが出ないまま接続先が既定値のまま出ます。後者のほうが厄介です。

process.env.NODE_ENV だけは別扱いで、置換の対象が process.env 全体と同じ範囲に加えて開発サーバーも含みます。埋まる値は Vite を実行した環境の NODE_ENV で、無ければ mode です。

露出するのは VITE_ が付いたものだけ

公式は「Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling」と書いています。prefix の付かない変数は import.meta.env から読めません。この既定値 VITE_envPrefix オプションで変えられるので、**「prefix が無いから安全」ではなく「envPrefix に一致しないから露出しない」**が正確な条件です。define で明示的に渡した値も露出します。

import.meta.env には prefix なしで読める組み込みの値が 5 つあります。

キー中身
MODE実行中のモード ('development' / 'production' など)
BASE_URLアプリを配信するベースパス
PROD本番ビルドかどうか
DEV開発中かどうか
SSRサーバー側で走っているかどうか

VITE_* に秘密を入れない

公式の警告をそのまま引きます。

VITE_* variables should not contain sensitive information such as API keys. The values of these variables are bundled into your source code at build time.

ビルド時にソースへ埋め込まれるので、.env.gitignore に入れていても配信された JS を見れば読めます。ビルド出力からは import.meta.env.VITE_API_BASE_URL という参照が消え、代わりに値そのものが文字列リテラルとして並びます。開発サーバーが挿入するのはリテラル置換ではなく import.meta.env へのオブジェクト代入です。どちらもブラウザへ値が渡っている点は同じです。API キーや秘密鍵を置く場所ではありません。秘密が要る通信は、鍵を持つサーバー側の経路を 1 つ立ててそこへ投げます。

.env の優先順位

同じキーが複数のファイルにあるときの強さは次の順です。右が強くなります。

.env.env.local.env.[mode].env.[mode].local

さらに強いものが 1 つあります。 Vite を実行した時点で既に存在する環境変数は .env ファイル群に上書きされません。CI で VITE_API_BASE_URL=... を渡すと、リポジトリの .env.production より優先されます。

型に穴が空いている

import.meta.env の型は Record<string, any> を継承しているので、宣言していないキーも any として通ります

// どちらも型エラーにならない
const typo: string = import.meta.env.VITE_API_BAES_URL
const wrong: boolean = import.meta.env.VITE_API_BASE_URL

塞ぐには ViteTypeOptionsstrictImportMetaEnv を宣言し、使うキーを自分で並べます。

src/env.d.ts
/// <reference types="vite/client" />

interface ViteTypeOptions {
strictImportMetaEnv: unknown
}

interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}

これを入れると、typo は Property 'VITE_API_BAES_URL' does not exist on type 'ImportMetaEnv'. Did you mean 'VITE_API_BASE_URL'? で止まり、型違いの代入も止まります。組み込みの 5 キーは宣言しなくてもそのまま読めます。 create-vue が作る雛形にはこの宣言が入っていないので、自分で足す判断になります。

サーバー側でデータを埋める

9 章で見たとおり、素の Vue の SSR では watchEffect の中で始めた取得を renderToString が待ちません。サーバーが返す HTML には読み込み中の状態が入ります。

Nuxt はこれを専用の composable で解決します。使い分けの軸は 1 つです。サーバーで取った結果をクライアントへ引き継ぐか。

使うもの何をするか
$fetchHTTP リクエストを投げるだけ。状態は引き継がない
useAsyncData任意の非同期処理を包み、結果を payload でクライアントへ渡す
useFetchuseAsyncData + $fetch のショートカット

$fetch を setup の中で直接 await すると、サーバーで 1 回、hydration 中のクライアントで 1 回、合わせて 2 回取得します。公式は「$fetch does not transfer state from the server to the client」と説明しています。

// SSR で 2 回走る
const twice = await $fetch('/api/item')

// サーバーで 1 回だけ走り、結果が payload でクライアントへ渡る
const { data: viaAsyncData } = await useAsyncData('item', () => $fetch('/api/item'))

const { data } = await useFetch('/api/item') // 上と同じことの短い書き方

$fetch を単体で使ってよいのは、hydration で二度走らない場所です。ボタンのハンドラや送信処理、サーバールート (defineEventHandler) の中がこれに当たります。避けるのはコンポーネントの setup 本体で $fetch を呼ぶことです。Nuxt は setup をサーバーとクライアントの両方で走らせるので、await を外してもリクエストは 2 回飛びます (import.meta.client で囲めば 1 回に絞れますが、その場合サーバーが返す HTML にデータは入りません)。

返り値は status で見る

useFetch / useAsyncData の返り値のうち、状態を見るのは status です。

名前中身
data結果の ref
status'idle' / 'pending' / 'success' / 'error' の ref
error失敗したときのエラーの ref
refresh / execute取り直す関数
cleardata を初期値へ、status'idle' へ戻す関数

pendingRef<boolean>)も返りますが、公式の例は status を使います。data / status / error は ref なので <script setup> の中では .value が要ります。

await は必須ではありません。 公式は「On the server, Nuxt waits for the promise to resolve before rendering in either case」と書いています。サーバー側の描画はどちらでも待つので、await が変えるのはクライアント側のナビゲーションをブロックするかだけです。ブロックしたくないなら lazy: true を渡すのが明示的な書き方です。

同じ URL でも共有されない

useFetch の key は書かなければ呼び出し箇所ごとに自動生成されます。そのため別のコンポーネントから同じ URL・同じオプションで呼んでも state は共有されず、それぞれがリクエストを投げます。同じコンポーネントの複数インスタンスは、呼び出し箇所が同じなので共有します。

意図して共有するなら key を明示します。

// どちらのコンポーネントでも同じ key を渡すとリクエストは 1 回になる
const { data } = await useFetch('/api/random', { key: 'random' })

ブラウザから $fetch を呼ぶと cookie はそのまま送られますが、サーバー側の描画中は送られません。 公式はサーバーサイドリクエストフォージェリ (SSRF) と認証の誤用を理由に挙げています。認証付きの API をサーバーで叩くと、ログインしていない扱いのレスポンスが返ります。

転送するには useRequestFetch() を通します。

const requestFetch = useRequestFetch()
const { data } = await useAsyncData(() => requestFetch('/api/cookies'))

useFetch を相対 URL で呼んだ場合は、サーバー側で Nuxt が自動的に useRequestFetch を使います。 host のように転送すべきでないヘッダーは除かれます。ここが $fetch の直呼びと分かれる点です。

Nuxt の設定値は useRuntimeConfig()

Nuxt では import.meta.env でなく runtimeConfig を使います。公開範囲が構造で決まるのが違いです。

export default defineNuxtConfig({
runtimeConfig: {
// root 直下はサーバーだけ
apiSecret: '',
public: {
// public の下はクライアントにも出る
apiBase: '/api',
},
},
})

読むときは useRuntimeConfig() を呼びます。サーバールートでは event を渡します。

// コンポーネント / composable から
useRuntimeConfig().public.apiBase

// サーバールートから (event を渡すと環境変数の上書きが反映される)
export default defineEventHandler((event) => {
const secret = useRuntimeConfig(event).apiSecret
return $fetch('https://api.example.com/me', {
headers: { Authorization: `token ${secret}` },
})
})

環境変数で上書きするときの規則は「NUXT_ で始まる大文字の名前で、キーの区切りと大文字小文字の変わり目の両方を _ にする」です。public.apiBaseNUXT_PUBLIC_API_BASEapiSecretNUXT_API_SECRET になります。

値は destr で JavaScript の型へ自動変換されます。 NUXT_MY_VAR=4848e0 は数値の 4848 になるので、文字列のまま渡すには値そのものに二重引用符を含めます。

public の下はクライアントにも出るので、Vite の VITE_* と同じ扱いです。秘密は root 直下に置き、サーバー側の経路からだけ読みます。

axios を足す前に

Nuxt では公式が $fetch を推奨しています。

$fetch is the preferred way to make HTTP calls in Nuxt instead of @nuxt/http and @nuxtjs/axios that are made for Nuxt 2

$fetchofetch をグローバルに置いたもので、SSR 中に自分のサーバールートを叩くと HTTP を経由せず該当の関数を直接呼びます

それでも axios を使う場合、Nuxt の plugin に置くときに引っかかりやすいのは次の 2 点です。

まず実行される場所です。app/plugins/ のファイルは既定でサーバーとクライアントの両側で走ります。片側だけにするには .client / .server の suffix を付けます。だから interceptor の中に localStoragewindow を書くと、サーバー側でリクエストを飛ばした時点で落ちます。

// サーバーでは undefined を返す。裸で読むと ReferenceError になる
const token = typeof localStorage === 'undefined' ? null : localStorage.getItem('token')

globalThis.localStorage 経由なら値が undefined なので TypeError です。どちらも描画を止めます。 そもそもサーバー側で認証情報が要るなら、cookie を useRequestFetch() で転送する形にします。

もう 1 つは書き方です。plugin は defineNuxtPlugin() で書きます。axios のインスタンスをただ export default したファイルを app/plugins/ に置くと、Nuxt は plugin として登録しようとします。共有インスタンスを置きたいだけなら app/utils/ や composable にします。

export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const api = $fetch.create({ baseURL: config.public.apiBase })
return { provide: { api } }
})

まとめ

  • abort() が通信を止められるのは、取得側が signal を尊重する経路だけです。一方 signal.aborted のガードは通信が止まるかどうかと独立に効きますabort() がフラグをその場で立てるからです。自前のラッパーで signal を渡し忘れても、古い結果は書き込まれません
  • 弾けるのは、その実行専用の AbortController がクロージャにあるからです。命令的に呼ぶ関数では前回の controller を持って abort() してから作り直します。signal.aborted を世代カウンタへ置き換えると後片付けを弾けず、アンマウント後の AbortErrorerror に残ります
  • オプションを合成するときは呼び出し側の options を先に展開します。 後ろで展開すると headers プロパティごと上書きされ、組み立てた Content-Type が消えます。さらに Headers を spread すると空になるので、ヘッダーは new Headers(options?.headers) に集め直します。どちらも options.headers が無い呼び出しでは症状が出ません
  • Vite では import.meta.env から読みます。process.env{} へ置換されるのは、クライアント向けのアプリを本番ビルドしたときだけです。process.env.X || 既定値 はエラーにならず、既定値のまま本番に出ます。開発サーバー・SSR ビルド・ライブラリビルドでは置換されません (process.env.NODE_ENV は開発サーバーでも置換されます)
  • import.meta.env から読めるのは envPrefix (既定は VITE_) に一致するものと、組み込みの MODE / BASE_URL / PROD / DEV / SSR です。 prefix を変えれば範囲も変わり、define で渡した値も露出します。prefix の有無そのものは安全性の根拠になりません
  • VITE_* に API キーを置かないでください。 値はビルド時にソースへ埋め込まれるので、配信された JS から読めます
  • .env の強さは .env.env.local.env.[mode].env.[mode].local の順で、実行時に既に存在する環境変数がそれより強いです
  • import.meta.env の型には穴があります。 既定では未宣言のキーも any として通るので、typo も型違いも捕まりません。塞ぐには ViteTypeOptions への strictImportMetaEnv の宣言と、使うキーを ImportMetaEnv に並べることの両方が要ります。strictImportMetaEnv だけ宣言してキーを並べないと、VITE_* の参照が落ちます (組み込みの 5 キーは別に宣言されているので残ります)。 create-vue の雛形には入っていません
  • Nuxt では setup の中で $fetch を呼ぶと SSR で 2 回取得します (await の有無は関係ありません)。結果を引き継ぐのは useAsyncData と、その短縮形の useFetch です。ハンドラやサーバールートの中では $fetch をそのまま使います
  • useFetch の状態は status ('idle' / 'pending' / 'success' / 'error') で見ます。pending も返りますが公式の例は status です。await は必須ではなく、変わるのはクライアント側のナビゲーションをブロックするかだけです
  • key を書かないと呼び出し箇所ごとに自動生成されるので、別のコンポーネントから同じ URL を呼んでも共有されません。共有するなら key を明示します
  • SSR 中の $fetch は cookie を転送しません。 転送するには useRequestFetch() を通します。useFetch に相対 URL を渡した場合は Nuxt が自動でこれを使います
  • Nuxt の設定値は useRuntimeConfig() で読み、root 直下はサーバーのみ、public の下はクライアントにも出ます。環境変数の上書きは NUXT_ + 大文字で、キーの区切りと大文字小文字の変わり目の両方を _ にします (public.apiBaseNUXT_PUBLIC_API_BASE)。値は destr で型変換されるので、数字に見える文字列は数値になります
  • Nuxt の plugin は既定で両側で走ります (.client / .server の suffix で片側に絞れます)。interceptor に localStoragewindow を書くとサーバー側で落ちます。plugin は defineNuxtPlugin() で書き、共有インスタンスを置くだけなら app/utils/ や composable にします
関連リファレンス

次に読む