Назад к блогу

Как построить конвертер валют на Vue.js с живым API курсов (Гайд 2026)

V
Vlado Grigirov
April 28, 2026
Currency API Vue.js Tutorial Exchange Rates JavaScript Finexly Composition API

Если вам нужен 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 dev

Vite даёт 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 бесплатных запросов в месяц и апгрейдитесь, только когда трафик перерастёт бесплатный план.

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 →