用ethers.js连接MetaMask实现Web3钱包登录:从踩坑到稳定运行的完整记录
前言:一个看似简单的需求,让我折腾了两天
事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。
从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。
背景:一个DeFi看板项目的前端需求
当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。
核心需求其实就三个:
- 用户点击"连接钱包"按钮,弹出MetaMask授权窗口
- 用户签名一条消息,后端验证签名后返回JWT token
- 页面根据用户地址展示对应的链上数据
看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。
问题分析:为什么"一行代码"搞不定?
我最初的思路是直接写一个connectWallet函数:
// 第一版代码,天真到不行
async function connectWallet() {
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
setAddress(accounts[0]);
}
然后我发现了三个问题:
问题1:用户拒绝连接时,代码会直接崩溃。 如果用户点击了MetaMask弹窗的"取消"按钮,request会抛出一个错误,但我的代码没有捕获它,导致页面白屏。
问题2:连接后没有验证链ID。 用户可能连接的是以太坊主网,也可能连接的是Goerli测试网,但我根本没有检查。后来用户反馈说"连接后看不到资产",排查了半天才发现是链ID不匹配。
问题3:页面刷新后连接状态丢失。 用户连接成功后,刷新页面就需要重新连接。这体验太差了,而且每次刷新都弹MetaMask窗口,用户会疯的。
这三个问题让我意识到,钱包登录远不止"调一个接口"那么简单。我需要一个完整的连接流程,包括状态管理、错误处理、链ID验证和持久化。
核心实现:一步步搭建稳定的钱包登录
第一步:初始化Provider和检测MetaMask
在React中,我习惯把所有Web3相关的逻辑封装在一个自定义Hook里。首先,我需要一个provider——这是与区块链交互的底层对象。
// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';
export function useWallet() {
const [provider, setProvider] = useState<BrowserProvider | null>(null);
const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
const [address, setAddress] = useState<string>('');
const [chainId, setChainId] = useState<number>(0);
const [error, setError] = useState<string>('');
// 初始化:检测MetaMask是否安装
useEffect(() => {
if (typeof window.ethereum === 'undefined') {
setError('请安装MetaMask浏览器插件');
return;
}
// 注意:这里不要自动请求连接,只在用户点击按钮时才触发
const ethersProvider = new BrowserProvider(window.ethereum);
setProvider(ethersProvider);
}, []);
}
这里有个坑: 不要一加载页面就调用eth_requestAccounts。有些用户不想连接钱包,只是浏览页面,自动弹窗会吓到他们。正确的做法是只检测MetaMask是否存在,连接操作交给用户点击按钮触发。
第二步:实现连接逻辑,处理所有异常
连接钱包的核心是调用eth_requestAccounts,但必须做好错误处理。我当时用了一个笨办法——直接在catch里打印错误信息,后来发现不同错误类型需要不同处理方式。
// hooks/useWallet.ts(续)
const connect = useCallback(async () => {
if (!provider) {
setError('Provider未初始化');
return;
}
try {
// 关键:先请求账户,再获取签名者
const accounts = await provider.send('eth_requestAccounts', []);
if (accounts.length === 0) {
throw new Error('没有获取到账户');
}
const userAddress = accounts[0];
const userSigner = await provider.getSigner();
const network = await provider.getNetwork();
const userChainId = Number(network.chainId);
// 验证链ID是否在支持的范围内
const SUPPORTED_CHAIN_IDS = [1, 5, 137]; // 以太坊主网、Goerli、Polygon
if (!SUPPORTED_CHAIN_IDS.includes(userChainId)) {
// 这里可以提示用户切换网络,但先保存状态
console.warn(`当前链ID ${userChainId} 不在支持列表中`);
}
setAddress(userAddress);
setSigner(userSigner);
setChainId(userChainId);
setError('');
// 持久化:把地址存到localStorage,下次刷新时自动恢复
localStorage.setItem('walletAddress', userAddress);
localStorage.setItem('walletChainId', userChainId.toString());
} catch (err: any) {
// 处理不同类型的错误
if (err.code === 4001) {
// 用户拒绝了连接请求
setError('用户拒绝了连接请求');
} else if (err.code === -32002) {
// MetaMask正在处理另一个请求
setError('请先处理MetaMask中的其他请求');
} else {
setError(err.message || '连接钱包失败');
}
}
}, [provider]);
注意这个细节: 错误码4001是用户拒绝,-32002是重复请求。这两个错误码我查了MetaMask文档才搞清楚,之前一直用err.message判断,结果发现不同版本的MetaMask返回的消息格式不一样。
第三步:消息签名与后端验证
登录不只是连接钱包,还需要让后端验证用户身份。最常用的方式是"消息签名"——前端让用户签名一条包含随机数(nonce)的消息,后端用公钥验证签名。
// hooks/useWallet.ts(续)
const signMessage = useCallback(async (message: string): Promise<string> => {
if (!signer) {
throw new Error('请先连接钱包');
}
try {
// 注意:message应该包含一个nonce,防止重放攻击
const signature = await signer.signMessage(message);
return signature;
} catch (err: any) {
if (err.code === 4001) {
throw new Error('用户取消了签名');
}
throw new Error('签名失败: ' + err.message);
}
}, [signer]);
// 实际登录流程
const login = useCallback(async () => {
if (!address) {
setError('请先连接钱包');
return;
}
try {
// 1. 从后端获取nonce
const nonceResponse = await fetch('/api/auth/nonce?address=' + address);
const { nonce } = await nonceResponse.json();
// 2. 让用户签名nonce
const message = `欢迎登录DeFiDash,本次登录的随机码为:${nonce}`;
const signature = await signMessage(message);
// 3. 发送地址和签名到后端验证
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, signature, message }),
});
const { token } = await loginResponse.json();
// 存储token,后续API请求带上
localStorage.setItem('authToken', token);
setError('');
} catch (err: any) {
setError(err.message || '登录失败');
}
}, [address, signMessage]);
这里有个坑: 签名消息的格式很重要。有些项目直接签address,这太不安全了,因为任何网站都可以伪造。一定要包含nonce,并且最好加上一些上下文信息(比如"欢迎登录XXX"),让用户在MetaMask里能看到清晰的内容。
第四步:页面刷新后自动恢复连接状态
用户连接成功后刷新页面,如果直接显示"未连接",体验很差。我通过localStorage保存地址,在页面加载时尝试恢复。
// hooks/useWallet.ts(续)
// 页面加载时恢复连接
useEffect(() => {
const savedAddress = localStorage.getItem('walletAddress');
const savedChainId = localStorage.getItem('walletChainId');
if (savedAddress && provider) {
// 恢复时只设置地址,不主动请求连接
setAddress(savedAddress);
setChainId(Number(savedChainId));
// 注意:这里不设置signer,因为signer需要用户授权
// 实际使用时,如果用户需要签名,再调用connect获取signer
}
}, [provider]);
// 监听账户变化和链变化
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
// 用户断开了连接
disconnect();
} else {
setAddress(accounts[0]);
localStorage.setItem('walletAddress', accounts[0]);
}
};
const handleChainChanged = (chainIdHex: string) => {
const newChainId = parseInt(chainIdHex, 16);
setChainId(newChainId);
localStorage.setItem('walletChainId', newChainId.toString());
// 链变化后,signer需要重新获取
if (provider) {
provider.getSigner().then(setSigner);
}
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
};
}, [provider]);
const disconnect = useCallback(() => {
setAddress('');
setSigner(null);
setChainId(0);
localStorage.removeItem('walletAddress');
localStorage.removeItem('walletChainId');
localStorage.removeItem('authToken');
}, []);
注意这个细节: chainChanged事件返回的是十六进制字符串(比如"0x5"),需要转成十进制。我当时直接用了parseInt,但忘记加基数参数,导致"0x5"被解析成0。排查了半天才发现。
完整代码:可直接运行的React Hook
我把上面所有代码整合成一个完整的useWallet Hook,你可以直接复制到项目中使用。
// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';
interface WalletState {
provider: BrowserProvider | null;
signer: JsonRpcSigner | null;
address: string;
chainId: number;
error: string;
isConnecting: boolean;
}
export function useWallet() {
const [state, setState] = useState<WalletState>({
provider: null,
signer: null,
address: '',
chainId: 0,
error: '',
isConnecting: false,
});
// 初始化:检测MetaMask
useEffect(() => {
if (typeof window.ethereum === 'undefined') {
setState(prev => ({ ...prev, error: '请安装MetaMask插件' }));
return;
}
const ethersProvider = new BrowserProvider(window.ethereum);
setState(prev => ({ ...prev, provider: ethersProvider }));
}, []);
// 恢复上次连接
useEffect(() => {
const savedAddress = localStorage.getItem('walletAddress');
if (savedAddress && state.provider) {
setState(prev => ({ ...prev, address: savedAddress }));
}
}, [state.provider]);
// 连接钱包
const connect = useCallback(async () => {
if (!state.provider) {
setState(prev => ({ ...prev, error: 'Provider未初始化' }));
return;
}
setState(prev => ({ ...prev, isConnecting: true, error: '' }));
try {
const accounts = await state.provider.send('eth_requestAccounts', []);
if (accounts.length === 0) {
throw new Error('没有获取到账户');
}
const userAddress = accounts[0];
const userSigner = await state.provider.getSigner();
const network = await state.provider.getNetwork();
const userChainId = Number(network.chainId);
localStorage.setItem('walletAddress', userAddress);
localStorage.setItem('walletChainId', userChainId.toString());
setState(prev => ({
...prev,
address: userAddress,
signer: userSigner,
chainId: userChainId,
isConnecting: false,
}));
} catch (err: any) {
let errorMsg = '连接钱包失败';
if (err.code === 4001) errorMsg = '用户拒绝了连接请求';
else if (err.code === -32002) errorMsg = '请先处理MetaMask中的其他请求';
else if (err.message) errorMsg = err.message;
setState(prev => ({ ...prev, error: errorMsg, isConnecting: false }));
}
}, [state.provider]);
// 断开连接
const disconnect = useCallback(() => {
localStorage.removeItem('walletAddress');
localStorage.removeItem('walletChainId');
localStorage.removeItem('authToken');
setState(prev => ({
...prev,
address: '',
signer: null,
chainId: 0,
error: '',
}));
}, []);
// 监听事件
useEffect(() => {
if (!window.ethereum) return;
const handleAccountsChanged = (accounts: string[]) => {
if (accounts.length === 0) {
disconnect();
} else {
setState(prev => ({ ...prev, address: accounts[0] }));
localStorage.setItem('walletAddress', accounts[0]);
}
};
const handleChainChanged = (chainIdHex: string) => {
const newChainId = parseInt(chainIdHex, 16);
setState(prev => ({ ...prev, chainId: newChainId }));
localStorage.setItem('walletChainId', newChainId.toString());
// 重新获取signer
if (state.provider) {
state.provider.getSigner().then(signer => {
setState(prev => ({ ...prev, signer }));
});
}
};
window.ethereum.on('accountsChanged', handleAccountsChanged);
window.ethereum.on('chainChanged', handleChainChanged);
return () => {
window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
window.ethereum.removeListener('chainChanged', handleChainChanged);
};
}, [state.provider, disconnect]);
// 签名消息
const signMessage = useCallback(async (message: string): Promise<string> => {
if (!state.signer) {
throw new Error('请先连接钱包');
}
try {
return await state.signer.signMessage(message);
} catch (err: any) {
if (err.code === 4001) throw new Error('用户取消了签名');
throw new Error('签名失败: ' + err.message);
}
}, [state.signer]);
return {
...state,
connect,
disconnect,
signMessage,
};
}
使用示例:
// App.tsx
import { useWallet } from './hooks/useWallet';
function App() {
const { address, chainId, error, isConnecting, connect, disconnect, signMessage } = useWallet();
const handleLogin = async () => {
try {
// 假设后端返回nonce
const signature = await signMessage('登录nonce: 123456');
// 发送signature到后端验证
console.log('签名结果:', signature);
} catch (err: any) {
console.error(err.message);
}
};
return (
<div>
{address ? (
<div>
<p>已连接: {address.slice(0, 6)}...{address.slice(-4)}</p>
<p>链ID: {chainId}</p>
<button onClick={handleLogin}>签名登录</button>
<button onClick={disconnect}>断开连接</button>
</div>
) : (
<button onClick={connect} disabled={isConnecting}>
{isConnecting ? '连接中...' : '连接钱包'}
</button>
)}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}
踩坑记录:我实际遇到的4个问题
1. ethers.getDefaultProvider() 在浏览器端报错
- 报错信息:
Error: network error: The method eth_getBlockByNumber does not exist/is not available - 原因:
getDefaultProvider()会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。 - 解决:统一使用
new BrowserProvider(window.ethereum)。
2. 用户拒绝连接后,再次点击连接按钮无反应
- 现象:用户第一次点击"连接钱包"时取消了MetaMask弹窗,再次点击按钮,弹窗不出现了。
- 原因:MetaMask检测到已有挂起的请求,返回错误码
-32002。 - 解决:在catch中处理这个错误,提示用户"请先处理MetaMask中的其他请求",并建议用户刷新页面。
3. 签名时MetaMask弹窗不显示消息内容
- 现象:调用
signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。 - 原因:消息是纯字符串,没有格式化为可读的EIP-712类型数据。
- 解决:对于简单登录,可以使用
personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。
4. 链切换后,signer对象失效
- 现象:用户在MetaMask中切换了网络,但调用
signer.getAddress()时返回了旧地址。 - 原因:signer是在连接时创建的,链变化后需要重新获取signer。
- 解决:在
chainChanged事件监听中,重新调用provider.getSigner()。
小结
连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。
如果你继续深入,可以研究一下:
- 使用
wagmi或RainbowKit等库简化钱包连接 - 实现多链支持,让用户在不同链之间切换
- 集成EIP-712类型数据签名,提升用户体验
希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。