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() で fetch が AbortError を投げます。しかし世代は進んでいないので判定を通り抜け、error に AbortError が入ります。もう画面に無いコンポーネントがエラー状態で終わるわけです。signal.aborted を見ていればここも弾けます。
オプションを合成する順序
呼び出しごとのオプションと共通のヘッダーを混ぜるとき、展開の順序を間違えるとヘッダーが丸ごと消えます。
// 壊れている — options.headers があると Content-Type が消える
fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
})
後ろの ...options が headers プロパティごと上書きするので、options に headers があると組み立てた結果が捨てられます。{ Authorization: 'Bearer x' } を渡すと、実際に飛ぶヘッダーは Authorization だけになります。
options.headers が無いときは症状が出ません。 ヘッダーを渡す呼び出しを書いた日に初めて壊れるので、原因を追いにくい種類の間違いです。
順序を直しても spread では足りない
options を先に展開すれば上書きは避けられますが、それだけでは中身が消える経路が残ります。RequestInit の headers は素のオブジェクトのほかに Headers と [key, value][] も受け取ります。Headers を spread すると空になります — 中身がプロパティとして並んでいないからです。型検査は通るので、渡した日に黙って消えます。
Headers に集め直せば 3 つの形すべてで残ります。8 章の useFetch から作る useApi は、第 2 引数に options?: RequestInit を取り、url を path に改名して apiBaseUrl を import します。そのうえで下の 2 か所を差し替えます。
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 を書くと開発中と本番で違う壊れ方をします。
// ベース 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
塞ぐには ViteTypeOptions に strictImportMetaEnv を宣言し、使うキーを自分で並べます。
/// <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 つです。サーバーで取った結果をクライアントへ引き継ぐか。
| 使うもの | 何をするか |
|---|---|
$fetch | HTTP リクエストを投げるだけ。状態は引き継がない |
useAsyncData | 任意の非同期処理を包み、結果を payload でクライアントへ渡す |
useFetch | useAsyncData + $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 | 取り直す関数 |
clear | data を初期値へ、status を 'idle' へ戻す関数 |
pending(Ref<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' })
SSR 中は cookie が飛ばない
ブラウザから $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.apiBase は NUXT_PUBLIC_API_BASE、apiSecret は NUXT_API_SECRET になります。
値は destr で JavaScript の型へ自動変換されます。 NUXT_MY_VAR=4848e0 は数値の 4848 になるので、文字列のまま渡すには値そのものに二重引用符を含めます。
public の下はクライアントにも出るので、Vite の VITE_* と同じ扱いです。秘密は root 直下に置き、サーバー側の経路からだけ読みます。
axios を足す前に
Nuxt では公式が $fetch を推奨しています。
$fetchis the preferred way to make HTTP calls in Nuxt instead of@nuxt/httpand@nuxtjs/axiosthat are made for Nuxt 2
$fetch は ofetch をグローバルに置いたもので、SSR 中に自分のサーバールートを叩くと HTTP を経由せず該当の関数を直接呼びます。
それでも axios を使う場合、Nuxt の plugin に置くときに引っかかりやすいのは次の 2 点です。
まず実行される場所です。app/plugins/ のファイルは既定でサーバーとクライアントの両側で走ります。片側だけにするには .client / .server の suffix を付けます。だから interceptor の中に localStorage や window を書くと、サーバー側でリクエストを飛ばした時点で落ちます。
// サーバーでは 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を世代カウンタへ置き換えると後片付けを弾けず、アンマウント後のAbortErrorがerrorに残ります - オプションを合成するときは呼び出し側の
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.apiBase→NUXT_PUBLIC_API_BASE)。値はdestrで型変換されるので、数字に見える文字列は数値になります - Nuxt の plugin は既定で両側で走ります (
.client/.serverの suffix で片側に絞れます)。interceptor にlocalStorageやwindowを書くとサーバー側で落ちます。plugin はdefineNuxtPlugin()で書き、共有インスタンスを置くだけならapp/utils/や composable にします
- Vite — Env Variables and Modes —
VITE_prefix と.envの優先順位、strictImportMetaEnv - Nuxt — Data Fetching —
useFetch/useAsyncData/$fetchの使い分け - Nuxt —
$fetch— 二重取得と SSR での header 転送 - Nuxt —
useRuntimeConfig— public / private とNUXT_prefix