分类: Web3前端开发

  • Web3前端开发完整教程 | wagmi+viem实战指南2026

    Web3前端开发完整教程 | wagmi+viem实战指南2026

    一、wagmi和viem是什么

    1.1 为什么要用wagmi

    直接用ethers.js或viem写以太坊交互也能工作,但涉及到React状态管理、缓存、自动重连、错误处理这些「脚手架」代码时,工作量就上来了。wagmi把这些都封装成React Hooks,让开发者专注于业务逻辑。

    wagmi的核心价值:

    • 开箱即用的React Hooks:连接钱包、读取数据、发送交易都有现成方案
    • 自动状态管理:连接状态、链ID、余额变化自动同步到组件
    • 缓存与去重:基于TanStack Query,不会重复发送相同的请求
    • TypeScript优先:类型提示完善,开发时就能发现错误

    1.2 viem的角色

    viem是底层库,负责与以太坊节点的通信。它比ethers.js更轻量、更快,提供了完整的以太坊JSON-RPC API封装。wagmi依赖viem作为传输层,同时提供React层面的抽象。

    两者的关系:viem做网络和编码工作,wagmi做React集成工作。

    展示wagmi与viem技术栈的协作架构图,左侧为用户界面(连接钱包按钮、代币余额),中间为wagmi的五个核心功能模块,右侧为viem RPC调用与以太坊区块链节点,通过连线清晰展示数据流向

    二、项目初始化

    2.1 创建React项目

    我习惯用Vite创建项目,比Create React App快很多:

    bash

    npm create vite@latest my-web3-dapp -- --template react-ts
    cd my-web3-dapp
    npm install
    

    2.2 安装依赖

    bash

    # 核心依赖
    npm install wagmi viem @tanstack/react-query
    
    # UI组件库(可选,推荐RainbowKit)
    npm install @rainbow-me/rainbowkit
    npm install @tanstack/react-query
    
    # 用于样式(Vite默认没有CSS方案)
    npm install tailwindcss postcss autoprefixer
    npx tailwindcss init -p
    

    2.3 基础配置

    创建wagmi配置文件:

    typescript

    // src/wagmi.config.ts
    import { http, createConfig } from 'wagmi'
    import { mainnet, sepolia } from 'wagmi/chains'
    import { injected, walletConnect } from 'wagmi/connectors'
    
    // WalletConnect项目ID(https://cloud.walletconnect.com/ 注册)
    const projectId = import.meta.env.VITE_WALLET_CONNECT_PROJECT_ID
    
    export const config = createConfig({
      chains: [mainnet, sepolia],
      connectors: [
        injected(),           // 浏览器插件钱包(MetaMask、Coinbase Wallet等)
        walletConnect({ 
          projectId,
          metadata: {
            name: 'My Web3 DApp',
            description: 'A sample Web3 application',
            url: window.location.origin,
            icons: ['https://example.com/icon.png']
          }
        }),
      ],
      transports: {
        [mainnet.id]: http(),  // 使用公共RPC,生产环境建议用自己的节点
        [sepolia.id]: http(),
      },
    })
    

    2.4 在main.tsx中配置Provider

    typescript

    // src/main.tsx
    import React from 'react'
    import ReactDOM from 'react-dom/client'
    import { WagmiProvider } from 'wagmi'
    import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
    import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
    import App from './App'
    import { config } from './wagmi.config'
    import '@rainbow-me/rainbowkit/styles.css'  // RainbowKit样式
    
    const queryClient = new QueryClient()
    
    ReactDOM.createRoot(document.getElementById('root')!).render(
      <React.StrictMode>
        <WagmiProvider config={config}>
          <QueryClientProvider client={queryClient}>
            <RainbowKitProvider>
              <App />
            </RainbowKitProvider>
          </QueryClientProvider>
        </WagmiProvider>
      </React.StrictMode>,
    )
    

    三、钱包连接实战

    3.1 连接按钮组件

    使用RainbowKit可以快速获得美观的连接按钮:

    typescript

    // src/components/ConnectButton.tsx
    import { ConnectButton } from '@rainbow-me/rainbowkit'
    
    export function WalletConnect() {
      return (
        <ConnectButton
          showBalance={{
            smallScreen: false,
            largeScreen: true,
          }}
          chainStatus="icon"
          accountStatus={{
            smallScreen: 'avatar',
            largeScreen: 'full',
          }}
        />
      )
    }
    

    3.2 自定义连接逻辑

    如果不使用RainbowKit,可以自己实现:

    typescript

    // src/components/CustomConnect.tsx
    import { useAccount, useConnect, useDisconnect } from 'wagmi'
    import { injected } from 'wagmi/connectors'
    
    export function CustomConnect() {
      const { address, isConnected } = useAccount()
      const { connect } = useConnect()
      const { disconnect } = useDisconnect()
    
      if (isConnected) {
        return (
          <div>
            <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
            <button onClick={() => disconnect()}>Disconnect</button>
          </div>
        )
      }
    
      return (
        <button onClick={() => connect({ connector: injected() })}>
          Connect Wallet
        </button>
      )
    }
    

    四、读取区块链数据

    4.1 读取原生币余额

    typescript

    import { useBalance, useAccount } from 'wagmi'
    
    function ETHBalance() {
      const { address, isConnected } = useAccount()
      
      const { data, isError, isLoading } = useBalance({
        address,
        query: {
          enabled: !!isConnected,
        }
      })
    
      if (!isConnected) return <p>Please connect your wallet</p>
      if (isLoading) return <p>Loading...</p>
      if (isError) return <p>Error fetching balance</p>
    
      return (
        <div>
          <p>Balance: {data?.formatted} {data?.symbol}</p>
          <p>In USD: ${(Number(data?.formatted) * 3500).toFixed(2)}</p>
        </div>
      )
    }
    

    4.2 读取ERC20代币余额

    需要自己构建读取合约的请求:

    typescript

    import { useContractRead, useAccount } from 'wagmi'
    import { erc20Abi } from 'viem'
    
    // USDC地址(主网)
    const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
    
    function USDCBalance() {
      const { address, isConnected } = useAccount()
      
      const { data: balance } = useContractRead({
        address: USDC_ADDRESS,
        abi: erc20Abi,
        functionName: 'balanceOf',
        args: [address!],
        query: {
          enabled: !!address,
        }
      })
    
      if (!isConnected) return <p>Please connect your wallet</p>
      
      // USDC是6位精度
      const formattedBalance = Number(balance) / 10 ** 6
      
      return <p>USDC Balance: {formattedBalance.toFixed(2)}</p>
    }
    

    4.3 批量读取多个合约

    useContractReads可以一次读取多个合约的状态,减少网络请求:

    typescript

    import { useContractReads, useAccount } from 'wagmi'
    import { erc20Abi } from 'viem'
    
    const TOKENS = [
      { address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', symbol: 'USDC', decimals: 6 },
      { address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', symbol: 'WETH', decimals: 18 },
    ]
    
    function TokenBalances() {
      const { address, isConnected } = useAccount()
    
      const { data, isLoading } = useContractReads({
        contracts: TOKENS.map((token) => ({
          address: token.address as `0x${string}`,
          abi: erc20Abi,
          functionName: 'balanceOf',
          args: [address!],
        })),
        query: { enabled: !!address }
      })
    
      if (!isConnected) return <p>Connect wallet</p>
      if (isLoading) return <p>Loading...</p>
    
      return (
        <div>
          {TOKENS.map((token, index) => {
            const balance = data?.[index]?.result
            const formatted = balance ? Number(balance) / 10 ** token.decimals : 0
            return (
              <p key={token.symbol}>
                {token.symbol}: {formatted.toFixed(4)}
              </p>
            )
          })}
        </div>
      )
    }
    

    五、发送交易

    5.1 发送ETH

    typescript

    import { useSendTransaction, useWaitForTransactionReceipt } from 'wagmi'
    import { parseEther } from 'viem'
    
    function SendETH() {
      const { sendTransaction, data: hash, isPending, error } = useSendTransaction()
      const { isLoading: isConfirming, isSuccess: isConfirmed } = useWaitForTransactionReceipt({
        hash,
      })
    
      async function handleSend(to: string, amount: string) {
        sendTransaction({
          to: to as `0x${string}`,
          value: parseEther(amount),  // 字符串转成Wei
        })
      }
    
      return (
        <div>
          <button 
            onClick={() => handleSend('0x...', '0.01')}
            disabled={isPending}
          >
            {isPending ? 'Confirming...' : 'Send 0.01 ETH'}
          </button>
          
          {error && <p>Error: {error.message}</p>}
          {isConfirming && <p>Waiting for confirmation...</p>}
          {isConfirmed && <p>Transaction confirmed!</p>}
        </div>
      )
    }
    

    5.2 调用合约函数

    以ERC20的transfer为例:

    typescript

    import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
    import { erc20Abi } from 'viem'
    
    const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
    
    function TransferUSDC() {
      const { 
        writeContract, 
        data: hash, 
        isPending, 
        error 
      } = useWriteContract()
      
      const { isLoading: isConfirming, isSuccess: isConfirmed } = 
        useWaitForTransactionReceipt({ hash })
    
      function handleTransfer(to: string, amount: string) {
        // amount是USDC的原始精度(6位小数)
        writeContract({
          address: USDC_ADDRESS,
          abi: erc20Abi,
          functionName: 'transfer',
          args: [
            to as `0x${string}`,
            BigInt(Math.floor(Number(amount) * 10 ** 6))
          ],
        })
      }
    
      return (
        <div>
          <button
            onClick={() => handleTransfer('0x...', '100')}
            disabled={isPending}
          >
            {isPending ? 'Sign in wallet...' : 'Transfer 100 USDC'}
          </button>
          
          {isConfirming && <p>Transaction submitting...</p>}
          {isConfirmed && <p>Transfer complete!</p>}
          {error && <p>Error: {error.shortMessage || error.message}</p>}
        </div>
      )
    }
    

    六、实战案例:DeFi代币兑换界面

    6.1 组件结构

    一个简单的代币兑换界面需要:

    • 代币选择器
    • 金额输入
    • 汇率显示
    • Swap按钮
    • 交易状态显示

    typescript

    // src/components/Swap.tsx
    import { useState } from 'react'
    import { useAccount, useBalance } from 'wagmi'
    import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
    import { parseUnits, formatUnits } from 'viem'
    
    // 简化示例:直接调用Uniswap V2 Router
    const ROUTER_ADDRESS = '0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D'
    
    export function Swap() {
      const { address, isConnected } = useAccount()
      
      // 代币信息
      const [tokenIn, setTokenIn] = useState('ETH')
      const [tokenOut, setTokenOut] = useState('USDC')
      const [amountIn, setAmountIn] = useState('')
      
      const { writeContract, data: hash, isPending } = useWriteContract()
      const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash })
    
      // 读取ETH余额
      const { data: ethBalance } = useBalance({ address })
    
      // 处理Swap
      async function handleSwap() {
        if (!amountIn) return
        
        const amountInWei = parseEther(amountIn)
        
        writeContract({
          address: ROUTER_ADDRESS,
          abi: UNISWAP_V2_ROUTER_ABI,
          functionName: 'swapExactETHForTokens',
          args: [
            BigInt(0),  // 最小输出金额,生产环境应该设合理值
            [WETH_ADDRESS, USDC_ADDRESS],  // 交易路径
            address!,
            BigInt(Math.floor(Date.now() / 1000) + 60 * 20)  // 20分钟过期
          ],
          value: amountInWei,
        })
      }
    
      if (!isConnected) {
        return <p>Please connect your wallet</p>
      }
    
      return (
        <div className="swap-container">
          <div className="input-group">
            <label>You Pay</label>
            <input 
              type="number"
              value={amountIn}
              onChange={(e) => setAmountIn(e.target.value)}
              placeholder="0.0"
            />
            <span>{tokenIn}</span>
            <p>Balance: {ethBalance?.formatted}</p>
          </div>
          
          <button onClick={handleSwap} disabled={isPending || !amountIn}>
            {isPending ? 'Sign in wallet...' : 'Swap'}
          </button>
          
          {isSuccess && <p>Swap successful!</p>}
        </div>
      )
    }
    

    七、常见问题与解决方案

    7.1 用户拒绝签名

    用户可能拒绝交易或者根本就没装钱包:

    typescript

    const { writeContract, error } = useWriteContract()
    
    // error.shortMessage 通常包含有用的信息
    // "User rejected the request" = 用户拒绝
    // "insufficient funds" = 余额不足
    

    7.2 链不匹配

    用户连接的链与DApp不匹配时,需要提示切换:

    typescript

    import { useChainId, useSwitchChain } from 'wagmi'
    
    function NetworkWarning() {
      const chainId = useChainId()
      const { switchChain } = useSwitchChain()
      
      if (chainId !== sepolia.id) {
        return (
          <div className="warning">
            <p>Please switch to Sepolia testnet</p>
            <button onClick={() => switchChain({ chainId: sepolia.id })}>
              Switch Network
            </button>
          </div>
        )
      }
    }
    

    7.3 交易Pending处理

    用户发起交易后,有时候会Pending很久:

    typescript

    import { useWaitForTransactionReceipt } from 'wagmi'
    
    const { isLoading: isPending } = useWaitForTransactionReceipt({
      hash,
      // 超时设置
      timeout: 1000 * 60 * 5, // 5分钟超时
    })
    
    if (isPending) {
      return <p>Transaction pending for a while... Please check your wallet.</p>
    }
    

    八、生产环境注意事项

    8.1 使用自己的RPC节点

    公共RPC有速率限制,生产环境应该用Alchemy或Infura:

    typescript

    import { http } from 'wagmi'
    import { mainnet } from 'wagmi/chains'
    
    const alchemyKey = import.meta.env.VITE_ALCHEMY_API_KEY
    
    export const config = createConfig({
      chains: [mainnet],
      transports: {
        [mainnet.id]: http(`https://eth-mainnet.g.alchemy.com/v2/${alchemyKey}`),
      },
    })
    

    8.2 其他优化建议

    注意缓存策略和错误边界。TanStack Query的缓存配置要适合区块链数据的实时性,错误边界能防止单个组件崩溃影响整个应用。

    结语

    wagmi和viem让Web3前端开发变得异常流畅。你不需要成为以太坊专家,只需要理解几个核心概念——连接钱包、读取数据、发送交易——就能构建出体验良好的DApp。

    我建议的学习路径是:

    1. 先跑通官方的示例项目
    2. 学会连接钱包和读取余额
    3. 尝试发送一笔简单的ETH转账
    4. 挑战一个完整的合约调用(比如ERC20转账)
    5. 最后实现一个简单的DeFi交互界面

    Web3前端开发的门槛已经很低了,缺的只是更多有热情加入的开发者。

    相关阅读: