HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • Web3 完整技术体系

    • Web3 完整技术体系
    • 区块链基础与密码学
    • 第一章:比特币原理与实现
    • 第二章:以太坊架构与核心概念
    • Solidity智能合约开发基础
    • 04-Solidity进阶与安全
    • 05-ERC标准详解
    • 06-DeFi核心协议-去中心化交易所
    • 07-DeFi核心协议-借贷协议
    • 08-DeFi核心协议-稳定币
    • NFT与元宇宙
    • Layer2扩容方案
    • 跨链技术
    • Web3前端开发
    • 链下数据与预言机
    • 智能合约测试与部署
    • MEV与交易优化
    • DAO治理
    • 项目实战:完整DeFi借贷协议

Web3前端开发

章节导读

Web3前端开发与传统Web开发有着本质的区别。用户不再依赖中心化的后端服务器,而是直接通过钱包与区块链交互。这带来了去中心化的优势,但也引入了新的挑战:钱包连接、交易签名、Gas管理、网络切换等。

本章将系统讲解Web3前端开发的核心技术,包括ethers.js、web3.js的使用,钱包集成,React Hooks最佳实践,以及完整的DApp开发流程。我们会通过一个完整的DEX项目,掌握Web3前端的所有关键技能。

学习路线

Web3基础
├── ethers.js vs web3.js
├── Provider与Signer
├── 合约交互
└── 事件监听

钱包集成
├── MetaMask连接
├── WalletConnect
├── 多钱包支持
└── 钱包状态管理

React开发
├── Web3 Hooks
├── 状态管理
├── 交易处理
└── 错误处理

完整项目
├── DEX界面
├── Swap功能
├── 流动性管理
└── 用户资产展示

一、Web3库对比

1.1 ethers.js vs web3.js

特性ethers.jsweb3.js
体积小(~88KB)大(~200KB)
API设计现代、简洁传统
TypeScript原生支持需要@types
文档优秀一般
社区活跃活跃
推荐度

推荐使用ethers.js,下面的示例都基于ethers.js v6。

1.2 安装和基础使用

npm install ethers

基础示例

import { ethers } from 'ethers';

// 1. 连接到以太坊网络
const provider = new ethers.JsonRpcProvider('https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY');

// 2. 查询余额
const balance = await provider.getBalance('0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb');
console.log('Balance:', ethers.formatEther(balance), 'ETH');

// 3. 查询区块
const block = await provider.getBlock('latest');
console.log('Latest block:', block.number);

// 4. 查询交易
const tx = await provider.getTransaction('0x...');
console.log('Transaction:', tx);

// 5. 创建钱包
const wallet = new ethers.Wallet('0x...', provider); // 使用私钥
const randomWallet = ethers.Wallet.createRandom(); // 随机生成

// 6. 发送ETH
const tx = await wallet.sendTransaction({
    to: '0x...',
    value: ethers.parseEther('1.0')
});
await tx.wait();

// 7. 与合约交互
const contract = new ethers.Contract(contractAddress, ABI, provider);
const value = await contract.getValue();

// 8. 调用合约函数(需要签名)
const contractWithSigner = contract.connect(wallet);
const tx = await contractWithSigner.setValue(123);
await tx.wait();

二、钱包连接

2.1 MetaMask基础

检测MetaMask

// 检测是否安装MetaMask
function isMetaMaskInstalled() {
    return typeof window.ethereum !== 'undefined' && window.ethereum.isMetaMask;
}

if (!isMetaMaskInstalled()) {
    alert('Please install MetaMask!');
    window.open('https://metamask.io/download/');
}

连接MetaMask

import { ethers } from 'ethers';

async function connectWallet() {
    if (!window.ethereum) {
        throw new Error('MetaMask not installed');
    }

    try {
        // 请求连接
        const accounts = await window.ethereum.request({
            method: 'eth_requestAccounts'
        });

        // 创建Provider
        const provider = new ethers.BrowserProvider(window.ethereum);

        // 获取Signer
        const signer = await provider.getSigner();

        // 获取地址
        const address = await signer.getAddress();

        // 获取余额
        const balance = await provider.getBalance(address);

        return {
            provider,
            signer,
            address,
            balance: ethers.formatEther(balance)
        };
    } catch (error) {
        console.error('Failed to connect wallet:', error);
        throw error;
    }
}

// 使用
const wallet = await connectWallet();
console.log('Connected:', wallet.address);
console.log('Balance:', wallet.balance, 'ETH');

监听账户变化

// 监听账户切换
window.ethereum.on('accountsChanged', (accounts) => {
    if (accounts.length === 0) {
        // 用户断开连接
        console.log('Please connect to MetaMask');
    } else {
        // 账户切换
        console.log('Account changed to:', accounts[0]);
        // 重新连接
        connectWallet();
    }
});

// 监听网络切换
window.ethereum.on('chainChanged', (chainId) => {
    console.log('Network changed to:', chainId);
    // 刷新页面是最简单的处理方式
    window.location.reload();
});

// 监听断开连接
window.ethereum.on('disconnect', (error) => {
    console.log('Disconnected:', error);
});

2.2 网络管理

切换网络

async function switchNetwork(chainId) {
    try {
        await window.ethereum.request({
            method: 'wallet_switchEthereumChain',
            params: [{ chainId: `0x${chainId.toString(16)}` }]
        });
    } catch (error) {
        // 如果网络不存在,添加网络
        if (error.code === 4902) {
            await addNetwork(chainId);
        } else {
            throw error;
        }
    }
}

// 网络配置
const networks = {
    1: {
        chainId: '0x1',
        chainName: 'Ethereum Mainnet',
        nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
        rpcUrls: ['https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY'],
        blockExplorerUrls: ['https://etherscan.io']
    },
    56: {
        chainId: '0x38',
        chainName: 'BNB Smart Chain',
        nativeCurrency: { name: 'BNB', symbol: 'BNB', decimals: 18 },
        rpcUrls: ['https://bsc-dataseed.binance.org/'],
        blockExplorerUrls: ['https://bscscan.com']
    },
    137: {
        chainId: '0x89',
        chainName: 'Polygon',
        nativeCurrency: { name: 'MATIC', symbol: 'MATIC', decimals: 18 },
        rpcUrls: ['https://polygon-rpc.com/'],
        blockExplorerUrls: ['https://polygonscan.com']
    }
};

async function addNetwork(chainId) {
    const network = networks[chainId];
    if (!network) {
        throw new Error('Network not supported');
    }

    await window.ethereum.request({
        method: 'wallet_addEthereumChain',
        params: [network]
    });
}

// 使用示例
await switchNetwork(56); // 切换到BSC

添加代币到MetaMask

async function addToken(tokenAddress, tokenSymbol, tokenDecimals, tokenImage) {
    try {
        await window.ethereum.request({
            method: 'wallet_watchAsset',
            params: {
                type: 'ERC20',
                options: {
                    address: tokenAddress,
                    symbol: tokenSymbol,
                    decimals: tokenDecimals,
                    image: tokenImage
                }
            }
        });
    } catch (error) {
        console.error('Failed to add token:', error);
    }
}

// 使用示例
await addToken(
    '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI
    'DAI',
    18,
    'https://assets.coingecko.com/coins/images/9956/small/dai-multi-collateral-mcd.png'
);

2.3 WalletConnect集成

npm install @walletconnect/web3-provider
import WalletConnectProvider from '@walletconnect/web3-provider';
import { ethers } from 'ethers';

async function connectWalletConnect() {
    // 创建WalletConnect Provider
    const provider = new WalletConnectProvider({
        infuraId: 'YOUR-INFURA-ID',
        rpc: {
            1: 'https://eth-mainnet.g.alchemy.com/v2/YOUR-API-KEY',
            56: 'https://bsc-dataseed.binance.org/',
            137: 'https://polygon-rpc.com/'
        }
    });

    // 启用连接
    await provider.enable();

    // 创建ethers provider
    const ethersProvider = new ethers.BrowserProvider(provider);

    // 获取signer
    const signer = await ethersProvider.getSigner();
    const address = await signer.getAddress();

    // 监听事件
    provider.on('accountsChanged', (accounts) => {
        console.log('Accounts changed:', accounts);
    });

    provider.on('chainChanged', (chainId) => {
        console.log('Chain changed:', chainId);
    });

    provider.on('disconnect', (code, reason) => {
        console.log('Disconnected:', code, reason);
    });

    return {
        provider: ethersProvider,
        signer,
        address
    };
}

// 使用
const wallet = await connectWalletConnect();

2.4 多钱包支持

// 钱包配置
const walletConfig = {
    metamask: {
        name: 'MetaMask',
        icon: '/metamask.svg',
        connect: connectMetaMask
    },
    walletconnect: {
        name: 'WalletConnect',
        icon: '/walletconnect.svg',
        connect: connectWalletConnect
    },
    coinbase: {
        name: 'Coinbase Wallet',
        icon: '/coinbase.svg',
        connect: connectCoinbase
    }
};

// 钱包选择组件
function WalletModal({ onConnect, onClose }) {
    return (
        <div className="modal">
            <h2>Connect Wallet</h2>
            {Object.entries(walletConfig).map(([key, wallet]) => (
                <button
                    key={key}
                    onClick={async () => {
                        try {
                            const result = await wallet.connect();
                            onConnect(result);
                            onClose();
                        } catch (error) {
                            console.error('Failed to connect:', error);
                        }
                    }}
                >
                    <img src={wallet.icon} alt={wallet.name} />
                    {wallet.name}
                </button>
            ))}
        </div>
    );
}

三、React Hooks开发

3.1 自定义useWallet Hook

import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

export function useWallet() {
    const [provider, setProvider] = useState(null);
    const [signer, setSigner] = useState(null);
    const [address, setAddress] = useState('');
    const [balance, setBalance] = useState('0');
    const [chainId, setChainId] = useState(null);
    const [isConnecting, setIsConnecting] = useState(false);
    const [error, setError] = useState(null);

    // 连接钱包
    const connect = useCallback(async () => {
        if (!window.ethereum) {
            setError('Please install MetaMask');
            return;
        }

        try {
            setIsConnecting(true);
            setError(null);

            const provider = new ethers.BrowserProvider(window.ethereum);
            const signer = await provider.getSigner();
            const address = await signer.getAddress();
            const balance = await provider.getBalance(address);
            const network = await provider.getNetwork();

            setProvider(provider);
            setSigner(signer);
            setAddress(address);
            setBalance(ethers.formatEther(balance));
            setChainId(Number(network.chainId));
        } catch (err) {
            setError(err.message);
            console.error('Failed to connect wallet:', err);
        } finally {
            setIsConnecting(false);
        }
    }, []);

    // 断开连接
    const disconnect = useCallback(() => {
        setProvider(null);
        setSigner(null);
        setAddress('');
        setBalance('0');
        setChainId(null);
    }, []);

    // 更新余额
    const updateBalance = useCallback(async () => {
        if (provider && address) {
            const balance = await provider.getBalance(address);
            setBalance(ethers.formatEther(balance));
        }
    }, [provider, address]);

    // 监听账户变化
    useEffect(() => {
        if (!window.ethereum) return;

        const handleAccountsChanged = (accounts) => {
            if (accounts.length === 0) {
                disconnect();
            } else {
                connect();
            }
        };

        const handleChainChanged = () => {
            window.location.reload();
        };

        window.ethereum.on('accountsChanged', handleAccountsChanged);
        window.ethereum.on('chainChanged', handleChainChanged);

        return () => {
            window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
            window.ethereum.removeListener('chainChanged', handleChainChanged);
        };
    }, [connect, disconnect]);

    // 自动连接(如果之前连接过)
    useEffect(() => {
        const wasConnected = localStorage.getItem('walletConnected');
        if (wasConnected === 'true') {
            connect();
        }
    }, [connect]);

    // 保存连接状态
    useEffect(() => {
        if (address) {
            localStorage.setItem('walletConnected', 'true');
        } else {
            localStorage.removeItem('walletConnected');
        }
    }, [address]);

    return {
        provider,
        signer,
        address,
        balance,
        chainId,
        isConnecting,
        error,
        connect,
        disconnect,
        updateBalance,
        isConnected: !!address
    };
}

使用示例

function App() {
    const {
        address,
        balance,
        chainId,
        isConnecting,
        error,
        connect,
        disconnect,
        isConnected
    } = useWallet();

    return (
        <div>
            {!isConnected ? (
                <button onClick={connect} disabled={isConnecting}>
                    {isConnecting ? 'Connecting...' : 'Connect Wallet'}
                </button>
            ) : (
                <div>
                    <p>Address: {address.slice(0, 6)}...{address.slice(-4)}</p>
                    <p>Balance: {parseFloat(balance).toFixed(4)} ETH</p>
                    <p>Chain ID: {chainId}</p>
                    <button onClick={disconnect}>Disconnect</button>
                </div>
            )}
            {error && <p style={{ color: 'red' }}>{error}</p>}
        </div>
    );
}

3.2 自定义useContract Hook

import { useMemo } from 'react';
import { ethers } from 'ethers';

export function useContract(address, ABI, signer) {
    return useMemo(() => {
        if (!address || !ABI) return null;

        if (signer) {
            return new ethers.Contract(address, ABI, signer);
        } else {
            // 只读模式
            const provider = new ethers.JsonRpcProvider(RPC_URL);
            return new ethers.Contract(address, ABI, provider);
        }
    }, [address, ABI, signer]);
}

// 使用示例
function TokenBalance({ tokenAddress }) {
    const { signer, address } = useWallet();
    const tokenContract = useContract(tokenAddress, ERC20_ABI, signer);
    const [balance, setBalance] = useState('0');

    useEffect(() => {
        if (!tokenContract || !address) return;

        async function fetchBalance() {
            const balance = await tokenContract.balanceOf(address);
            const decimals = await tokenContract.decimals();
            setBalance(ethers.formatUnits(balance, decimals));
        }

        fetchBalance();

        // 监听Transfer事件
        const filter = tokenContract.filters.Transfer(null, address);
        tokenContract.on(filter, fetchBalance);

        return () => {
            tokenContract.off(filter, fetchBalance);
        };
    }, [tokenContract, address]);

    return <div>Balance: {balance}</div>;
}

3.3 自定义useTransaction Hook

import { useState, useCallback } from 'react';

export function useTransaction() {
    const [isLoading, setIsLoading] = useState(false);
    const [error, setError] = useState(null);
    const [txHash, setTxHash] = useState(null);

    const sendTransaction = useCallback(async (txFunction, onSuccess) => {
        setIsLoading(true);
        setError(null);
        setTxHash(null);

        try {
            // 发送交易
            const tx = await txFunction();
            setTxHash(tx.hash);

            // 等待确认
            const receipt = await tx.wait();

            if (onSuccess) {
                onSuccess(receipt);
            }

            return receipt;
        } catch (err) {
            // 解析错误
            let errorMessage = 'Transaction failed';

            if (err.code === 'ACTION_REJECTED') {
                errorMessage = 'User rejected transaction';
            } else if (err.code === 'INSUFFICIENT_FUNDS') {
                errorMessage = 'Insufficient funds';
            } else if (err.reason) {
                errorMessage = err.reason;
            } else if (err.message) {
                errorMessage = err.message;
            }

            setError(errorMessage);
            throw err;
        } finally {
            setIsLoading(false);
        }
    }, []);

    const reset = useCallback(() => {
        setIsLoading(false);
        setError(null);
        setTxHash(null);
    }, []);

    return {
        sendTransaction,
        isLoading,
        error,
        txHash,
        reset
    };
}

// 使用示例
function TransferButton() {
    const { signer } = useWallet();
    const { sendTransaction, isLoading, error, txHash } = useTransaction();

    const handleTransfer = async () => {
        await sendTransaction(
            () => signer.sendTransaction({
                to: '0x...',
                value: ethers.parseEther('0.1')
            }),
            (receipt) => {
                console.log('Transaction confirmed:', receipt);
                alert('Transfer successful!');
            }
        );
    };

    return (
        <div>
            <button onClick={handleTransfer} disabled={isLoading}>
                {isLoading ? 'Sending...' : 'Send 0.1 ETH'}
            </button>
            {error && <p style={{ color: 'red' }}>{error}</p>}
            {txHash && (
                <a href={`https://etherscan.io/tx/${txHash}`} target="_blank">
                    View on Etherscan
                </a>
            )}
        </div>
    );
}

四、合约交互

4.1 读取合约数据

import { ethers } from 'ethers';

// ERC20 ABI(只包含需要的函数)
const ERC20_ABI = [
    'function name() view returns (string)',
    'function symbol() view returns (string)',
    'function decimals() view returns (uint8)',
    'function totalSupply() view returns (uint256)',
    'function balanceOf(address) view returns (uint256)',
    'function allowance(address owner, address spender) view returns (uint256)',
    'event Transfer(address indexed from, address indexed to, uint256 value)'
];

async function getTokenInfo(tokenAddress) {
    const provider = new ethers.JsonRpcProvider(RPC_URL);
    const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);

    const [name, symbol, decimals, totalSupply] = await Promise.all([
        contract.name(),
        contract.symbol(),
        contract.decimals(),
        contract.totalSupply()
    ]);

    return {
        name,
        symbol,
        decimals,
        totalSupply: ethers.formatUnits(totalSupply, decimals)
    };
}

// React组件
function TokenInfo({ tokenAddress }) {
    const [info, setInfo] = useState(null);
    const [loading, setLoading] = useState(true);

    useEffect(() => {
        getTokenInfo(tokenAddress)
            .then(setInfo)
            .finally(() => setLoading(false));
    }, [tokenAddress]);

    if (loading) return <div>Loading...</div>;
    if (!info) return <div>Failed to load token info</div>;

    return (
        <div>
            <h3>{info.name} ({info.symbol})</h3>
            <p>Decimals: {info.decimals}</p>
            <p>Total Supply: {info.totalSupply}</p>
        </div>
    );
}

4.2 调用合约函数

// ERC20转账
async function transferToken(tokenAddress, to, amount) {
    const { signer } = useWallet();
    const contract = new ethers.Contract(tokenAddress, ERC20_ABI, signer);

    // 获取decimals
    const decimals = await contract.decimals();

    // 转换金额
    const amountInWei = ethers.parseUnits(amount.toString(), decimals);

    // 发送交易
    const tx = await contract.transfer(to, amountInWei);

    // 等待确认
    const receipt = await tx.wait();

    return receipt;
}

// React组件
function TransferToken({ tokenAddress }) {
    const [to, setTo] = useState('');
    const [amount, setAmount] = useState('');
    const { sendTransaction, isLoading, error, txHash } = useTransaction();

    const handleTransfer = async () => {
        await sendTransaction(
            () => transferToken(tokenAddress, to, amount),
            () => {
                alert('Transfer successful!');
                setTo('');
                setAmount('');
            }
        );
    };

    return (
        <div>
            <input
                type="text"
                placeholder="Recipient address"
                value={to}
                onChange={(e) => setTo(e.target.value)}
            />
            <input
                type="number"
                placeholder="Amount"
                value={amount}
                onChange={(e) => setAmount(e.target.value)}
            />
            <button onClick={handleTransfer} disabled={isLoading}>
                {isLoading ? 'Transferring...' : 'Transfer'}
            </button>
            {error && <p style={{ color: 'red' }}>{error}</p>}
            {txHash && <p>Transaction: {txHash}</p>}
        </div>
    );
}

4.3 授权(Approve)

async function approveToken(tokenAddress, spenderAddress, amount) {
    const { signer } = useWallet();
    const contract = new ethers.Contract(tokenAddress, ERC20_ABI, signer);

    // 检查当前授权额度
    const currentAllowance = await contract.allowance(
        await signer.getAddress(),
        spenderAddress
    );

    // 如果已授权足够,直接返回
    const decimals = await contract.decimals();
    const amountInWei = ethers.parseUnits(amount.toString(), decimals);

    if (currentAllowance >= amountInWei) {
        return null;
    }

    // 授权
    const tx = await contract.approve(spenderAddress, amountInWei);
    const receipt = await tx.wait();

    return receipt;
}

// React组件
function ApproveButton({ tokenAddress, spenderAddress, amount }) {
    const { sendTransaction, isLoading } = useTransaction();
    const [isApproved, setIsApproved] = useState(false);

    const handleApprove = async () => {
        const receipt = await sendTransaction(
            () => approveToken(tokenAddress, spenderAddress, amount)
        );

        if (receipt) {
            setIsApproved(true);
        }
    };

    if (isApproved) {
        return <div> Approved</div>;
    }

    return (
        <button onClick={handleApprove} disabled={isLoading}>
            {isLoading ? 'Approving...' : 'Approve'}
        </button>
    );
}

4.4 事件监听

function TransactionHistory({ tokenAddress, userAddress }) {
    const [transfers, setTransfers] = useState([]);
    const contract = useContract(tokenAddress, ERC20_ABI);

    useEffect(() => {
        if (!contract || !userAddress) return;

        async function fetchHistory() {
            // 查询历史Transfer事件
            const filterFrom = contract.filters.Transfer(userAddress, null);
            const filterTo = contract.filters.Transfer(null, userAddress);

            const [eventsFrom, eventsTo] = await Promise.all([
                contract.queryFilter(filterFrom, -10000), // 最近10000个区块
                contract.queryFilter(filterTo, -10000)
            ]);

            const allEvents = [...eventsFrom, ...eventsTo]
                .sort((a, b) => b.blockNumber - a.blockNumber)
                .slice(0, 20); // 只显示最近20条

            setTransfers(allEvents);
        }

        fetchHistory();

        // 实时监听新的Transfer事件
        const filter = contract.filters.Transfer();
        contract.on(filter, (from, to, value, event) => {
            if (from === userAddress || to === userAddress) {
                setTransfers(prev => [event, ...prev].slice(0, 20));
            }
        });

        return () => {
            contract.off(filter);
        };
    }, [contract, userAddress]);

    return (
        <div>
            <h3>Transaction History</h3>
            {transfers.map((event, index) => (
                <div key={index}>
                    <p>
                        {event.args.from === userAddress ? 'Sent' : 'Received'}{' '}
                        {ethers.formatUnits(event.args.value, 18)}
                    </p>
                    <p>
                        {event.args.from === userAddress ? 'To' : 'From'}:{' '}
                        {event.args.from === userAddress ? event.args.to : event.args.from}
                    </p>
                    <p>Block: {event.blockNumber}</p>
                    <hr />
                </div>
            ))}
        </div>
    );
}

五、完整项目:简化版DEX

5.1 项目结构

dex-frontend/
├── src/
│   ├── components/
│   │   ├── Wallet.jsx
│   │   ├── Swap.jsx
│   │   ├── Pool.jsx
│   │   └── TokenSelector.jsx
│   ├── hooks/
│   │   ├── useWallet.js
│   │   ├── useContract.js
│   │   ├── useTransaction.js
│   │   └── useTokenBalance.js
│   ├── utils/
│   │   ├── contracts.js
│   │   └── format.js
│   ├── constants/
│   │   └── tokens.js
│   └── App.jsx
├── package.json
└── vite.config.js

5.2 Swap组件

// src/components/Swap.jsx
import { useState, useEffect } from 'react';
import { ethers } from 'ethers';
import { useWallet } from '../hooks/useWallet';
import { useContract } from '../hooks/useContract';
import { useTransaction } from '../hooks/useTransaction';
import { ROUTER_ADDRESS, ROUTER_ABI, ERC20_ABI } from '../utils/contracts';
import TokenSelector from './TokenSelector';

function Swap() {
    const { signer, address } = useWallet();
    const router = useContract(ROUTER_ADDRESS, ROUTER_ABI, signer);
    const { sendTransaction, isLoading, error } = useTransaction();

    const [tokenIn, setTokenIn] = useState(null);
    const [tokenOut, setTokenOut] = useState(null);
    const [amountIn, setAmountIn] = useState('');
    const [amountOut, setAmountOut] = useState('');
    const [slippage, setSlippage] = useState(0.5); // 0.5%

    // 计算输出金额
    useEffect(() => {
        if (!router || !tokenIn || !tokenOut || !amountIn || amountIn === '0') {
            setAmountOut('');
            return;
        }

        async function fetchAmountOut() {
            try {
                const amountInWei = ethers.parseUnits(amountIn, tokenIn.decimals);
                const amounts = await router.getAmountsOut(amountInWei, [
                    tokenIn.address,
                    tokenOut.address
                ]);
                const amountOutWei = amounts[1];
                setAmountOut(ethers.formatUnits(amountOutWei, tokenOut.decimals));
            } catch (err) {
                console.error('Failed to get amount out:', err);
                setAmountOut('');
            }
        }

        const timeoutId = setTimeout(fetchAmountOut, 500);
        return () => clearTimeout(timeoutId);
    }, [router, tokenIn, tokenOut, amountIn]);

    // 授权
    const handleApprove = async () => {
        const tokenContract = new ethers.Contract(tokenIn.address, ERC20_ABI, signer);
        const amountInWei = ethers.parseUnits(amountIn, tokenIn.decimals);

        await sendTransaction(
            () => tokenContract.approve(ROUTER_ADDRESS, amountInWei)
        );
    };

    // 交换
    const handleSwap = async () => {
        if (!tokenIn || !tokenOut || !amountIn || !amountOut) {
            alert('Please fill in all fields');
            return;
        }

        const amountInWei = ethers.parseUnits(amountIn, tokenIn.decimals);
        const amountOutMin = ethers.parseUnits(
            (parseFloat(amountOut) * (1 - slippage / 100)).toFixed(tokenOut.decimals),
            tokenOut.decimals
        );

        const deadline = Math.floor(Date.now() / 1000) + 60 * 20; // 20分钟

        await sendTransaction(
            () => router.swapExactTokensForTokens(
                amountInWei,
                amountOutMin,
                [tokenIn.address, tokenOut.address],
                address,
                deadline
            ),
            () => {
                alert('Swap successful!');
                setAmountIn('');
                setAmountOut('');
            }
        );
    };

    return (
        <div className="swap-container">
            <h2>Swap</h2>

            {/* 输入代币 */}
            <div className="token-input">
                <label>From</label>
                <input
                    type="number"
                    placeholder="0.0"
                    value={amountIn}
                    onChange={(e) => setAmountIn(e.target.value)}
                />
                <TokenSelector
                    selected={tokenIn}
                    onSelect={setTokenIn}
                />
            </div>

            {/* 交换箭头 */}
            <button onClick={() => {
                setTokenIn(tokenOut);
                setTokenOut(tokenIn);
                setAmountIn(amountOut);
            }}>
                ↓
            </button>

            {/* 输出代币 */}
            <div className="token-input">
                <label>To</label>
                <input
                    type="number"
                    placeholder="0.0"
                    value={amountOut}
                    readOnly
                />
                <TokenSelector
                    selected={tokenOut}
                    onSelect={setTokenOut}
                />
            </div>

            {/* 滑点设置 */}
            <div className="slippage-setting">
                <label>Slippage Tolerance</label>
                <select value={slippage} onChange={(e) => setSlippage(parseFloat(e.target.value))}>
                    <option value={0.1}>0.1%</option>
                    <option value={0.5}>0.5%</option>
                    <option value={1.0}>1.0%</option>
                    <option value={3.0}>3.0%</option>
                </select>
            </div>

            {/* 价格信息 */}
            {amountIn && amountOut && (
                <div className="price-info">
                    <p>
                        Price: 1 {tokenIn.symbol} ={' '}
                        {(parseFloat(amountOut) / parseFloat(amountIn)).toFixed(6)}{' '}
                        {tokenOut.symbol}
                    </p>
                    <p>
                        Minimum received: {(parseFloat(amountOut) * (1 - slippage / 100)).toFixed(6)}{' '}
                        {tokenOut.symbol}
                    </p>
                </div>
            )}

            {/* 按钮 */}
            <div className="button-group">
                <button onClick={handleApprove} disabled={isLoading}>
                    Approve
                </button>
                <button onClick={handleSwap} disabled={isLoading || !amountOut}>
                    {isLoading ? 'Swapping...' : 'Swap'}
                </button>
            </div>

            {error && <p className="error">{error}</p>}
        </div>
    );
}

export default Swap;

5.3 代币选择器

// src/components/TokenSelector.jsx
import { useState } from 'react';
import { COMMON_TOKENS } from '../constants/tokens';

function TokenSelector({ selected, onSelect }) {
    const [isOpen, setIsOpen] = useState(false);
    const [searchTerm, setSearchTerm] = useState('');

    const filteredTokens = COMMON_TOKENS.filter(
        token =>
            token.symbol.toLowerCase().includes(searchTerm.toLowerCase()) ||
            token.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
            token.address.toLowerCase().includes(searchTerm.toLowerCase())
    );

    return (
        <div className="token-selector">
            <button onClick={() => setIsOpen(true)}>
                {selected ? (
                    <>
                        <img src={selected.logoURI} alt={selected.symbol} />
                        {selected.symbol}
                    </>
                ) : (
                    'Select Token'
                )}
            </button>

            {isOpen && (
                <div className="modal">
                    <div className="modal-content">
                        <h3>Select a token</h3>
                        <input
                            type="text"
                            placeholder="Search name or paste address"
                            value={searchTerm}
                            onChange={(e) => setSearchTerm(e.target.value)}
                        />

                        <div className="token-list">
                            {filteredTokens.map(token => (
                                <div
                                    key={token.address}
                                    className="token-item"
                                    onClick={() => {
                                        onSelect(token);
                                        setIsOpen(false);
                                    }}
                                >
                                    <img src={token.logoURI} alt={token.symbol} />
                                    <div>
                                        <div className="token-symbol">{token.symbol}</div>
                                        <div className="token-name">{token.name}</div>
                                    </div>
                                </div>
                            ))}
                        </div>

                        <button onClick={() => setIsOpen(false)}>Close</button>
                    </div>
                </div>
            )}
        </div>
    );
}

export default TokenSelector;

5.4 流动性池

// src/components/Pool.jsx
import { useState } from 'react';
import { ethers } from 'ethers';
import { useWallet } from '../hooks/useWallet';
import { useContract } from '../hooks/useContract';
import { useTransaction } from '../hooks/useTransaction';
import { ROUTER_ADDRESS, ROUTER_ABI } from '../utils/contracts';

function Pool() {
    const { signer, address } = useWallet();
    const router = useContract(ROUTER_ADDRESS, ROUTER_ABI, signer);
    const { sendTransaction, isLoading } = useTransaction();

    const [tokenA, setTokenA] = useState(null);
    const [tokenB, setTokenB] = useState(null);
    const [amountA, setAmountA] = useState('');
    const [amountB, setAmountB] = useState('');

    // 添加流动性
    const handleAddLiquidity = async () => {
        if (!tokenA || !tokenB || !amountA || !amountB) {
            alert('Please fill in all fields');
            return;
        }

        const amountAWei = ethers.parseUnits(amountA, tokenA.decimals);
        const amountBWei = ethers.parseUnits(amountB, tokenB.decimals);

        // 设置最小数量(滑点容忍)
        const amountAMin = amountAWei * 95n / 100n; // 5% slippage
        const amountBMin = amountBWei * 95n / 100n;

        const deadline = Math.floor(Date.now() / 1000) + 60 * 20;

        await sendTransaction(
            () => router.addLiquidity(
                tokenA.address,
                tokenB.address,
                amountAWei,
                amountBWei,
                amountAMin,
                amountBMin,
                address,
                deadline
            ),
            () => {
                alert('Liquidity added successfully!');
                setAmountA('');
                setAmountB('');
            }
        );
    };

    // 移除流动性
    const handleRemoveLiquidity = async (lpTokenAmount) => {
        // 实现移除流动性逻辑
    };

    return (
        <div className="pool-container">
            <h2>Add Liquidity</h2>

            <div className="token-input">
                <input
                    type="number"
                    placeholder="0.0"
                    value={amountA}
                    onChange={(e) => setAmountA(e.target.value)}
                />
                <TokenSelector selected={tokenA} onSelect={setTokenA} />
            </div>

            <div className="plus">+</div>

            <div className="token-input">
                <input
                    type="number"
                    placeholder="0.0"
                    value={amountB}
                    onChange={(e) => setAmountB(e.target.value)}
                />
                <TokenSelector selected={tokenB} onSelect={setTokenB} />
            </div>

            <button onClick={handleAddLiquidity} disabled={isLoading}>
                {isLoading ? 'Adding...' : 'Add Liquidity'}
            </button>
        </div>
    );
}

export default Pool;

5.5 样式(CSS)

/* src/App.css */
.app {
    max-width: 480px;
    margin: 40px auto;
    padding: 20px;
}

.wallet-info {
    background: #f5f5f5;
    padding: 15px;
    border-radius: 12px;
    margin-bottom: 20px;
}

.swap-container, .pool-container {
    background: white;
    padding: 20px;
    border-radius: 16px;
    box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}

.token-input {
    background: #f5f5f5;
    padding: 15px;
    border-radius: 12px;
    margin: 10px 0;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.token-input input {
    border: none;
    background: transparent;
    font-size: 24px;
    width: 60%;
    outline: none;
}

.token-selector button {
    background: white;
    border: 1px solid #e0e0e0;
    padding: 8px 12px;
    border-radius: 20px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 8px;
}

.token-selector button:hover {
    background: #f5f5f5;
}

.token-selector img {
    width: 24px;
    height: 24px;
    border-radius: 50%;
}

.modal {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0,0,0,0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
}

.modal-content {
    background: white;
    padding: 20px;
    border-radius: 16px;
    max-width: 400px;
    width: 90%;
    max-height: 80vh;
    overflow-y: auto;
}

.token-list {
    margin: 20px 0;
    max-height: 400px;
    overflow-y: auto;
}

.token-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 12px;
    cursor: pointer;
    border-radius: 8px;
}

.token-item:hover {
    background: #f5f5f5;
}

.price-info {
    background: #f5f5f5;
    padding: 10px;
    border-radius: 8px;
    margin: 10px 0;
    font-size: 14px;
}

.button-group {
    display: flex;
    gap: 10px;
    margin-top: 20px;
}

button {
    flex: 1;
    background: #3f51b5;
    color: white;
    border: none;
    padding: 15px;
    border-radius: 12px;
    font-size: 16px;
    font-weight: bold;
    cursor: pointer;
}

button:hover:not(:disabled) {
    background: #303f9f;
}

button:disabled {
    background: #ccc;
    cursor: not-allowed;
}

.error {
    color: #f44336;
    margin-top: 10px;
    font-size: 14px;
}

六、总结

核心要点

  1. ethers.js是Web3前端的核心库

    • 简洁的API
    • 完善的TypeScript支持
    • 优秀的文档
  2. 钱包集成是Web3应用的入口

    • 支持MetaMask、WalletConnect等
    • 处理账户和网络切换
    • 管理连接状态
  3. React Hooks简化状态管理

    • useWallet、useContract、useTransaction
    • 自定义Hooks提高代码复用
    • 优化性能和用户体验
  4. 交易处理需要完善的错误处理

    • 用户拒绝交易
    • 余额不足
    • Gas估算失败
    • 网络错误

最佳实践

  1. 总是验证用户输入
  2. 使用BigNumber避免精度问题
  3. 实施适当的加载状态
  4. 提供友好的错误提示
  5. 缓存合约实例避免重复创建
  6. 监听事件实时更新UI
  7. 支持多种钱包
  8. 添加网络检测和切换

下一步学习

  • 13-链下数据与预言机.md - 学习如何在前端使用预言机数据
Prev
跨链技术
Next
链下数据与预言机