背景
上个月,我接手了一个新的 NFT 铸造平台前端开发。项目要求很简单:用户进入网站,点击“连接钱包”按钮,用 MetaMask 登录,然后页面显示其钱包地址和 ETH 余额。这听起来是 Web3 开发的“Hello World”,对吧?我心想,用老伙计 ethers.js 分分钟搞定。毕竟之前参与 DeFi 项目时也用过,感觉轻车熟路。于是,我新建了一个 React 项目,安装好 ethers,开始撸代码。没想到,就是这个看似简单的任务,让我在接下来的几个小时里,跟各种奇怪的错误和浏览器行为斗智斗勇。
问题分析
我最开始的思路非常直接:检查 window.ethereum 是否存在(这是 MetaMask 注入的对象),然后用 ethers.providers.Web3Provider 包装它,最后调用 provider.send('eth_requestAccounts') 来请求账户授权。代码一气呵成,运行,点击按钮——控制台一片寂静,页面毫无反应。
我第一反应是 MetaMask 没安装?检查了一下,扩展明明好好的。然后我加了一堆 console.log,发现 window.ethereum 确实是存在的。那问题出在哪?我仔细阅读了 ethers.js 文档,发现了一个关键点:MetaMask 从 v8 开始,window.ethereum 的 API 发生了变化,它现在是一个 EIP-1193 规范的 Provider,而 ethers.js 的 Web3Provider 正是为了适配这种规范而设计的。我的思路没错啊。
接着,我尝试在按钮点击事件里直接写:
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
这次弹窗出来了!这说明基础连接请求是通的。那么问题就锁定在 ethers.js 的用法上了。我意识到,我可能忽略了异步状态和 React 生命周期的配合,以及一些错误处理的边界情况。是时候重新梳理,一步步构建一个健壮的连接流程了。
核心实现
第一步:检测 Provider 与浏览器兼容性
首先,我们不能假设用户一定安装了 MetaMask。因此,连接之前必须先做检测。同时,现代 MetaMask 也可能同时注入 window.ethereum 和旧的 window.web3,我们应该优先使用新的。
// 检测函数
const checkIfMetaMaskInstalled = () => {
// 检查是否有 EIP-1193 规范的 provider
if (window.ethereum && window.ethereum.isMetaMask) {
return true;
}
// 如果用户使用的是非常老的版本,可能只有 window.web3
if (window.web3 && window.web3.currentProvider) {
console.warn('检测到旧版 MetaMask,建议用户升级。');
// 这里可以做一些降级处理,但为了简单,我们先返回false引导用户
return false;
}
return false;
};
这里有个坑:仅仅检查 window.ethereum 是不够的,因为其他钱包(如 Coinbase Wallet)也可能注入这个对象。所以加上 window.ethereum.isMetaMask 属性判断更准确。但注意,这个属性是 MetaMask 特有的。
第二步:初始化 Ethers Provider 和 Signer
检测通过后,我们需要初始化 ethers 的核心对象:Provider 和 Signer。Provider 是连接区块链的抽象,Signer 代表一个有签名权限的账户。
import { ethers } from 'ethers';
const initializeProviderAndSigner = async () => {
// 再次确认,避免竞态条件
if (!window.ethereum) {
throw new Error('请安装 MetaMask!');
}
// 1. 创建 Web3Provider
// 注意:ethers v5 和 v6 的导入方式不同,这里是 v5
const provider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 表示支持任何网络
// 2. 请求账户授权,这会弹出 MetaMask 窗口
await provider.send('eth_requestAccounts', []);
// 3. 获取 Signer
const signer = provider.getSigner();
// 4. 获取当前账户地址
const address = await signer.getAddress();
return { provider, signer, address };
};
注意这个细节:new ethers.providers.Web3Provider(window.ethereum, ‘any’) 中的第二个参数 ‘any’。这是网络配置,‘any’ 允许任何网络。如果你只支持特定网络(如主网),可以传入 ‘homestead’。使用 ‘any’ 能让用户在切换网络(比如从以太坊主网切换到 Polygon)时,我们的 provider 能自动适应,而不会报错。
第三步:监听账户与网络变化
用户可能在连接后切换 MetaMask 账户,或者切换网络。如果我们的前端没有监听这些事件,状态就会不同步,导致显示错误的地址或余额。
const setupEventListeners = (provider: ethers.providers.Web3Provider, updateAccountCallback: (address: string) => void) => {
// 监听 accountsChanged 事件(用户切换账户)
window.ethereum.on('accountsChanged', (accounts: string[]) => {
if (accounts.length === 0) {
// 用户锁定了钱包或断开了所有账户
console.log('请连接钱包');
updateAccountCallback('');
} else {
// 账户切换了
console.log('当前账户变为:', accounts[0]);
updateAccountCallback(accounts[0]);
// 注意:这里不需要再次请求授权(eth_requestAccounts)
}
});
// 监听 chainChanged 事件(用户切换网络)
window.ethereum.on('chainChanged', (_chainId: string) => {
// 链ID是十六进制字符串,例如“0x1”(主网)
console.log('网络切换,新的Chain ID:', _chainId);
// 页面完全重载是最简单的方式,因为很多合约实例、provider都需要重新初始化
window.location.reload();
});
};
这里有个大坑:chainChanged 事件触发后,简单的更新状态可能不够。因为网络变了,之前初始化的 Provider 实例内部可能还缓存着旧网络的 RPC 信息,直接使用可能导致后续的 RPC 调用发往错误的网络。最稳妥的办法是刷新页面,让所有组件重新初始化。虽然体验略有中断,但能保证状态绝对干净。在更复杂的 DApp 中,你可能需要设计一个更精细的状态管理方案来优雅地处理网络切换。
第四步:获取账户余额并整合到 React 状态
最后,我们把上面的功能整合到一个 React 组件中,并获取账户的 ETH 余额。
import { useState, useEffect, useCallback } from 'react';
const useMetaMask = () => {
const [account, setAccount] = useState<string>('');
const [balance, setBalance] = useState<string>('');
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const connectWallet = useCallback(async () => {
setIsConnecting(true);
setError('');
try {
if (!checkIfMetaMaskInstalled()) {
throw new Error('未检测到 MetaMask,请安装后重试。');
}
const { provider, signer, address } = await initializeProviderAndSigner();
setAccount(address);
// 获取余额
const balanceRaw = await provider.getBalance(address);
const balanceFormatted = ethers.utils.formatEther(balanceRaw);
setBalance(balanceFormatted);
// 设置事件监听
setupEventListeners(provider, (newAddress) => {
setAccount(newAddress);
if (newAddress) {
// 如果切换到了新账户,重新获取余额
provider.getBalance(newAddress).then(bal => setBalance(ethers.utils.formatEther(bal)));
} else {
setBalance('');
}
});
} catch (err: any) {
console.error('连接钱包失败:', err);
setError(err.message || '连接失败');
setAccount('');
setBalance('');
} finally {
setIsConnecting(false);
}
}, []); // 依赖项为空,因为这个函数只在初始化时定义一次
// 组件卸载时,移除事件监听(避免内存泄漏)
useEffect(() => {
return () => {
if (window.ethereum && window.ethereum.removeListener) {
// 注意:ethers provider 包装后,事件源还是 window.ethereum
window.ethereum.removeAllListeners('accountsChanged');
window.ethereum.removeAllListeners('chainChanged');
}
};
}, []);
return { account, balance, isConnecting, error, connectWallet };
};
注意这个细节:获取的余额是 BigNumber 类型,单位是 wei(1 ETH = 10^18 wei)。必须用 ethers.utils.formatEther 将其转换为可读的 ETH 单位字符串。另外,错误处理非常重要,要把 MetaMask 抛出的错误(比如用户拒绝连接)友好地展示给用户。
完整代码
下面是一个可以直接在 React 项目中使用的完整组件示例:
// MetaMaskConnector.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
// 类型声明
declare global {
interface Window {
ethereum?: any;
web3?: any;
}
}
const MetaMaskConnector: React.FC = () => {
const [account, setAccount] = useState<string>('');
const [balance, setBalance] = useState<string>('');
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
// 1. 检测 MetaMask
const checkIfMetaMaskInstalled = useCallback((): boolean => {
return !!(window.ethereum && window.ethereum.isMetaMask);
}, []);
// 2. 初始化
const initializeWallet = useCallback(async () => {
if (!window.ethereum) throw new Error('未安装 MetaMask');
const prov = new ethers.providers.Web3Provider(window.ethereum, 'any');
await prov.send('eth_requestAccounts', []);
const signer = prov.getSigner();
const address = await signer.getAddress();
return { prov, address };
}, []);
// 3. 连接钱包主函数
const connectWallet = useCallback(async () => {
setIsConnecting(true);
setError('');
try {
if (!checkIfMetaMaskInstalled()) {
throw new Error('请安装 MetaMask 浏览器扩展。');
}
const { prov, address } = await initializeWallet();
setProvider(prov);
setAccount(address);
// 获取余额
const balanceRaw = await prov.getBalance(address);
setBalance(ethers.utils.formatEther(balanceRaw));
} catch (err: any) {
console.error('连接失败:', err);
setError(err.message || '未知错误');
setAccount('');
setBalance('');
} finally {
setIsConnecting(false);
}
}, [checkIfMetaMaskInstalled, initializeWallet]);
// 4. 设置事件监听
useEffect(() => {
if (!provider || !window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
// 用户锁定了钱包
setAccount('');
setBalance('');
setError('钱包已断开连接。');
} else if (accounts[0] !== account) {
// 切换了账户
setAccount(accounts[0]);
provider.getBalance(accounts[0]).then(bal => {
setBalance(ethers.utils.formatEther(bal));
});
}
};
const handleChainChanged = () => {
// 网络切换,建议刷新页面
window.location.reload();
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
// 清理函数
return () => {
if (window.ethereum && window.ethereum.removeListener) {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
}
};
}, [provider, account]);
// 5. 页面加载时尝试自动连接(可选,谨慎使用)
useEffect(() => {
const tryAutoConnect = async () => {
if (checkIfMetaMaskInstalled() && window.ethereum.isConnected()) {
// 检查是否已经授权过
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
if (accounts.length > 0) {
// 自动连接
connectWallet();
}
}
};
tryAutoConnect();
}, [checkIfMetaMaskInstalled, connectWallet]);
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h2>MetaMask 钱包连接示例</h2>
{!account ? (
<div>
<button
onClick={connectWallet}
disabled={isConnecting}
style={{
padding: '10px 20px',
fontSize: '16px',
cursor: isConnecting ? 'wait' : 'pointer',
}}
>
{isConnecting ? '连接中...' : '连接 MetaMask'}
</button>
{error && <p style={{ color: 'red' }}>错误: {error}</p>}
</div>
) : (
<div>
<p><strong>连接成功!</strong></p>
<p><strong>账户地址:</strong> {`${account.substring(0, 6)}...${account.substring(account.length - 4)}`}</p>
<p><strong>ETH 余额:</strong> {parseFloat(balance).toFixed(4)} ETH</p>
<button
onClick={() => {
setAccount('');
setBalance('');
setError('');
}}
style={{ marginTop: '10px', padding: '5px 10px' }}
>
断开连接(前端)
</button>
<p style={{ fontSize: '12px', color: '#666' }}>
(注意:这只是前端清除状态,MetaMask 中的连接授权仍需在其界面内管理)
</p>
</div>
)}
{!checkIfMetaMaskInstalled() && (
<p style={{ color: 'orange', marginTop: '10px' }}>
未检测到 MetaMask。请
<a href="https://metamask.io/download/" target="_blank" rel="noopener noreferrer">下载安装</a>
后刷新页面。
</p>
)}
</div>
);
};
export default MetaMaskConnector;
踩坑记录
-
window.ethereum为undefined,但 MetaMask 已安装。- 问题:在 Next.js 或 SSR 框架中,代码可能在服务器端执行,那里没有
window对象。 - 解决:所有对
window.ethereum的访问都必须放在useEffect中或通过typeof window !== ‘undefined’进行保护。
- 问题:在 Next.js 或 SSR 框架中,代码可能在服务器端执行,那里没有
-
用户拒绝连接后,再次点击按钮无效。
- 问题:MetaMask 会记住用户的拒绝操作,短时间内再次调用
eth_requestAccounts不会弹出窗口。 - 解决:引导用户点击 MetaMask 扩展图标,在弹出界面中手动重置已拒绝的站点授权。这是 MetaMask 的用户体验设计,前端无法绕过。
- 问题:MetaMask 会记住用户的拒绝操作,短时间内再次调用
-
accountsChanged事件在初次连接时也触发了。- 问题:有些版本的 MetaMask 在用户授权账户后,会立即触发一次
accountsChanged事件,导致事件处理函数和初始化逻辑重复执行。 - 解决:在事件处理函数中,通过对比新旧账户地址来判断是初次连接还是主动切换。如果旧地址为空字符串,新地址有值,可以视为初次连接的一部分,避免不必要的状态更新或重复请求。
- 问题:有些版本的 MetaMask 在用户授权账户后,会立即触发一次
-
余额显示为巨大的整数。
- 问题:直接
console.log从provider.getBalance()获取的结果,显示为一个包含hex属性的对象或一个巨大的数字。 - 解决:这是
ethers.js的BigNumber类型。必须使用ethers.utils.formatEther进行单位转换。我差点自己写转换函数,幸好查了文档。
- 问题:直接
小结
通过这次实践,我深刻体会到 Web3 前端开发中“细节决定成败”——Provider的初始化参数、事件监听的绑定与清理、异步错误处理,每一个环节疏忽都可能导致功能失效。完整的钱包连接不仅仅是弹出授权窗口,更要考虑用户后续的所有操作路径。下一步,我可以在此基础上集成合约调用、签名消息等功能,并考虑用 wagmi 这样的高阶库来管理更复杂的状态。