背景
上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包连接。团队技术栈是 React + TypeScript,对于 Web3 交互,我们选择了老牌且功能强大的 ethers.js 库。我心想:“连接 MetaMask 嘛,官方文档例子那么多,还不是手到擒来?” 于是,我复制了一段最常见的示例代码,准备十分钟搞定这个功能。然而,现实很快给了我一个教训——从“连接”到“稳定可用”之间,隔着一整条满是坑的沟。
问题分析
我最开始的思路非常简单:检查 window.ethereum 是否存在,如果存在,就用 ethers.providers.Web3Provider 包装它,然后调用 provider.send('eth_requestAccounts', []) 来触发 MetaMask 的授权弹窗。代码跑起来,在已经安装了 MetaMask 的浏览器里,第一次点击确实弹窗了,连接成功了。
但问题接踵而至:
- 刷新页面后,连接状态丢失:用户需要重新点击连接并授权,体验极差。
- 用户切换了 MetaMask 账户:前端界面上的地址没有自动更新。
- 用户切换了网络(比如从以太坊主网切换到 Goerli 测试网):我们的 DApp 需要感知到这个变化,并可能提示用户切换回目标网络。
- 用户根本没有安装 MetaMask:页面直接报错,白屏。
最初的代码只处理了“发起连接”这个单一动作,完全没考虑 Web3 应用是“动态的”、“有状态的”。我意识到,我需要构建的不是一个“连接按钮”,而是一个完整的“钱包连接状态管理器”。它需要监听区块链提供者的各种事件,并将状态同步到 React 组件中。
核心实现
第一步:封装一个健壮的钱包连接钩子
我决定创建一个自定义 React Hook useWallet 来集中管理所有钱包状态。首先,要安全地获取 window.ethereum 对象。这里就有第一个坑:TypeScript 不知道 window.ethereum 的类型。
// types/global.d.ts
interface Window {
ethereum?: any; // 为了快速开发,可以先设为 any,更严谨的做法是导入 MetaMask 的 EIP-1193 类型
}
// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
declare global {
interface Window {
ethereum?: any;
}
}
export const useWallet = () => {
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [account, setAccount] = useState<string>('');
const [chainId, setChainId] = useState<number>(0);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string>('');
// 初始化:检查是否已授权
useEffect(() => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展');
return;
}
const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 允许任何网络
setProvider(initProvider);
// 尝试获取已连接的账户
initProvider.listAccounts().then((accounts) => {
if (accounts.length > 0) {
setAccount(accounts[0]);
setSigner(initProvider.getSigner());
}
});
// 获取当前网络链 ID
initProvider.getNetwork().then((network) => {
setChainId(network.chainId);
});
}, []);
}
注意:new Web3Provider(window.ethereum, 'any') 中的 'any' 参数很重要,它告诉 ethers 我们接受任何网络,这样在监听网络切换时不会抛出错误。
第二步:实现连接与断开连接
连接函数需要处理用户交互和可能的拒绝。
// 在 useWallet 钩子内
const connectWallet = useCallback(async () => {
if (!provider) {
setError('Provider 未初始化');
return;
}
setIsConnecting(true);
setError('');
try {
// 这会触发 MetaMask 弹窗
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
setAccount(currentAccount);
setSigner(provider.getSigner());
// 连接成功后,再获取一次最新的网络信息
const network = await provider.getNetwork();
setChainId(network.chainId);
} catch (err: any) {
console.error('连接钱包失败:', err);
// 用户拒绝连接是最常见的错误
setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
} finally {
setIsConnecting(false);
}
}, [provider]);
const disconnectWallet = useCallback(() => {
// 注意:MetaMask 没有真正的“断开连接”API,这里只是清除本地状态
setAccount('');
setSigner(null);
setChainId(0);
// 在实际项目中,你可能还需要清除相关的应用状态(如用户余额、NFT等)
}, []);
这里有个坑:disconnectWallet 并不能让 MetaMask 忘记你的网站授权。真正的“断开”需要用户在 MetaMask 界面手动操作。我们只是在前端清除了状态。
第三步:监听账户与网络变化
这是实现“状态同步”的核心。我们需要监听 window.ethereum 发出的事件。
// 在 useWallet 钩子的 useEffect 中,初始化之后
useEffect(() => {
if (!window.ethereum) return;
// 监听账户变更
const handleAccountsChanged = (accounts: string[]) => {
console.log('accountsChanged', accounts);
if (accounts.length === 0) {
// MetaMask 被锁定或用户主动断开连接了所有账户
disconnectWallet();
} else if (accounts[0] !== account) {
// 用户切换了账户
setAccount(accounts[0]);
if (provider) {
setSigner(provider.getSigner());
}
}
};
// 监听链 ID 变更(网络切换)
const handleChainChanged = (_chainId: string) => {
// 注意:MetaMask 文档建议在链变更时刷新页面,但现代 DApp 通常不这样做
// 我们只是更新 chainId 状态,组件可以根据新 chainId 做出反应(如提示切换网络)
console.log('chainChanged', _chainId);
// chainId 是十六进制字符串,需要转换
setChainId(parseInt(_chainId, 16));
// 网络变了,provider 和 signer 实例其实还能用,但某些场景可能需要重置
if (provider) {
provider.getNetwork().then(network => setChainId(network.chainId));
}
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
// 组件卸载时清除监听
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, [provider, account, disconnectWallet]); // 依赖项要小心,避免重复绑定
关键细节:chainChanged 事件回调的参数是十六进制字符串,而 ethers 的 chainId 是数字,需要转换。另外,监听器一定要在组件卸载时移除,防止内存泄漏。
第四步:在组件中使用并处理网络不匹配
最后,在组件中集成这个 Hook,并处理一个常见业务逻辑:如果用户不在我们支持的网络上,提示他切换。
// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';
import { shortenAddress } from '../utils/address'; // 一个格式化地址的辅助函数
const SUPPORTED_CHAIN_ID = 1; // 假设我们只支持以太坊主网
export const WalletConnector: React.FC = () => {
const {
account,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
} = useWallet();
const isOnSupportedNetwork = chainId === SUPPORTED_CHAIN_ID;
const handleSwitchNetwork = async () => {
if (!window.ethereum) return;
try {
// 尝试切换到以太坊主网
await window.ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: '0x1' }], // 主网的十六进制链ID
});
} catch (switchError: any) {
// 如果用户没有添加该网络,可以尝试添加它
if (switchError.code === 4902) {
try {
await window.ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: '0x1',
chainName: 'Ethereum Mainnet',
nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://mainnet.infura.io/v3/YOUR_INFURA_KEY'],
blockExplorerUrls: ['https://etherscan.io'],
}],
});
} catch (addError) {
console.error('添加网络失败', addError);
}
}
console.error('切换网络失败', switchError);
}
};
if (error && !window.ethereum) {
return <div className="error">未检测到钱包,请安装 MetaMask。</div>;
}
return (
<div className="wallet-connector">
{!account ? (
<button onClick={connectWallet} disabled={isConnecting}>
{isConnecting ? '连接中...' : '连接钱包'}
</button>
) : (
<div className="wallet-info">
{!isOnSupportedNetwork && (
<div className="network-warning">
当前网络不受支持。
<button onClick={handleSwitchNetwork}>切换到主网</button>
</div>
)}
<span className="address">{shortenAddress(account)}</span>
<button onClick={disconnectWallet} className="disconnect-btn">
断开
</button>
</div>
)}
{error && <div className="error">{error}</div>}
</div>
);
};
完整代码
考虑到篇幅,这里提供一个整合后的 hooks/useWallet.ts 核心代码概览,以及一个简单的 utils/address.ts:
// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
declare global {
interface Window {
ethereum?: any;
}
}
export const useWallet = () => {
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
const [signer, setSigner] = useState<ethers.Signer | null>(null);
const [account, setAccount] = useState<string>('');
const [chainId, setChainId] = useState<number>(0);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string>('');
const disconnectWallet = useCallback(() => {
setAccount('');
setSigner(null);
setChainId(0);
}, []);
const connectWallet = useCallback(async () => {
if (!provider) {
setError('Provider 未初始化');
return;
}
setIsConnecting(true);
setError('');
try {
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
setAccount(currentAccount);
setSigner(provider.getSigner());
const network = await provider.getNetwork();
setChainId(network.chainId);
} catch (err: any) {
console.error('连接钱包失败:', err);
setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
} finally {
setIsConnecting(false);
}
}, [provider]);
useEffect(() => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展');
return;
}
const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any');
setProvider(initProvider);
initProvider.listAccounts().then((accounts) => {
if (accounts.length > 0) {
setAccount(accounts[0]);
setSigner(initProvider.getSigner());
}
});
initProvider.getNetwork().then((network) => {
setChainId(network.chainId);
});
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnectWallet();
} else if (accounts[0] !== account) {
setAccount(accounts[0]);
if (initProvider) {
setSigner(initProvider.getSigner());
}
}
};
const handleChainChanged = (_chainId: string) => {
setChainId(parseInt(_chainId, 16));
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, [disconnectWallet]); // 注意依赖,这里只依赖了稳定的 disconnectWallet
return {
provider,
signer,
account,
chainId,
isConnecting,
error,
connectWallet,
disconnectWallet,
};
};
// utils/address.ts
export const shortenAddress = (address: string, chars = 4): string => {
if (!address) return '';
return `${address.substring(0, chars + 2)}...${address.substring(42 - chars)}`;
};
踩坑记录
Provider 未初始化错误:在connectWallet函数中直接使用provider,但provider的初始化在useEffect中,是异步的。在用户快速点击连接按钮时,provider可能还是null。解决:在函数开始处增加if (!provider) return;的判断。- 重复监听事件导致内存泄漏:最初我把事件监听写在了一个没有依赖数组的
useEffect里,导致组件每次渲染都绑定新监听器,旧监听器未移除。解决:确保useEffect有正确的依赖数组,并在清理函数中removeListener。 - 网络切换后
signer失效的错觉:用户切换网络后,我最初错误地认为需要重新创建provider和signer。实际上,ethers的Web3Provider实例在传入'any'参数后,可以跨网络工作,signer仍然有效。需要更新的只是chainId状态。解决:在handleChainChanged中只更新chainId,除非有特定业务需求,否则不重置provider/signer。 chainId类型不一致:ethers的getNetwork()返回的chainId是number,而window.ethereum的chainChanged事件返回的是十六进制string。直接比较会导致判断失败。解决:统一转换为数字类型再比较,使用parseInt(_chainId, 16)。
小结
通过这次实践,我深刻体会到 Web3 前端连接钱包不仅仅是弹出一个授权窗口,更是一个需要持续监听和同步外部状态(账户、网络)的复杂功能。封装一个自定义 Hook 来集中管理这些状态和副作用,是让代码保持清晰、可维护的关键。下一步,可以在此基础上集成钱包余额查询、交易发送监听、以及多钱包提供商(如 WalletConnect)的支持。