ライブ API からリアルタイムの為替レートを取得する Vue 通貨コンバーターが必要なら、このチュートリアルが最初から最後まで案内します。Composition API、TypeScript、無料の為替レート API を使って、本番投入できる Vue 3 コンポーネントを構築します — 適切なデバウンス、キャッシュ、エラーハンドリング、Nuxt の SSR に対応したパターン付きで。
完成すると、再利用可能な <CurrencyConverter /> コンポーネント、どの Vue 3 プロジェクトにも組み込める useCurrencyRates コンポーザブル、そして実ユーザーに通貨変換を提供する際に重要なトレードオフへの明確な理解が手に入ります。
構築するもの
以下の機能を持つ Vue 3 通貨コンバーター:
- Finexly API 経由で 170 以上の通貨 のライブレート
- ユーザー入力に応じてリアクティブに更新される変換
- 通貨スワップボタン(USD → EUR がワンクリックで EUR → USD になる)
- ネットワークを浪費しないデバウンスされた API 呼び出し
- レイテンシを低く保ち、無料枠のクォータを守るインメモリキャッシュ
- エラーハンドリング、ローディング状態、適切な TypeScript 型
- Nuxt 3 でハイドレーション不一致を起こさない SSR フレンドリー設計
最終的なコンポーネントはおよそ 80 行。コンポーザブルはあと 60 行。それだけです。
前提条件
以下に慣れている必要があります:
- Vue 3 構文(
<script setup>形式) - 基本的な TypeScript
fetchで REST API を呼び出すこと
Finexly の API キーも必要です。ダッシュボードから取得してください — 約 30 秒で完了し、無料プランはクレジットカード不要で 月 1,000 リクエスト です。サービス未使用なら、Finexly API ドキュメントに 5 分のクイックスタートがあります。
ステップ 1: Vite で Vue 3 プロジェクトを立ち上げる
ゼロから始める場合:
npm create vite@latest finexly-converter -- --template vue-ts
cd finexly-converter
npm install
npm run devVite は Vue 3、TypeScript、ホットモジュールリロードをすぐ使える状態で提供します。src/App.vue を開いてボイラープレートをクリアしてください — まもなくコンバーターに置き換えます。
既存の Nuxt 3 プロジェクトに統合する場合、このステップは飛ばせます。下記のコンポーザブルは vue の標準 ref と computed を使うので Nuxt でも同じように動きます。
ステップ 2: API キーを安全に保管する
Finexly API キーをコンポーネントに直接貼り付けないでください。.env.local に置きます:
VITE_FINEXLY_API_KEY=your_key_hereVite は VITE_ プレフィックスの変数をクライアントに公開します。Nuxt の場合は NUXT_PUBLIC_FINEXLY_API_KEY を使い、useRuntimeConfig().public.finexlyApiKey から読みます。
クライアントバンドルに鍵が露出するのが気になるなら、リクエストをバックエンドルートまたはサーバーレス関数経由でプロキシしてください。そのパターンはステップ 7 で示します。
ステップ 3: useCurrencyRates コンポーザブルを作る
コンポーザブルはコンバーターの中核です。fetch、キャッシュ、ローディング/エラー状態を引き受けて、コンポーネント側はプレゼンテーショナルに保ちます。
src/composables/useCurrencyRates.ts を作成:
import { ref, type Ref } from 'vue'
const API_KEY = import.meta.env.VITE_FINEXLY_API_KEY
const BASE_URL = 'https://api.finexly.com/v1'
// モジュールレベルのキャッシュ:このコンポーザブルを呼ぶ全コンポーネントで共有される。
// 各エントリの寿命は 5 分 — 即時に感じるのに十分長く、激しい FX 変動でも
// 精度を保てる程度に短い。
const cache = new Map<string, { rates: Record<string, number>; expires: number }>()
const TTL_MS = 5 * 60 * 1000
interface RatesResponse {
base: string
date: string
rates: Record<string, number>
}
export function useCurrencyRates() {
const rates: Ref<Record<string, number>> = ref({})
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchRates(base: string) {
const cached = cache.get(base)
if (cached && cached.expires > Date.now()) {
rates.value = cached.rates
return
}
loading.value = true
error.value = null
try {
const url = `${BASE_URL}/latest?base=${base}&apikey=${API_KEY}`
const res = await fetch(url)
if (!res.ok) throw new Error(`Finexly returned ${res.status}`)
const data: RatesResponse = await res.json()
rates.value = data.rates
cache.set(base, { rates: data.rates, expires: Date.now() + TTL_MS })
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load rates'
} finally {
loading.value = false
}
}
function convert(amount: number, from: string, to: string): number {
if (from === to) return amount
const fromRate = rates.value[from]
const toRate = rates.value[to]
if (!fromRate || !toRate) return 0
return (amount / fromRate) * toRate
}
return { rates, loading, error, fetchRates, convert }
}押さえておきたい点:
cacheは意図的にモジュールレベルに置いています。コンポーザブルを呼ぶ 2 つのコンポーネントが同じキャッシュを共有するので、ルート切替で同じ基準通貨を再フェッチしません。ratesはRecord<string, number>型なので、後で直接<select>に流し込めます。convert関数は クロスレート 計算を行うので、"from" 通貨が変わるたびに再フェッチする必要がありません。詳細はステップ 5 で。
ステップ 4: コンバーターコンポーネント
src/components/CurrencyConverter.vue を作成:
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useCurrencyRates } from '@/composables/useCurrencyRates'
const { rates, loading, error, fetchRates, convert } = useCurrencyRates()
const amount = ref(100)
const from = ref('USD')
const to = ref('EUR')
const converted = computed(() =>
convert(amount.value, from.value, to.value).toFixed(2)
)
const currencies = computed(() => Object.keys(rates.value).sort())
function swap() {
;[from.value, to.value] = [to.value, from.value]
}
let timeout: ReturnType<typeof setTimeout> | null = null
watch(from, (newBase) => {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => fetchRates(newBase), 300)
})
onMounted(() => fetchRates(from.value))
</script>
<template>
<div class="converter">
<h2>Currency Converter</h2>
<div class="row">
<input v-model.number="amount" type="number" min="0" />
<select v-model="from">
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<button @click="swap" aria-label="Swap currencies">⇅</button>
<div class="row">
<output>{{ loading ? '...' : converted }}</output>
<select v-model="to">
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template><CurrencyConverter /> を App.vue に置けば動作するコンバーターになります。最初の fetch が返ってくると通貨リストが埋まります。金額入力フィールドへの入力はネットワークアクセスなしにリアクティブに結果を更新します。スワップボタンで両側のドロップダウンが入れ替わります。"from" 通貨の変更でデバウンスされた再フェッチが走ります。
ステップ 5: クロスレート変換(リクエストを浪費しない)
単一基準のみの変換で十分なら、このセクションは飛ばして構いません。しかしほとんどのコンバーターでは両方の側をユーザーが選びます — そしてナイーブな実装は "from" 変更のたびに再フェッチしてしまいます。
クロスレート計算でこれを解決します。USD を基準にして EUR と JPY の対 USD レートを持っていれば、EUR → JPY のレートはまさに (1 / EUR_rate) * JPY_rate です。これがステップ 3 の convert 関数の処理です:
return (amount / fromRate) * toRateつまり セッションごとに 1 回 fetch すれば済む。無料枠クォータには大きな勝利。from のウォッチャーは防御策として機能します — キャッシュにない希少通貨をユーザーが選んだ場合、その通貨を基準に再フェッチすることで変換が動き続けます。
ステップ 6: ローディングスケルトンとエラー状態
スピナーで構いませんが、レート取得中のレイアウトシフトは目障りです。最初のフェッチではプレースホルダーオプションをレンダリング:
<select v-model="from" :disabled="loading && currencies.length === 0">
<option v-if="currencies.length === 0">Loading...</option>
<option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>エラー時は行き止まりメッセージではなく、リトライボタンを出します:
<div v-if="error" class="error-box">
<p>{{ error }}</p>
<button @click="fetchRates(from)">Retry</button>
</div>丁寧にやるなら 429(レート制限)と 5xx を別々に扱います。Finexly の月 1,000 無料リクエストと 5 分キャッシュなら、本物のスパイクが来ない限り上限に届きません — それでもクリーンなリトライ経路があると UI に堅牢さが出ます。
ステップ 7: API キーをサーバープロキシで隠す
import.meta.env.VITE_* の中身はクライアントバンドルに入ります。読み取り専用の通貨ウィジェットならたいてい許容範囲です — 最悪でも誰かが鍵を使ってレートを掠め取るくらい。深層防御が欲しいならサーバー側でリクエストをプロキシします。
Nuxt 3 のサーバールート例:
// server/api/rates.get.ts
export default defineEventHandler(async (event) => {
const { base } = getQuery(event)
const config = useRuntimeConfig()
return await $fetch(
`https://api.finexly.com/v1/latest?base=${base}&apikey=${config.finexlyApiKey}`
)
})そしてコンポーザブルを Finexly の origin ではなく /api/rates?base=${base} に向けます。鍵はサーバーから出ません。同じパターンが Vue のメタフレームワーク全般で使えます — Nuxt、Quasar、ただの Express バックエンドでも。
ステップ 8: 本番チェックリスト
リリース前に:
- 積極的にキャッシュする。 FX レートは毎秒変わりません。クライアント 5 分・サーバー 1 分の TTL でほぼ全ての表示用途に十分です。本番実績のあるパターンは キャッシュとエラーハンドリングのガイド を参照。
- 正しく丸める。 通貨表示は通貨の最小単位に丸めます(USD は 2 桁、JPY は 0 桁、KWD は 3 桁)。
.toFixed(2)ではなくIntl.NumberFormat(locale, { style: 'currency', currency: to.value })を使ってください。 Intlでフォーマット。new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(123.45)は「€123.45」を返し、ユーザーのロケールも尊重します。- SSR セーフな fetch。 Nuxt では
onMounted内の生fetchよりもuseFetchを優先し、サーバーレンダリング中にレートが利用可能でハイドレーション不一致を防ぎます。 - クォータを監視する。 月の予算の 80% を超えたら警告する小さなロガーを追加。より大きな量には 料金プラン が無料枠の続きから始まります。
Vue 2(Options API)版
Vue 2 を使っているなら、同じロジックがほぼ行ごとに翻訳できます:
export default {
data() {
return { rates: {}, loading: false, error: null, amount: 100, from: 'USD', to: 'EUR' }
},
computed: {
converted() {
const fr = this.rates[this.from]
const tr = this.rates[this.to]
return fr && tr ? ((this.amount / fr) * tr).toFixed(2) : '0.00'
},
currencies() {
return Object.keys(this.rates).sort()
},
},
mounted() { this.fetchRates(this.from) },
watch: {
from(newBase) { this.fetchRates(newBase) },
},
methods: {
async fetchRates(base) {
this.loading = true
try {
const res = await fetch(
`https://api.finexly.com/v1/latest?base=${base}&apikey=${process.env.VUE_APP_FINEXLY_KEY}`
)
const data = await res.json()
this.rates = data.rates
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
},
}Composition API はコンポーネント間でロジックを共有するのに整然としていますが、単一のコンバーターウィジェット程度なら Options API でも問題なく動きます。チームがより関数的なアプローチを好むなら、JavaScript 統合ガイド がフレームワーク非依存のパターンをカバーしています。
よくある落とし穴(と回避法)
Nuxt のハイドレーション不一致。 onMounted 内の fetch 呼び出しはクライアントでは動きますが SSR の整合性を壊します。代わりに useFetch または useAsyncData を使ってください。
長時間セッション後の古いレート。 上記の 5 分 TTL だと、丸一日開いたままのタブは 1 時間前のレートを表示します。visibilitychange でリフレッシュ:
document.addEventListener('visibilitychange', () => {
if (!document.hidden) fetchRates(from.value)
})浮動小数点ドリフト。 JavaScript の数値は double です。0.1 + 0.2 !== 0.3。金銭金額には整数のセントに乗算してから(amount * 100)計算し、最後に割り戻してください。チェックアウト関連のものには dinero.js のようなライブラリを使うのも手です。
開発中の CORS エラー。 ブラウザからの直接呼び出しを許さない通貨 API もあります。Finexly はクライアント利用のためのブラウザ origin を許可していますが、それ以外はステップ 7 のプロキシで解決します。
Vue プロジェクトに Finexly を選ぶ理由
フロントエンドアプリ向けの為替レート API選びで重要なのは、応答時間、精度、無料枠の寛大さ、クリーンな JSON です。Finexly は 4 つすべてを狙います — sub-50ms の p95 レイテンシ、60 秒ごとに更新される mid-market レート、170 以上の通貨、加工なしで Vue の ref に落ちる JSON 形式。
代替手段との比較は通貨 API 比較でトレードオフを詳しく解説しています。または並列比較ページも使えます。
よくある質問
このチュートリアルを Vue 2 で使えますか?
はい。コンポーザブルパターンは Vue 3 専用ですが、根底のロジック — レートを取得し data に格納して変換を計算する — は Options API でも同じように動きます。上の Vue 2 サンプルがそのまま使えます。
Finexly API は Vue プロジェクトで無料ですか?
無料プランは月 1,000 リクエストで、副業プロジェクト、ポートフォリオ、小さな SaaS には十分すぎます。5 分キャッシュなら日次アクティブユーザー約 200 人を支えられます。より大きな量は料金プランを参照。
API キーがクライアントバンドルに露出しないようにするには?
ステップ 7 のようにサーバールート経由でリクエストをプロキシします。VITE_* と NUXT_PUBLIC_* プレフィックスはどちらも変数をクライアントから見える状態にします。機微なものはサーバー関数の背後に置いてください。
レートはどれくらい正確ですか?
Finexly は複数の Tier-1 流動性プロバイダーから mid-market レートを集約し、60 秒ごとにリフレッシュします。表示や多くの値付け用途には十分な精度です。トレード執行用にはストリーミングフィードが欲しいでしょう — REST vs WebSocket ガイドを参照。
過去の金額を変換できますか?
はい — Finexly の /historical エンドポイントは日付パラメーターを受け取って任意の営業日のレートを返します。パターンは上の /latest エンドポイントと同じで、URL を入れ替えるだけ。履歴為替レート API ガイドで詳しく扱っています。
まとめ
これで、現実的な懸念事項 — キャッシュ、デバウンス、エラー状態、SSR、API キーの安全性 — を扱う Vue 3 通貨コンバーターが手に入りました。コンポーザブルパターンのおかげで、同じ useCurrencyRates をナビゲーションバーのウィジェット、チェックアウトページ、ダッシュボードのチャートに、フェッチロジックを書き直すことなく投入できます。
自分のプロジェクトで試す準備はできましたか?Finexly の無料 API キーを取得 — クレジットカード不要。月 1,000 の無料リクエストから始め、トラフィックが無料枠を超えたらアップグレードしてください。
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →