背景
上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户点击“连接钱包”按钮,弹出 MetaMask 授权,连接成功后显示用户地址和余额。作为有几年经验的 Web3 开发者,我心想这还不是手到擒来?直接上 ethers.js 这个老伙计,几行代码搞定。于是,我新建了一个 React 组件,信心满满地开始敲代码。没想到,就是这个看似基础的功能,让我在接下来的一天里,跟各种奇怪的报错和边界情况斗智斗勇。
问题分析
我最开始的思路非常直接:在组件挂载时,检查 window.ethereum 是否存在(即用户是否安装了 MetaMask),然后调用 ethereum.request({ method: 'eth_requestAccounts' }) 请求账户授权,最后用 new ethers.providers.Web3Provider(window.ethereum) 创建 provider 来读取链上数据。
第一版代码跑起来,点击按钮,MetaMask 确实弹出来了,授权也很顺利。控制台打印出了地址,我正准备庆祝,问题就来了。
- 页面刷新后,登录状态丢失:用户需要重新点击连接。这体验太差了,我们的产品经理第一个不答应。
- 切换 MetaMask 账户时,前端页面没反应:用户在钱包里换了账号,但我们的网站显示的依然是旧地址。
- 切换网络时页面卡住:用户从以太坊主网切换到 Polygon,页面有时会卡死,需要手动刷新。
我意识到,我把问题想简单了。一个生产级的钱包连接,不仅仅是“弹出授权框拿到地址”,它必须是一个有状态、能响应变化、并且持久化的连接。我需要监听钱包的各种事件(账户变化、网络变化),并妥善管理这些状态,使其与 React 组件的状态同步。
核心实现
第一步:检测 Provider 与初始化状态
首先,我们不能假设用户一定装了 MetaMask。所以,检测 window.ethereum 是第一步,并且最好在组件生命周期早期进行。
这里有个坑:window.ethereum 的类型在 TypeScript 中是 any 或 unknown。为了更好的类型安全,我将其断言为 ethers.providers.ExternalProvider,但更严谨的做法是使用 ethers 提供的类型工具,或者直接检查必要的方法是否存在。
我决定在自定义 Hook (useWallet) 的初始化阶段完成检测和基础设置。
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
// 声明全局的 ethereum 类型
declare global {
interface Window {
ethereum?: ethers.providers.ExternalProvider & {
isMetaMask?: boolean;
request: (args: { method: string; params?: any[] }) => Promise<any>;
on: (event: string, callback: (...args: any[]) => void) => void;
removeListener: (event: string, callback: (...args: any[]) => void) => void;
};
}
}
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(() => {
const checkIfWalletIsConnected = async () => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展!');
return;
}
try {
// 尝试获取已授权的账户
const accounts = await window.ethereum.request({
method: 'eth_accounts',
});
if (accounts.length > 0) {
// 如果已有授权账户,直接初始化 provider 和 signer
await initProviderAndSigner(accounts[0]);
}
// 获取当前网络ID
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
setChainId(parseInt(chainIdHex, 16));
} catch (err) {
console.error('初始化检查钱包连接失败:', err);
}
};
checkIfWalletIsConnected();
}, []);
}
eth_accounts 这个方法是关键,它不会弹出授权框,而是静默返回已被当前 DApp 授权的账户列表。如果列表不为空,说明用户之前已经连接过,我们可以直接恢复状态。这是解决“刷新后状态丢失”问题的核心。
第二步:实现连接与断开功能
连接功能就是主动弹出授权请求。这里要注意错误处理,特别是用户拒绝授权的情况。
const connectWallet = useCallback(async () => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展!');
return;
}
setIsConnecting(true);
setError('');
try {
// 1. 请求账户授权,这会弹出 MetaMask 窗口
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
// 2. 用获取到的第一个账户初始化
await initProviderAndSigner(accounts[0]);
// 3. 获取当前网络
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
setChainId(parseInt(chainIdHex, 16));
} catch (err: any) {
// 用户拒绝授权是最常见的错误
if (err.code === 4001) {
setError('您拒绝了钱包连接请求。');
} else {
setError(`连接失败: ${err.message}`);
}
console.error('连接钱包失败:', err);
} finally {
setIsConnecting(false);
}
}, []);
const disconnectWallet = useCallback(() => {
// 注意:ethers.js 和 MetaMask 没有真正的“断开连接”API。
// 所谓的断开,只是清除我们本地应用的状态。
setProvider(null);
setSigner(null);
setAccount('');
setChainId(0);
setError('');
// 在实际项目中,你可能还需要清除 localStorage/SessionStorage 中的相关状态
}, []);
这里有个大坑:很多新手(包括当时的我)会寻找 disconnect 或 logout 方法。但实际上,MetaMask 的权限模型是“一次授权,持续有效”,直到用户在其钱包界面手动移除站点权限。所以前端的“断开”只是前端自己清空状态,下次用 eth_accounts 检查时,如果用户没移除权限,还是会拿到地址。这是一个重要的认知点。
第三步:监听钱包事件(关键!)
这是让应用“活”起来,响应外部变化的核心。我们需要监听 accountsChanged 和 chainChanged 事件。
// 初始化 provider 和 signer 的辅助函数
const initProviderAndSigner = useCallback(async (accountAddress: string) => {
if (!window.ethereum) return;
// 创建 Provider
const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
setProvider(web3Provider);
// 创建 Signer
const web3Signer = web3Provider.getSigner();
setSigner(web3Signer);
setAccount(accountAddress);
}, []);
// 设置事件监听
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
console.log('accountsChanged 事件触发:', accounts);
if (accounts.length === 0) {
// 用户在所有界面断开了连接,或者切换到了一个没有权限的账户
disconnectWallet();
setError('请连接您的钱包账户。');
} else if (accounts[0] !== account) {
// 用户切换了账户
initProviderAndSigner(accounts[0]);
}
};
const handleChainChanged = (_chainId: string) => {
// 注意:chainId 是十六进制字符串
console.log('chainChanged 事件触发:', _chainId);
// 当网络切换时,MetaMask 建议刷新页面,因为许多链上数据可能失效。
// 但为了更好体验,我们可以只重置部分状态并重新获取链ID。
window.location.reload();
// 更优雅的做法:不刷新,只更新 chainId 并重新初始化 provider(可能需要新的 RPC 配置)
// setChainId(parseInt(_chainId, 16));
// initProviderAndSigner(account); // 重新初始化,因为网络变了
};
// 绑定监听器
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
// 组件卸载时清理监听器,防止内存泄漏
return () => {
if (window.ethereum) {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
}
};
}, [account, disconnectWallet, initProviderAndSigner]);
注意这个细节:chainChanged 事件的处理。早期文档和很多教程都建议直接 window.location.reload(),因为网络切换后,旧的 provider 实例可能指向错误的 RPC。虽然刷新简单粗暴,但体验不好。更优的方案是:更新 chainId,然后基于新的 chainId 创建一个新的 provider 实例(如果你配置了多链 RPC 的话)。我这里为了代码清晰,先用了刷新方案。
第四步:获取余额与完善 UI
有了 provider 和 account,获取余额就很简单了。但要注意异步操作和错误处理。
const [balance, setBalance] = useState<string>('0');
// 获取余额的函数
const fetchBalance = useCallback(async () => {
if (!provider || !account) {
setBalance('0');
return;
}
try {
const balanceWei = await provider.getBalance(account);
// 格式化为 Ether 单位,保留4位小数
const balanceEth = ethers.utils.formatEther(balanceWei);
setBalance(parseFloat(balanceEth).toFixed(4));
} catch (err) {
console.error('获取余额失败:', err);
setBalance('0');
}
}, [provider, account]);
// 当 account 或 provider 变化时,重新获取余额
useEffect(() => {
fetchBalance();
}, [fetchBalance]);
最后,将这些状态和方法暴露给组件,一个基础但健壮的钱包连接 Hook 就完成了。
完整代码
以下是一个整合了上述所有功能的 React 组件示例:
// WalletConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
declare global {
interface Window {
ethereum?: ethers.providers.ExternalProvider & {
isMetaMask?: boolean;
request: (args: { method: string; params?: any[] }) => Promise<any>;
on: (event: string, callback: (...args: any[]) => void) => void;
removeListener: (event: string, callback: (...args: any[]) => void) => void;
};
}
}
const WalletConnector: React.FC = () => {
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 [balance, setBalance] = useState<string>('0');
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string>('');
const initProviderAndSigner = useCallback(async (accountAddress: string) => {
if (!window.ethereum) return;
const web3Provider = new ethers.providers.Web3Provider(window.ethereum);
const web3Signer = web3Provider.getSigner();
setProvider(web3Provider);
setSigner(web3Signer);
setAccount(accountAddress);
}, []);
const fetchBalance = useCallback(async () => {
if (!provider || !account) {
setBalance('0');
return;
}
try {
const balanceWei = await provider.getBalance(account);
const balanceEth = ethers.utils.formatEther(balanceWei);
setBalance(parseFloat(balanceEth).toFixed(4));
} catch (err) {
console.error('获取余额失败:', err);
setBalance('0');
}
}, [provider, account]);
const connectWallet = useCallback(async () => {
if (!window.ethereum) {
setError('请安装 MetaMask 钱包扩展!');
return;
}
setIsConnecting(true);
setError('');
try {
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
await initProviderAndSigner(accounts[0]);
const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
setChainId(parseInt(chainIdHex, 16));
} catch (err: any) {
if (err.code === 4001) {
setError('连接请求被拒绝。');
} else {
setError(`连接失败: ${err.message}`);
}
} finally {
setIsConnecting(false);
}
}, [initProviderAndSigner]);
const disconnectWallet = useCallback(() => {
setProvider(null);
setSigner(null);
setAccount('');
setChainId(0);
setBalance('0');
setError('');
}, []);
// 初始化检查与事件监听
useEffect(() => {
if (!window.ethereum) {
setError('未检测到 Web3 钱包。请安装 MetaMask。');
return;
}
const checkInitialConnection = async () => {
try {
const accounts = await window.ethereum!.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
await initProviderAndSigner(accounts[0]);
}
const chainIdHex = await window.ethereum!.request({ method: 'eth_chainId' });
setChainId(parseInt(chainIdHex, 16));
} catch (err) {
console.error('初始连接检查出错:', err);
}
};
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnectWallet();
setError('账户已断开。');
} else if (accounts[0] !== account) {
initProviderAndSigner(accounts[0]);
}
};
const handleChainChanged = (_chainId: string) => {
// 简单处理:刷新页面
window.location.reload();
};
checkInitialConnection();
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum?.removeListener('chainChanged', handleChainChanged);
};
}, [account, disconnectWallet, initProviderAndSigner]);
// 余额监听
useEffect(() => {
fetchBalance();
}, [fetchBalance]);
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h2>钱包连接状态</h2>
{error && <p style={{ color: 'red' }}>错误: {error}</p>}
{!account ? (
<button onClick={connectWallet} disabled={isConnecting}>
{isConnecting ? '连接中...' : '连接 MetaMask'}
</button>
) : (
<div>
<p><strong>连接地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
<p><strong>网络 ID:</strong> {chainId}</p>
<p><strong>余额:</strong> {balance} ETH</p>
<button onClick={disconnectWallet} style={{ marginTop: '10px' }}>
断开连接(前端)
</button>
<p style={{ fontSize: '0.8em', color: '#666', marginTop: '5px' }}>
(注:需在 MetaMask 中移除站点权限才能完全断开)
</p>
</div>
)}
<div style={{ marginTop: '20px', fontSize: '0.9em', color: '#333' }}>
<p>试试以下操作,观察页面变化:</p>
<ul>
<li>在 MetaMask 中切换账户</li>
<li>在 MetaMask 中切换网络(如 Goerli 测试网)</li>
<li>刷新页面</li>
</ul>
</div>
</div>
);
};
export default WalletConnector;
踩坑记录
window.ethereum类型错误:在 TypeScript 中直接使用window.ethereum会报类型错误。我一开始用(window as any).ethereum粗暴解决,后来发现这不利于代码维护。最终通过扩展global接口提供了更精确的类型定义,并检查必要方法是否存在。accountsChanged事件在断开时触发空数组:我最初只监听新账户,没处理accounts.length === 0的情况。导致用户在 MetaMask 里断开连接后,我的应用界面还显示着旧地址。加上这个判断后,体验才正常。- 网络切换后 Provider 失效:这是我遇到最棘手的问题。用户切换网络后,旧的
provider实例发出的请求可能仍发往旧的 RPC 节点,导致各种UNSUPPORTED_OPERATION或网络错误。我尝试过在chainChanged事件里创建新的provider,但有时会碰到异步时序问题。最后,对于这个简单 demo,我采用了 MetaMask 官方早期文档推荐的页面刷新方案。在真实复杂项目中,需要结合项目状态管理库(如 Redux、Zustand)和自定义的多链 RPC 配置来更优雅地处理。 - 余额显示单位问题:
provider.getBalance()返回的是BigNumber类型的wei单位。直接toString()会显示一长串数字。必须用ethers.utils.formatEther()进行单位转换。同时要注意转换后的精度显示,避免出现过多小数位。
小结
这次折腾让我彻底明白,一个稳定的钱包连接不仅仅是调用一个 API,而是一个需要持续维护状态、监听外部事件、并妥善处理各种边界情况的完整功能模块。虽然现在有 wagmi、RainbowKit 这样优秀的封装库,但理解其底层原理,亲手用 ethers.js 实现一遍,对于排查复杂问题和构建定制化需求依然至关重要。下一步,我可以在此基础上集成多链支持、钱包连接缓存(localStorage)以及更优雅的网络切换处理逻辑。