背景
上个月,我接手了一个新的 NFT 铸造平台前端项目。项目要求很简单:用户点击一个"连接钱包"按钮,弹出 MetaMask 进行连接和授权,然后前端获取到用户的以太坊地址并显示出来。这听起来是 Web3 开发的"Hello World",对吧?我心想,用老伙计 ethers.js 应该分分钟搞定。毕竟我之前在 DeFi 项目里用过很多次了。于是,我自信满满地开始敲代码,没想到接下来的几个小时,我几乎把 ethers.js 连接钱包的常见坑全踩了一遍。从 window.ethereum 为 undefined 到账户切换监听失效,再到网络切换时的状态混乱,整个过程堪称一部小型历险记。
问题分析
我最开始的思路非常直接:在 React 组件的 useEffect 里,或者在一个按钮的点击事件中,直接调用 ethers.providers.Web3Provider 并传入 window.ethereum,然后调用 provider.send("eth_requestAccounts") 来请求账户。代码大概长这样:
javascript
const connectWallet = async () => {
if (window.ethereum) {
const provider = new ethers.providers.Web3Provider(window.ethereum);
const accounts = await provider.send("eth_requestAccounts", []);
setAccount(accounts[0]);
} else {
alert('请安装 MetaMask!');
}
};
但一运行就出问题了。首先,开发服务器热更新时,有时会报错 window.ethereum is undefined。其次,连接成功后,我切换到 MetaMask 的另一个账户,前端页面上的地址并没有自动更新。最后,当用户在 MetaMask 里切换网络(比如从 Goerli 切到 Mainnet),我的应用完全感知不到,还显示着旧网络下的状态。
我意识到,我把问题想得太简单了。一个健壮的钱包连接模块,至少需要处理三件事:1. Provider 的可靠获取 (处理未安装钱包、页面加载时机);2. 账户变化的监听 ;3. 网络变化的监听。而我最初的代码,只完成了最基础的"一次性连接"功能。
核心实现
第一步:安全地获取 Provider 并连接账户
首先,我们不能直接假设 window.ethereum 存在。用户可能没安装 MetaMask,或者我们的代码在服务器端渲染(SSR)时执行。所以,获取 Provider 的逻辑必须放在客户端生命周期内,并且做好错误处理。
这里有个坑: window.ethereum 的类型。在 TypeScript 中,直接访问会报错。我们需要扩展 Window 接口。同时,MetaMask 注入的 ethereum 对象有一个 request 方法,但 ethers.js 的 Web3Provider 封装得很好,我们通常用 provider.send 或 provider.getSigner。
我的思路是:创建一个自定义 Hook,比如叫 useEthereumProvider,来安全地创建和管理 Provider 实例。
typescript
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useEffect, useState } from 'react';
// 扩展 Window 接口以包含 ethereum
declare global {
interface Window {
ethereum?: any;
}
}
export const useEthereumProvider = () => {
const [provider, setProvider] = useState<BrowserProvider | null>(null);
const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
useEffect(() => {
// 确保在客户端环境下执行
if (typeof window !== 'undefined' && window.ethereum) {
// 注意:ethers v6 中,Web3Provider 已更名为 BrowserProvider
const ethersProvider = new BrowserProvider(window.ethereum);
setProvider(ethersProvider);
// 尝试获取已连接的账户
ethersProvider.getSigner().then(s => setSigner(s)).catch(console.error);
}
}, []); // 空依赖数组,仅初始化一次
return { provider, signer };
};
注意,我用了 ethers v6 的 BrowserProvider。如果你还在用 v5,请使用 ethers.providers.Web3Provider。这个 Hook 在组件挂载时安全地初始化 Provider。
第二步:实现连接钱包函数
有了 Provider,接下来实现具体的连接函数。这个函数需要处理用户点击"连接钱包"按钮的动作。
typescript
const [account, setAccount] = useState<string>('');
const { provider } = useEthereumProvider();
const handleConnect = async () => {
if (!provider) {
alert('未检测到钱包Provider,请确认MetaMask已安装');
return;
}
try {
// 请求账户访问权限。这里会弹出MetaMask授权窗口。
const accounts = await provider.send('eth_requestAccounts', []);
if (accounts && accounts[0]) {
setAccount(accounts[0]);
// 获取 Signer 实例,用于后续签名交易
const signer = await provider.getSigner();
// 你可以将 signer 存储到状态或 context 中
}
} catch (error: any) {
console.error('连接钱包失败:', error);
// 用户拒绝了请求
if (error.code === 4001) {
alert('您拒绝了连接请求。');
}
}
};
注意这个细节: provider.send('eth_requestAccounts', []) 是触发 MetaMask 弹出授权窗口的关键调用。它返回一个 Promise,用户授权后 resolve,拒绝后 reject 并带有错误码 4001。
第三步:监听账户和网络变化
这是让应用"活"起来的关键。MetaMask 允许用户随时切换账户或网络,我们的前端需要实时响应。
window.ethereum 对象提供了 on 方法用于监听事件。主要监听两个事件:'accountsChanged' 和 'chainChanged'。
typescript
useEffect(() => {
// 确保 ethereum 对象存在
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
console.log('accountsChanged', accounts);
if (accounts.length === 0) {
// 用户断开了连接,或者锁定了钱包
setAccount('');
alert('请连接您的钱包。');
} else if (accounts[0] !== account) {
// 切换到了新账户
setAccount(accounts[0]);
// 通常这里需要重新获取 Signer,因为账户变了
if (provider) {
provider.getSigner().then(newSigner => {
// 更新 signer 状态
});
}
}
};
const handleChainChanged = (_chainId: string) => {
// _chainId 是十六进制字符串,例如 '0x1' (Mainnet)
console.log('chainChanged', _chainId);
// 当网络切换时,MetaMask 建议页面重载
// 但为了更好体验,我们可以不重载,而是更新应用内的网络状态,并重置相关数据
window.location.reload(); // 简单粗暴但有效
// 更优方案:更新 networkId 状态,并重新初始化合约实例等
};
// 添加监听
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, provider]); // 依赖 account 和 provider
这里有个大坑: 关于 chainChanged 事件的处理。MetaMask 官方文档早期建议在 chainChanged 时刷新页面,因为许多 dApp 的状态(如合约实例)依赖于网络。虽然不刷新也可以,但你需要手动更新所有依赖网络的状态。为了简单可靠,我选择了刷新页面。在更复杂的应用中,你可能需要设计一个状态管理系统来优雅地处理网络切换。
第四步:获取当前网络信息
除了账户,我们通常还需要知道用户当前连接到了哪个网络。
typescript
const [chainId, setChainId] = useState<number | null>(null);
const { provider } = useEthereumProvider();
useEffect(() => {
if (!provider) return;
const fetchNetwork = async () => {
try {
const network = await provider.getNetwork();
// network.chainId 是 BigInt 类型 (ethers v6)
setChainId(Number(network.chainId));
} catch (error) {
console.error('获取网络信息失败:', error);
}
};
fetchNetwork();
// 注意:provider.getNetwork() 可能不会随 chainChanged 自动更新。
// 所以我们依赖上一步的 chainChanged 事件,触发重新获取或页面刷新。
}, [provider]);
完整代码
下面是一个整合了以上所有功能的、可直接运行的 React 组件示例。
typescript
// WalletConnector.tsx
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import React, { useEffect, useState } from 'react';
declare global {
interface Window {
ethereum?: any;
}
}
const WalletConnector: React.FC = () => {
const [provider, setProvider] = useState<BrowserProvider | null>(null);
const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
const [account, setAccount] = useState<string>('');
const [chainId, setChainId] = useState<number | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// 1. 初始化 Provider
useEffect(() => {
if (typeof window !== 'undefined' && window.ethereum) {
const ethersProvider = new BrowserProvider(window.ethereum);
setProvider(ethersProvider);
// 尝试静默获取已连接的账户
ethersProvider.getSigner()
.then(s => {
setSigner(s);
s.getAddress().then(addr => setAccount(addr));
})
.catch(() => {/* 用户未连接,忽略错误 */});
}
}, []);
// 2. 获取初始网络
useEffect(() => {
if (!provider) return;
provider.getNetwork().then(network => {
setChainId(Number(network.chainId));
});
}, [provider]);
// 3. 设置事件监听
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
console.log('账户变更:', accounts);
if (accounts.length === 0) {
// 断开连接
setAccount('');
setSigner(null);
alert('钱包已断开。');
} else if (accounts[0] !== account) {
setAccount(accounts[0]);
// 更新 signer
provider?.getSigner().then(s => setSigner(s));
}
};
const handleChainChanged = (_chainId: string) => {
console.log('网络变更:', _chainId);
// 简单处理:刷新页面
window.location.reload();
};
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, provider]);
// 4. 连接钱包函数
const handleConnect = async () => {
if (!provider) {
alert('请安装 MetaMask 钱包扩展!');
return;
}
setLoading(true);
try {
const accounts = await provider.send('eth_requestAccounts', []);
const currentAccount = accounts[0];
setAccount(currentAccount);
const currentSigner = await provider.getSigner();
setSigner(currentSigner);
// 获取并更新网络
const network = await provider.getNetwork();
setChainId(Number(network.chainId));
} catch (error: any) {
console.error('连接失败:', error);
if (error.code === 4001) {
alert('连接请求被拒绝。');
}
} finally {
setLoading(false);
}
};
// 5. 断开连接 (MetaMask 没有真正的"断开",这里只是清除本地状态)
const handleDisconnect = () => {
setAccount('');
setSigner(null);
alert('已断开本地连接。如需完全断开,请在 MetaMask 中操作。');
};
return (
<div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
<h3>钱包连接状态</h3>
{!provider ? (
<p>⚠️ 未检测到钱包Provider。请确保 MetaMask 已安装并启用。</p>
) : (
<>
<p>
<strong>网络ID:</strong> {chainId ? `0x${chainId.toString(16)}` : '未知'}
</p>
<p>
<strong>当前账户:</strong> {account ? `${account.substring(0, 6)}...${account.substring(account.length - 4)}` : '未连接'}
</p>
<div>
{!account ? (
<button onClick={handleConnect} disabled={loading}>
{loading ? '连接中...' : '连接 MetaMask'}
</button>
) : (
<div>
<button onClick={handleDisconnect} style={{ marginLeft: '10px' }}>
断开连接
</button>
</div>
)}
</div>
{signer && (
<p style={{ marginTop: '10px', color: 'green' }}>
✅ Signer 已就绪,可进行签名操作。
</p>
)}
</>
)}
</div>
);
};
export default WalletConnector;
踩坑记录
-
window.ethereum is undefined(Next.js/SSR 环境)- 现象: 在 Next.js 项目中,组件首次渲染或热更新时控制台报错。
- 原因: 代码在服务端或构建时执行,
window对象不存在。 - 解决: 所有访问
window.ethereum的代码都必须包裹在if (typeof window !== 'undefined')条件判断中,或放在useEffect、事件处理函数等客户端生命周期钩子中。
-
账户切换后页面不更新
- 现象: 在 MetaMask 里切换了账户,但 dApp 页面上显示的地址还是旧的。
- 原因: 没有监听
accountsChanged事件。 - 解决: 按照上文所述,正确添加
window.ethereum.on('accountsChanged', callback)监听,并在回调中更新 React 状态。注意: 当用户断开连接时,accounts数组为空,需要处理这个情况。
-
网络切换后合约调用出错
- 现象: 用户从 Goerli 切换到 Mainnet,dApp 仍尝试在旧网络的合约地址上调用,导致 RPC 错误。
- 原因: 没有监听
chainChanged事件,或监听后没有更新依赖网络的合约实例等状态。 - 解决: 监听
chainChanged事件。采用简单方案(刷新页面)或复杂方案(更新全局网络状态并重新初始化所有网络相关的对象,如 Provider、Signer、合约实例等)。
-
ethers v5 与 v6 的 API 差异
- 现象: 照着旧教程写代码,发现
Web3Provider等类找不到。 - 原因: 项目安装的是
ethersv6,其 API 有重大变更。 - 解决: 查阅官方升级指南。关键变化:
ethers.providers.Web3Provider变为ethers.BrowserProvider;provider.getSigner().getAddress()返回 Promise;chainId是 BigInt 类型。务必检查你使用的版本。
- 现象: 照着旧教程写代码,发现
小结
通过这次实践,我深刻体会到 Web3 前端开发中"细节决定成败"。一个看似简单的钱包连接,需要考虑 Provider 的生命周期、用户交互的多种可能(授权、拒绝、切换)以及钱包状态的持续同步。最终稳定可用的代码,是初始化、请求授权、事件监听和状态管理四部分紧密协作的结果。如果你要在此基础上继续深挖,下一步可以考虑将钱包状态管理抽象为 Context 或使用状态管理库(如 Zustand、Jotai),以支持多组件共享,并集成更多钱包类型(如 WalletConnect)以实现更好的用户体验。