Если вам нужен Vue-конвертер валют, который тянет курсы в реальном времени из живого API, этот туториал проведёт вас от начала до конца. Вы построите готовый к продакшену компонент Vue 3 с помощью Composition API, TypeScript и бесплатного API курсов валют — с правильным debouncing, кешированием, обработкой ошибок и SSR-совместимыми паттернами для Nuxt.
К концу у вас будет переиспользуемый компонент <CurrencyConverter />, composable useCurrencyRates, который можно положить в любой Vue 3 проект, и чёткое понимание компромиссов, важных при выкатывании конвертации валют реальным пользователям.
Что вы построите
Конвертер валют на Vue 3 со следующими фичами:
- Живые курсы для 170+ валют через Finexly API
- Реактивная конвертация, обновляющаяся при вводе
- Кнопка перестановки валют (USD → EUR превращается в EUR → USD одним кликом)
- Debounced API-вызовы, чтобы не спамить сеть
- In-memory кеш для низкой задержки и соблюдения бесплатной квоты
- Обработка ошибок, состояния загрузки и аккуратные TypeScript-типы
- SSR-совместимость, чтобы работало в Nuxt 3 без ошибок гидрации
Финальный компонент — около 80 строк кода. Composable — ещё 60. Всё.
Предварительные требования
Вы должны быть знакомы с:
- Синтаксисом Vue 3 (форма
<script setup>) - Базовым TypeScript
- Вызовами REST API через
fetch
Вам также понадобится API-ключ Finexly. Получите его в панели — занимает около 30 секунд, бесплатный план даёт 1 000 запросов в месяц без кредитной карты. Если ещё не пользовались сервисом, в документации Finexly API есть 5-минутный quickstart.
Шаг 1: Создание проекта Vue 3 с Vite
Если вы начинаете с нуля:
npm create vite@latest finexly-converter -- --template vue-ts
cd finexly-converter
npm install
npm run devVite даёт Vue 3, TypeScript и hot module reload из коробки. Откройте src/App.vue и очистите шаблон — мы скоро заменим его на конвертер.
Если интегрируетесь в существующий Nuxt 3 проект, этот шаг можно пропустить. Composable ниже работает в Nuxt идентично, потому что использует стандартные ref и computed из vue.
Шаг 2: Безопасное хранение API-ключа
Никогда не вставляйте API-ключ Finexly прямо в компонент. Положите его в .env.local:
VITE_FINEXLY_API_KEY=ваш_ключ_здесьVite экспонирует любую переменную с префиксом VITE_ клиенту. Для Nuxt используйте NUXT_PUBLIC_FINEXLY_API_KEY и читайте её из useRuntimeConfig().public.finexlyApiKey.
Если беспокоитесь, что ключ окажется в клиентском бандле, проксируйте запрос через бэкенд-роут или serverless-функцию. Этот паттерн покажем в Шаге 7.
Шаг 3: Создание composable useCurrencyRates
Composable — сердце конвертера. Он отвечает за 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'
// Кеш на уровне модуля: общий для всех компонентов, вызывающих этот composable.
// Каждая запись живёт 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намеренно живёт на уровне модуля. Два компонента, вызывающие composable, делят один кеш — переключение маршрутов не вызывает повторный fetch той же базовой валюты.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 — и у вас рабочий конвертер. Список валют наполняется, как только приходит первый ответ. Ввод в поле количества обновляет результат реактивно, без обращения к сети. Кнопка перестановки меняет местами выпадающие списки. Смена валюты "from" триггерит debounced-рефетч.
Шаг 5: Конвертация по кросс-курсу (чтобы не жечь запросы)
Если вы конвертируете только из одной базы, эту секцию можно пропустить. Но большинство конвертеров позволяют пользователю выбирать обе стороны — а наивная реализация ре-фетчит при каждой смене "from".
Математика кросс-курса решает это. Если у вас USD как база и известны курсы EUR и JPY относительно USD, то курс EUR → JPY — это просто (1 / EUR_rate) * JPY_rate. Именно это делает функция convert из Шага 3:
return (amount / fromRate) * toRateЭто значит, что достаточно одного fetch на сессию. Большая победа для бесплатных квот. Watcher на from становится защитной мерой — если пользователь выберет экзотическую валюту, отсутствующую в кешированной таблице, рефетч с этой базой гарантирует, что конвертация продолжит работать.
Шаг 6: Loading-скелетоны и состояния ошибок
Спиннеры — нормально, но layout-shift при загрузке курсов раздражает. Рендерьте плейсхолдеры в выпадашке при первом fetch:
<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 (rate-limit) и 5xx раздельно. С 1 000 бесплатных запросов в месяц у Finexly и кешем на 5 минут понадобится реальный всплеск, чтобы упереться в лимит — но чистый retry-путь делает UI крепким.
Шаг 7: Спрятать API-ключ за серверным прокси
Всё, что в import.meta.env.VITE_*, попадает в клиентский бандл. Для большинства read-only валютных виджетов это допустимо — худший случай, что кто-то скрейпит курсы с вашим ключом. Если хотите глубокую защиту, проксируйте запрос на сервере.
Серверный роут 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}`
)
})Затем направьте composable на /api/rates?base=${base} вместо origin-а Finexly. Ключ никогда не покидает сервер. Тот же паттерн работает в любом мета-фреймворке Vue — Nuxt, Quasar или простой Express-бэкенд.
Шаг 8: Чек-лист продакшена
Перед релизом:
- Кешируйте агрессивно. Курсы FX не меняются каждую секунду. TTL 5 минут на клиенте и 1 минута на сервере — более чем достаточно почти для любого случая отображения. См. наш гайд по кешированию и обработке ошибок — паттерны, проверенные в продакшене.
- Округляйте корректно. Отображение валюты округляется до минимальной единицы валюты (2 знака для USD, 0 для JPY, 3 для KWD). Используйте
Intl.NumberFormat(locale, { style: 'currency', currency: to.value })вместо.toFixed(2). - Форматируйте через
Intl.new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(123.45)даёт «€123.45» и уважает локаль пользователя. - SSR-безопасные fetch. В Nuxt предпочитайте
useFetchсыромуfetchвнутриonMounted, чтобы курсы были доступны при серверном рендере и не вызывали ошибок гидрации. - Мониторьте квоту. Добавьте маленький логгер, который предупреждает при сжигании 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. Вызов fetch в onMounted работает на клиенте, но ломает SSR-консистентность. Используйте useFetch или useAsyncData.
Устаревшие курсы после долгой сессии. TTL 5 минут означает, что вкладка, открытая весь день, показывает курсы часовой давности. Обновляйте по 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 решает остальные случаи.
Почему Finexly для Vue-проектов
Несколько вещей важны при выборе API курсов валют для фронтенд-приложения: время отклика, точность, щедрость бесплатного тарифа, чистый JSON. Finexly целится во все четыре — p95-задержка sub-50ms, mid-market курсы, обновляемые каждые 60 секунд, 170+ валют и форма JSON, которая ложится прямо в Vue ref без обработки.
Если хотите увидеть, как он сравнивается с альтернативами, наше сравнение валютных API детально проходит по компромиссам. Или используйте страницу сравнения бок о бок.
Часто задаваемые вопросы
Можно использовать этот туториал с Vue 2?
Да. Composable-паттерн только для Vue 3, но подлежащая логика — фетчить курсы, сохранять в data, считать конвертацию — работает в Options API одинаково. Пример Vue 2 выше — прямая замена.
Бесплатен ли Finexly API для Vue-проектов?
Бесплатный план даёт 1 000 запросов в месяц — более чем достаточно для пет-проекта, портфолио или небольшого SaaS. С 5-минутным кешем это выдерживает около 200 ежедневных активных пользователей. См. тарифные планы для больших объёмов.
Как избежать утечки API-ключа в клиентский бандл?
Проксируйте запрос через серверный роут, как в Шаге 7. Префиксы VITE_* и NUXT_PUBLIC_* оба делают переменные видимыми клиенту. Всё чувствительное должно жить за серверной функцией.
Насколько точны курсы?
Finexly агрегирует mid-market курсы от нескольких Tier-1 провайдеров ликвидности и обновляет каждые 60 секунд. Достаточно точно для отображения и большинства pricing-применений. Для исполнения сделок нужен стриминг-фид — см. наш гайд REST vs WebSocket.
Можно конвертировать исторические суммы?
Да — эндпоинт /historical Finexly принимает параметр даты и возвращает курсы любого рабочего дня. Паттерн идентичен эндпоинту /latest выше; просто меняется URL. Подробно в гайде по API исторических курсов.
Заключение
Теперь у вас есть конвертер валют на Vue 3, который справляется с реальными задачами — кешированием, debouncing, состояниями ошибок, SSR и безопасностью API-ключа. Composable-паттерн означает, что вы можете положить тот же useCurrencyRates в виджет навбара, страницу чекаута или график дашборда, не переписывая логику fetch.
Готовы попробовать в собственном проекте? Получите бесплатный API-ключ Finexly — без кредитной карты. Начните с 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 →