Розробка фронтенда dApp на Vue.js
Vue.js у Web3 — не другий сорт після React. Проблема в тому, що більшість Web3 бібліотек писалися React-first: wagmi, RainbowKit, ConnectKit — все це React hooks. Для Vue потрібен інший стек, і він існує: wagmi/core (framework-agnostic) + vue-query + web3modal. Або ethers.js напряму з Vue 3 Composition API. Розберемо обидва варіанти.
Stack та архітектура
Варіант 1: wagmi/core + vue-query
@wagmi/core — headless версія wagmi без прив'язки до React. Все actions (connect, readContract, writeContract, watchAccount) працюють як звичайні функції. vue-query (TanStack Query для Vue) управляє кешуванням та реактивністю.
// wagmi.config.ts
import { createConfig, http } from '@wagmi/core'
import { mainnet, polygon } from '@wagmi/core/chains'
import { walletConnect, injected } from '@wagmi/connectors'
export const config = createConfig({
chains: [mainnet, polygon],
connectors: [
injected(), // MetaMask та інші EIP-1193
walletConnect({ projectId: import.meta.env.VITE_WC_PROJECT_ID }),
],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
},
})
Composable для роботи з аккаунтом:
// composables/useWallet.ts
import { ref, computed, watchEffect } from 'vue'
import { connect, disconnect, getAccount, watchAccount } from '@wagmi/core'
import { config } from '@/wagmi.config'
export function useWallet() {
const account = ref(getAccount(config))
const unwatch = watchAccount(config, {
onChange(data) { account.value = data }
})
onUnmounted(() => unwatch())
return {
address: computed(() => account.value.address),
isConnected: computed(() => account.value.isConnected),
chainId: computed(() => account.value.chainId),
connect: (connector) => connect(config, { connector }),
disconnect: () => disconnect(config),
}
}
Варіант 2: ethers.js + Vue 3 Composition API
Якщо проект вже використовує ethers.js або команда краще знає ethers:
// composables/useEthers.ts
import { ref, shallowRef } from 'vue'
import { BrowserProvider, JsonRpcSigner } from 'ethers'
export function useEthers() {
const provider = shallowRef<BrowserProvider | null>(null)
const signer = shallowRef<JsonRpcSigner | null>(null)
const address = ref<string | null>(null)
async function connectWallet() {
if (!window.ethereum) throw new Error('No wallet detected')
const _provider = new BrowserProvider(window.ethereum)
const _signer = await _provider.getSigner()
provider.value = _provider
signer.value = _signer
address.value = await _signer.getAddress()
window.ethereum.on('accountsChanged', (accounts: string[]) => {
address.value = accounts[0] ?? null
})
window.ethereum.on('chainChanged', () => window.location.reload())
}
return { provider, signer, address, connectWallet }
}
shallowRef для provider та signer — важлива деталь. Глибока реактивність Vue на ethers.js об'єктах викликає performance проблеми та дивні баги.
Читання даних контракту
З vue-query кешування та refetch працюють прозоро:
// composables/useTokenBalance.ts
import { useQuery } from '@tanstack/vue-query'
import { readContract } from '@wagmi/core'
import { erc20Abi } from 'viem'
import { config } from '@/wagmi.config'
export function useTokenBalance(tokenAddress: Ref<`0x${string}`>, owner: Ref<`0x${string}` | undefined>) {
return useQuery({
queryKey: computed(() => ['balance', tokenAddress.value, owner.value]),
queryFn: () => readContract(config, {
address: tokenAddress.value,
abi: erc20Abi,
functionName: 'balanceOf',
args: [owner.value!],
}),
enabled: computed(() => !!owner.value),
staleTime: 10_000, // 10 секунд кеш
})
}
Запис у контракт та стан трансакції
Мутації через vue-query useMutation, стан трансакції через waitForTransactionReceipt:
import { useMutation } from '@tanstack/vue-query'
import { writeContract, waitForTransactionReceipt } from '@wagmi/core'
export function useApprove(tokenAddress: Ref<`0x${string}`>) {
return useMutation({
mutationFn: async ({ spender, amount }: { spender: `0x${string}`, amount: bigint }) => {
const hash = await writeContract(config, {
address: tokenAddress.value,
abi: erc20Abi,
functionName: 'approve',
args: [spender, amount],
})
await waitForTransactionReceipt(config, { hash })
return hash
},
})
}
У компоненті:
<script setup>
const { mutate: approve, isPending, isSuccess, error } = useApprove(tokenAddress)
</script>
<template>
<button @click="approve({ spender, amount })" :disabled="isPending">
{{ isPending ? 'Підтвердіть у гаманці...' : 'Approve' }}
</button>
<p v-if="error">{{ error.shortMessage }}</p>
</template>
Web3Modal для Multi-wallet підключення
WalletConnect's Web3Modal працює з Vue через @web3modal/wagmi/vue:
import { createWeb3Modal } from '@web3modal/wagmi/vue'
createWeb3Modal({
wagmiConfig: config,
projectId: import.meta.env.VITE_WC_PROJECT_ID,
enableAnalytics: false,
})
Після цього в будь-якому компоненті:
<w3m-button />
Готовий UI з підтримкою MetaMask, WalletConnect, Coinbase Wallet, Injected та 300+ гаманців. Кастомізація через CSS змінні.
State management: Pinia для глобального Web3 стану
Для даних, які потрібні в кількох несвязаних компонентах (баланс, allowances), Pinia store:
// stores/web3.ts
import { defineStore } from 'pinia'
import { useWallet } from '@/composables/useWallet'
export const useWeb3Store = defineStore('web3', () => {
const wallet = useWallet()
// computed getters, actions для batch операцій
return { ...wallet }
})
Сборка: Vite + @vitejs/plugin-vue
Node polyfills для бібліотек, які очікують Node.js окружень (деякі частини ethers.js):
// vite.config.ts
import { nodePolyfills } from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [vue(), nodePolyfills({ include: ['buffer', 'stream', 'util'] })],
resolve: {
alias: { '@': '/src' }
}
})
Без polyfills Buffer is not defined або process is not defined — класичні помилки при першому запуску Web3 проекту на Vite.







